sql-code-graph 1.2.2__py3-none-any.whl → 1.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: sql-code-graph
3
- Version: 1.2.2
3
+ Version: 1.3.0
4
4
  Summary: SQL code graph analyzer and lineage tracer
5
5
  Project-URL: Homepage, https://github.com/Warhorze/sql-code-graph
6
6
  Project-URL: Repository, https://github.com/Warhorze/sql-code-graph
@@ -1,17 +1,17 @@
1
- sqlcg/__init__.py,sha256=6jzbRxs0WG3Yq2KYIDG9bagxRZvhbhp6agviO4_nXwM,115
1
+ sqlcg/__init__.py,sha256=Zwww9eU1OV0KsLzksvKQE65bNgEFPPwTUb0XCSW4JIE,115
2
2
  sqlcg/__main__.py,sha256=1YoFLcqEgTwYq1J3TbUwpkdG0zeeLIf2fJvwWI-CLFU,109
3
3
  sqlcg/cli/__init__.py,sha256=W8fD0LpMq2xm_5WKGNMvJh2WBL1ho5E8hUeAqXQYT1g,28
4
4
  sqlcg/cli/main.py,sha256=WmdTjsOlz1ozi2Y3Aq4ezR_FCRl-Lc1YOKw3_d48dlY,1650
5
5
  sqlcg/cli/commands/__init__.py,sha256=oSHtr6VD-jNubOjuCQyZj2tBppjMEpQDh-IGQ8of9eA,30
6
6
  sqlcg/cli/commands/analyze.py,sha256=xr3RHmO4eFTP4VKZn4DAx3BJzSi60_DIZmdE-QLfsHI,13601
7
- sqlcg/cli/commands/db.py,sha256=iUPnSxkSjt2EZzj_zmXiIyM39NTTCpazD4Y8Q9iPZEc,7725
7
+ sqlcg/cli/commands/db.py,sha256=paW096LE8fMpxPNvoW9zHmZ9xjb-dEbwVmfHR1bcg7U,8676
8
8
  sqlcg/cli/commands/find.py,sha256=zTYN9goILalYq4R9x6lIR6MmNcydDbR17UXkx1gPRsI,2913
9
9
  sqlcg/cli/commands/gain.py,sha256=Kws76u1na2XxmbWN_YWrPaYHYmYBLC6DDDf7xqnltqc,9126
10
- sqlcg/cli/commands/git.py,sha256=yMgWOuoTCTBr2P1QgmghRi5ikmUYHuxDUVyBDYerErw,5728
11
- sqlcg/cli/commands/index.py,sha256=xMnxKDiUt5LH_3lKAotoRctL4VSOvcw7Gq--idLPtm0,11091
10
+ sqlcg/cli/commands/git.py,sha256=9a8T2FVxcAHq1H6Cslaq34t10w9fBGf4T2reiLk33t8,6135
11
+ sqlcg/cli/commands/index.py,sha256=1ElHRPkn-CPprIz869A__98aSyf-P5E56PVA0lq7xBw,17462
12
12
  sqlcg/cli/commands/install.py,sha256=KNABvrLbamPyYnmnVdCaM_MNezbDc-pr6IkignCWI8k,9186
13
- sqlcg/cli/commands/mcp.py,sha256=2gDsNvtj1Ql7PkjX9dHWAzOK0uCPRR5DGdBAzJa8PIU,6005
14
- sqlcg/cli/commands/reindex.py,sha256=n1mQTYAZshtCKPgpR12S6ZMCqO3cSUtpCXjzb1PuZxU,11857
13
+ sqlcg/cli/commands/mcp.py,sha256=QYaupf69lLpYzIoqsPJoCPiAggLVkYBzwpuOLRzxJDU,9140
14
+ sqlcg/cli/commands/reindex.py,sha256=Ki5BHbI_nuM6ML0-w7qnVZqSAhELSpsFaUo6BVqzhRo,13812
15
15
  sqlcg/cli/commands/report.py,sha256=JU0qjyMxwOukE7bN3XvvIzOI7zMg_Gsnvk_8F6pKNpA,4915
16
16
  sqlcg/cli/commands/uninstall.py,sha256=IYwQaqnMmmzW0Nlls40wD-L3tVkMgKIMRXUkcXPMUc4,9398
17
17
  sqlcg/cli/commands/watch.py,sha256=7N6c-QuvxAEGHzDZ0C3CU2BkHSraZW9YtgoFnz7SaQo,2373
@@ -20,7 +20,7 @@ sqlcg/core/config.py,sha256=qNR-yXkfYfS8Y8WX4Qo6Zkq8PPP_ZiTrvX0DLmEZkGY,14821
20
20
  sqlcg/core/freshness.py,sha256=gRb8pRPw5SdIUxAYkMXIJ00DTdQ6CegRZPAvWnv0rU0,4575
21
21
  sqlcg/core/graph_db.py,sha256=Aa85wPFg26H-Ud9SrZyxCHH-99iitAI5S3X9T_62Yyw,7957
22
22
  sqlcg/core/jobs.py,sha256=Je-fCdSKRgiSsv1W8SgNAlp36a7t7-pJZ-qKPbka9OE,3298
23
- sqlcg/core/kuzu_backend.py,sha256=3kL8bGEQm70fuxYUdt1p7fsY12lCLQ07x01NYg6FOGA,16821
23
+ sqlcg/core/kuzu_backend.py,sha256=PHW7VqI7oCLKsHnm4OoBoNnE2XT19ohxUpQMMIJnjlY,17038
24
24
  sqlcg/core/neo4j_backend.py,sha256=AM1TncP9GBGph-rSHwalZPmGUV2kFILzaJP-PSB0UYw,8437
25
25
  sqlcg/core/queries.cypher,sha256=cvPOVe5GUOzJN4bxUvDxNI--xIIP8gm42TR-gUnea4U,4685
26
26
  sqlcg/core/queries.py,sha256=gkl4bhkZM8FsvbSA-IaK17sRFcO3hB5YlVCemkCXgWM,2064
@@ -38,7 +38,7 @@ sqlcg/lineage/__init__.py,sha256=Da1DlYwtK13WHv_RnHjAtNkHTOuFbhxqCjT1Le7DsWM,46
38
38
  sqlcg/lineage/aggregator.py,sha256=LVyNcmvLBHWbh8SrDsJJBKd7sLg3-2NhEWwEndG7Jbc,4144
39
39
  sqlcg/lineage/schema_resolver.py,sha256=iXt6LYF6UVWsGUpcfbmjmGn9wCgXl721lTGf_8AaWcc,7320
40
40
  sqlcg/metrics/__init__.py,sha256=hLJ6wm4St8qqYwKh3o9QG7lcEt1BEYM31ccqO9tGpIg,133
41
- sqlcg/metrics/store.py,sha256=BaMf7QYTmYMlX_Jzi1GNU8R2sMVkWdn07f-ZSndtcNk,8879
41
+ sqlcg/metrics/store.py,sha256=O1UoBu4dIZYIgNBqLWIyL3vLAnSgWrJinOgSLhQigHM,10596
42
42
  sqlcg/parsers/__init__.py,sha256=AamA8wBbDZV9_zEtZCI4Hyen5UAVKHmBwjTghTt2PZE,785
43
43
  sqlcg/parsers/ansi_parser.py,sha256=mGZvijMOMQ4i1BybpwU29a8jnIGViefhy9fxzkSpsRM,17193
44
44
  sqlcg/parsers/base.py,sha256=IiOkVsm6jz9-48RqDCXiW-UXAraNxQ4pKXvSA7aolnA,49907
@@ -53,14 +53,15 @@ sqlcg/server/exceptions.py,sha256=EONw34icOByCTpppSQrvQBW6asc4hfqaGDCAFjv96II,46
53
53
  sqlcg/server/models.py,sha256=l7ORy6sbtzBW1y3qVaeLwEukbyAgBkz9S5VIm2q4b24,19378
54
54
  sqlcg/server/noise_filter.py,sha256=idSBGgdKWWccJdpOo9qgbM2350Oew-2l5W6Yc9GYQqY,6337
55
55
  sqlcg/server/read_client.py,sha256=ncoJK7UckGhWtN9bv1CgViNMNtac96zBUE7RPYQ8_WI,7783
56
- sqlcg/server/server.py,sha256=QBb7N4lc5o7KBh1Ik6MAiOvBV8SW81ahtkM1ZOVuyXE,18983
56
+ sqlcg/server/server.py,sha256=9SilAu18cHTZUQvSo8S8e9CxSM6CUlG8rX2OnHBUh1Y,24178
57
57
  sqlcg/server/skill.py,sha256=GE8eeimk6yiGGJ74erGypqYAviur5peSR6_2a4QQWVM,12828
58
- sqlcg/server/tools.py,sha256=JvijDC0h5uHjZyZUIZq9sztNG3W5sr-Yy5rHwOVuJec,66642
58
+ sqlcg/server/tools.py,sha256=DTaDwZQmL6jzNF8vgJeNhVMGRoIrrpcxNiXOMpWvx-A,72401
59
+ sqlcg/server/writer.py,sha256=gagS6EVG8A4OKpf0GAb--MUielnaiIULwVVik58pT6k,24693
59
60
  sqlcg/utils/__init__.py,sha256=--iqt5ThTXmT8Wz7da8hs3n0zDfYPl8P-z5OgRJ_77E,154
60
61
  sqlcg/utils/hashing.py,sha256=H25-sYfxHKb3_IERFnHyAIYNiXN470Oqo5sJT_D3YOA,438
61
62
  sqlcg/utils/ignore.py,sha256=wJjwa0mjnQ_xJExOUxk25y00g065XmmzJapqV3ifD5o,1151
62
63
  sqlcg/utils/logging.py,sha256=u0fCmYsLj9o81vawm3xZTHaw68GQYVm7JxG-gP81u8A,840
63
- sql_code_graph-1.2.2.dist-info/METADATA,sha256=e4llIRyH4QYoHbIznQIpZ_wreqsuscvDzuLBVQBv33M,14148
64
- sql_code_graph-1.2.2.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
65
- sql_code_graph-1.2.2.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
66
- sql_code_graph-1.2.2.dist-info/RECORD,,
64
+ sql_code_graph-1.3.0.dist-info/METADATA,sha256=2QJqn9Q606zlPKDgeYrNzq0d1QW35jIRCxmvDCvvNIE,14148
65
+ sql_code_graph-1.3.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
66
+ sql_code_graph-1.3.0.dist-info/entry_points.txt,sha256=Wfe49sVzV9p4eVFGo5RxcV-frr3HOP0yzzst8JBxQLQ,46
67
+ sql_code_graph-1.3.0.dist-info/RECORD,,
sqlcg/__init__.py CHANGED
@@ -1,5 +1,5 @@
1
1
  """SQL Code Graph - SQL lineage and dependency analysis tool."""
2
2
 
3
- __version__ = "1.2.2"
3
+ __version__ = "1.3.0"
4
4
 
5
5
  __all__ = ["__version__"]
sqlcg/cli/commands/db.py CHANGED
@@ -45,6 +45,29 @@ def db_reset( # noqa: B008
45
45
  repo: str | None = typer.Option(None, "--repo", help="Reset only this repo path"), # noqa: B008
46
46
  ) -> None:
47
47
  """Wipe the database or a single repo's subgraph."""
48
+ import socket as _socket
49
+
50
+ from sqlcg.server.control import sock_path
51
+
52
+ # Step 3.4 (OD-3 / W2): refuse cleanly when a server is live — both the
53
+ # full reset and the --repo partial reset open the RW backend directly and
54
+ # would fight the server's lock. Guard runs BEFORE either destructive branch.
55
+ sp = sock_path()
56
+ if sp.exists():
57
+ try:
58
+ with _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) as s:
59
+ s.settimeout(1)
60
+ s.connect(str(sp))
61
+ # Connection succeeded — a server is live.
62
+ console.print(
63
+ "[red]A server is running on this database; stop it first "
64
+ "('sqlcg mcp stop') before resetting the database.[/red]"
65
+ )
66
+ raise typer.Exit(1)
67
+ except (FileNotFoundError, ConnectionRefusedError, OSError):
68
+ # No live server — fall through to destructive action.
69
+ pass
70
+
48
71
  if repo:
49
72
  # Delete all nodes for this repo (use run_write for mutation)
50
73
  with get_backend() as backend:
sqlcg/cli/commands/git.py CHANGED
@@ -33,7 +33,7 @@ _HOOKS: list[_HookSpec] = [
33
33
  '[ "$3" = "1" ] || exit 0\n'
34
34
  '{sqlcg_bin} reindex --from "$1" --to "$2"'
35
35
  ' "$(git rev-parse --show-toplevel)" --dialect auto --quiet --notify'
36
- ' || echo "sqlcg: graph not updated (server busy/locked)'
36
+ ' || echo "sqlcg: graph not updated (reindex failed)'
37
37
  " -- run 'sqlcg mcp status'\" >&2\n"
38
38
  ),
39
39
  ),
@@ -50,10 +50,10 @@ PREV=$(git rev-parse --verify --quiet ORIG_HEAD)
50
50
  TOP=$(git rev-parse --show-toplevel)
51
51
  if [ -n "$PREV" ]; then
52
52
  {sqlcg_bin} reindex --from "$PREV" --to HEAD "$TOP" --dialect auto --quiet --notify \\
53
- || echo "sqlcg: graph not updated (server busy/locked) -- run 'sqlcg mcp status'" >&2
53
+ || echo "sqlcg: graph not updated (reindex failed) -- run 'sqlcg mcp status'" >&2
54
54
  else
55
55
  {sqlcg_bin} reindex "$TOP" --dialect auto --quiet --notify \\
56
- || echo "sqlcg: graph not updated (server busy/locked) -- run 'sqlcg mcp status'" >&2
56
+ || echo "sqlcg: graph not updated (reindex failed) -- run 'sqlcg mcp status'" >&2
57
57
  fi
58
58
  """,
59
59
  ),
@@ -101,7 +101,14 @@ def _install_single_hook(hooks_dir: Path, spec: _HookSpec, sqlcg_bin: str) -> No
101
101
  if hook_path.exists():
102
102
  existing_content = hook_path.read_text()
103
103
  if spec.sentinel in existing_content:
104
- # Already installed — idempotent, skip silently
104
+ if existing_content == script:
105
+ # Byte-identical current template — true idempotency, silent skip.
106
+ return
107
+ # Sentinel present but content differs: sqlcg-owned but stale hook.
108
+ # Overwrite with the current rendered template and report the upgrade.
109
+ hook_path.write_text(script)
110
+ hook_path.chmod(0o755)
111
+ console.print(f"[green]Upgraded git hook:[/green] .git/hooks/{spec.filename}")
105
112
  return
106
113
  else:
107
114
  # Foreign hook without sqlcg sentinel
@@ -1,6 +1,8 @@
1
1
  """Index command for scanning and indexing SQL files."""
2
2
 
3
+ import json
3
4
  import os
5
+ import socket as _socket
4
6
  from pathlib import Path
5
7
 
6
8
  import typer
@@ -19,6 +21,10 @@ from sqlcg.indexer.indexer import Indexer
19
21
 
20
22
  console = Console()
21
23
 
24
+ # Socket timeout for the index-via-server path.
25
+ # Generous budget: full index of a large repo can take several minutes.
26
+ _INDEX_SOCKET_TIMEOUT_S = 600
27
+
22
28
 
23
29
  def index_cmd( # noqa: B008
24
30
  path: Path = typer.Argument(..., help="Directory to index"), # noqa: B008
@@ -71,9 +77,24 @@ def index_cmd( # noqa: B008
71
77
  "Marks freshness as 'indexed with working-tree changes'."
72
78
  ),
73
79
  ),
80
+ detach: bool = typer.Option( # noqa: B008
81
+ False,
82
+ "--detach",
83
+ help=(
84
+ "When routing through a live server, return immediately after enqueueing "
85
+ "(fire-and-forget). Default is to wait for the index to complete."
86
+ ),
87
+ ),
74
88
  ) -> None:
75
89
  """Index SQL files in a directory.
76
90
 
91
+ When a server is live on this DB, the index is routed through the server's
92
+ control socket so the DB is never opened directly (avoids lock contention).
93
+ Use --detach to enqueue and return immediately (fire-and-forget).
94
+
95
+ With no server live, falls back to the direct-write path unchanged
96
+ (zero-config small-repo invariant).
97
+
77
98
  Schema aliases (staging schema → canonical schema) can be configured in
78
99
  .sqlcg.toml under sqlcg.schema_aliases, e.g. da_tmp = "da".
79
100
  """
@@ -85,6 +106,26 @@ def index_cmd( # noqa: B008
85
106
  logging.getLogger("sqlcg").setLevel(level)
86
107
  logging.getLogger("sqlglot").setLevel(level)
87
108
 
109
+ # Resolve path early so socket routing uses the absolute path.
110
+ path = path.resolve()
111
+
112
+ # Resolve dialect before routing so the WriterRequest always carries a concrete
113
+ # dialect (never the literal sentinel "auto"). Bug A: the route call was before
114
+ # this resolution, causing the server to receive "auto" and fail with
115
+ # "Unknown dialect 'auto'" on every server-routed index.
116
+ if dialect == "auto":
117
+ dialect = get_dialect(path)
118
+
119
+ # Step 3.2 — probe for a live server and route through the socket if present.
120
+ _routed = _try_route_index_via_server(
121
+ path=path,
122
+ dialect=dialect,
123
+ wait=not detach,
124
+ quiet=quiet,
125
+ )
126
+ if _routed:
127
+ return
128
+
88
129
  # Route parse warnings to stderr (--verbose) or to the configured log file.
89
130
  sqlcg_log = logging.getLogger("sqlcg")
90
131
 
@@ -117,10 +158,6 @@ def index_cmd( # noqa: B008
117
158
  if buffer_pool_size > 0:
118
159
  os.environ["SQLCG_BUFFER_POOL_MB"] = str(buffer_pool_size)
119
160
 
120
- # Resolve dialect: 'auto' reads from .sqlcg.toml, otherwise use provided value
121
- if dialect == "auto":
122
- dialect = get_dialect(path)
123
-
124
161
  if not quiet and not config_file_present(path):
125
162
  console.print(
126
163
  f"[yellow]No .sqlcg.toml found at {path}/.sqlcg.toml — "
@@ -172,6 +209,132 @@ def index_cmd( # noqa: B008
172
209
  )
173
210
 
174
211
 
212
+ def _try_route_index_via_server(
213
+ *,
214
+ path: Path,
215
+ dialect: str | None,
216
+ wait: bool,
217
+ quiet: bool,
218
+ ) -> bool:
219
+ """Probe for a live server and route the index through the socket if found.
220
+
221
+ Returns True if the index was handled via the server (caller should return).
222
+ Returns False if no server is live (caller should fall through to direct path).
223
+ """
224
+ from sqlcg.server.control import sock_path
225
+
226
+ sp = sock_path()
227
+ if not sp.exists():
228
+ return False
229
+
230
+ payload = {
231
+ "op": "index",
232
+ "root": str(path),
233
+ "dialect": dialect,
234
+ "wait": wait,
235
+ "requested_by": "cli",
236
+ }
237
+ payload_bytes = json.dumps(payload).encode()
238
+ frame = f"{len(payload_bytes)}\n".encode() + payload_bytes
239
+
240
+ try:
241
+ with _socket.socket(_socket.AF_UNIX, _socket.SOCK_STREAM) as s:
242
+ s.settimeout(_INDEX_SOCKET_TIMEOUT_S)
243
+ s.connect(str(sp))
244
+ s.sendall(frame)
245
+
246
+ if not wait:
247
+ # Fire-and-forget: read one framed acknowledgement frame.
248
+ f = s.makefile("rb")
249
+ length_line = f.readline()
250
+ if length_line:
251
+ try:
252
+ body_len = int(length_line.strip())
253
+ resp_bytes = f.read(body_len)
254
+ resp = json.loads(resp_bytes)
255
+ if "error" in resp:
256
+ err = resp["error"]
257
+ if "SQLCG_DB_PATH" in err or "write lock" in err:
258
+ console.print(f"[red]{err}[/red]")
259
+ else:
260
+ console.print(f"[red]Server error: {err}[/red]")
261
+ raise typer.Exit(1)
262
+ if not quiet:
263
+ pos = resp.get("position", "?")
264
+ console.print(f"[green]Queued via server[/green] (position {pos})")
265
+ except (ValueError, json.JSONDecodeError):
266
+ pass
267
+ return True
268
+
269
+ # wait=True: stream framed frames until done:true.
270
+ f = s.makefile("rb")
271
+ with Progress(
272
+ SpinnerColumn(),
273
+ TextColumn("[progress.description]{task.description}"),
274
+ BarColumn(),
275
+ MofNCompleteColumn(),
276
+ TimeRemainingColumn(),
277
+ console=console,
278
+ redirect_stderr=True,
279
+ ) as progress:
280
+ task = progress.add_task("Indexing via server", total=None)
281
+
282
+ while True:
283
+ length_line = f.readline()
284
+ if not length_line:
285
+ break
286
+ try:
287
+ body_len = int(length_line.strip())
288
+ except ValueError:
289
+ break
290
+ frame_bytes = f.read(body_len)
291
+ frame_resp = json.loads(frame_bytes)
292
+
293
+ if frame_resp.get("done"):
294
+ if not frame_resp.get("ok"):
295
+ err = frame_resp.get("error", "unknown error")
296
+ if "SQLCG_DB_PATH" in err or "write lock" in err:
297
+ console.print(f"[red]{err}[/red]")
298
+ else:
299
+ console.print(f"[red]Server index error: {err}[/red]")
300
+ raise typer.Exit(1)
301
+ srv_summary = frame_resp.get("summary", {})
302
+ if not quiet:
303
+ console.print(
304
+ f"[green]Indexed via server[/green] "
305
+ f"{srv_summary.get('files_parsed', '?')} files — "
306
+ f"{srv_summary.get('tables_found', '?')} tables, "
307
+ f"{srv_summary.get('lineage_edges_created', '?')} edges"
308
+ )
309
+ break
310
+ # Progress frame
311
+ files_done = frame_resp.get("files_done", 0)
312
+ files_total = frame_resp.get("files_total")
313
+ if files_total:
314
+ progress.update(task, completed=files_done, total=files_total)
315
+
316
+ return True
317
+
318
+ except TimeoutError:
319
+ import sys as _sys
320
+
321
+ print(
322
+ f"Server is still applying the index (timed out waiting after "
323
+ f"{_INDEX_SOCKET_TIMEOUT_S}s); the graph will update when it finishes "
324
+ "— check 'sqlcg mcp status'.",
325
+ file=_sys.stderr,
326
+ )
327
+ raise typer.Exit(0) from None
328
+ except (FileNotFoundError, ConnectionRefusedError, OSError):
329
+ # No live server — fall through to direct path.
330
+ return False
331
+ except typer.Exit:
332
+ raise
333
+ except Exception as exc:
334
+ console.print(f"[red]Socket routing failed: {exc}[/red]")
335
+ raise typer.Exit(1) from exc
336
+
337
+
175
338
  def _run_index(
176
339
  *,
177
340
  path: Path,
sqlcg/cli/commands/mcp.py CHANGED
@@ -78,7 +78,12 @@ def mcp_status() -> None:
78
78
  """Print server status JSON (connects to control socket).
79
79
 
80
80
  Returns JSON with fields: running, pid, db_path, indexed_sha, head_sha,
81
- stale_by_commits, connected_clients, uptime when a server is live.
81
+ stale_by_commits, connected_clients, uptime, writer_queue when a server
82
+ is live.
83
+
84
+ The status response is length-prefixed framed (v1.3.0, B3) so large
85
+ writer_queue payloads are received in full — the client uses the
86
+ recv-exactly makefile+readline+read(n) pattern, NOT a single recv(4096).
82
87
 
83
88
  When no server is found: {"running": false}.
84
89
  When the PID file exists with a live process but the socket is unavailable:
@@ -89,6 +94,7 @@ def mcp_status() -> None:
89
94
  to the PID-file probe — never hangs or errors on a dead socket.
90
95
  """
91
96
  import socket as _socket
97
+ from datetime import datetime
92
98
 
93
99
  from sqlcg.server.control import is_pid_alive, read_pid, sock_path
94
100
 
@@ -98,8 +104,69 @@ def mcp_status() -> None:
98
104
  s.settimeout(2)
99
105
  s.connect(str(sp))
100
106
  s.sendall(json.dumps({"op": "status"}).encode() + b"\n")
101
- data = s.recv(4096)
102
- console.print_json(data.decode())
107
+ # Framed recv-exactly (B3 / OD-4): read length line then exactly that many bytes.
108
+ # This replaces the old s.recv(4096) which would truncate large writer_queue payloads.
109
+ f = s.makefile("rb")
110
+ length_line = f.readline()
111
+ if length_line:
112
+ try:
113
+ body_len = int(length_line.strip())
114
+ data = f.read(body_len)
115
+ except (ValueError, OSError):
116
+ data = length_line # fallback: treat first line as body
117
+ else:
118
+ data = b""
119
+
120
+ status = json.loads(data.decode())
121
+
122
+ # Pretty-print the base fields.
123
+ console.print_json(json.dumps({k: v for k, v in status.items() if k != "writer_queue"}))
124
+
125
+ # Render the writer_queue block separately for readability.
126
+ wq = status.get("writer_queue")
127
+ if wq:
128
+ console.print("\n[bold]writer_queue[/bold]")
129
+ active = wq.get("active")
130
+ if active:
131
+ console.print(f" active: op={active.get('op')!r} root={active.get('root')!r}")
132
+ prog = wq.get("active_progress", {})
133
+ if prog.get("state") == "running":
134
+ files_done = prog.get("files_done", 0)
135
+ files_total = prog.get("files_total")
136
+ if files_total:
137
+ console.print(f" progress: {files_done}/{files_total} files")
138
+ else:
139
+ console.print(" active: none")
140
+
141
+ pending = wq.get("pending", [])
142
+ console.print(f" pending: {len(pending)}")
143
+
144
+ total_coalesced = wq.get("coalesced_since_start", 0)
145
+ by_reason = wq.get("coalesced_by_reason", {})
146
+ if total_coalesced:
147
+ from sqlcg.server.writer import (
148
+ COALESCE_COLLAPSED_INTO_PENDING_REINDEX,
149
+ COALESCE_REINDEX_DROPPED_INDEX_PENDING,
150
+ COALESCE_SUPERSEDED_BY_INDEX,
151
+ )
152
+
153
+ n_sup = by_reason.get(COALESCE_SUPERSEDED_BY_INDEX, 0)
154
+ n_col = by_reason.get(COALESCE_COLLAPSED_INTO_PENDING_REINDEX, 0)
155
+ n_drop = by_reason.get(COALESCE_REINDEX_DROPPED_INDEX_PENDING, 0)
156
+ console.print(
157
+ f" coalesced: {total_coalesced} "
158
+ f"(superseded_by_index={n_sup}, "
159
+ f"collapsed_into_pending_reindex={n_col}, "
160
+ f"reindex_dropped_index_pending={n_drop})"
161
+ )
162
+ last_at = wq.get("last_coalesce_at")
163
+ last_reason = wq.get("last_coalesce_reason")
164
+ if last_at and last_reason:
165
+ last_human = datetime.fromtimestamp(last_at).strftime("%Y-%m-%d %H:%M:%S")
166
+ console.print(f" last coalesce: {last_reason} at {last_human}")
167
+ else:
168
+ console.print(" coalesced: 0")
169
+
103
170
  except (FileNotFoundError, ConnectionRefusedError, OSError):
104
171
  # Socket unavailable — probe via PID file (R3: stale-socket fall-through)
105
172
  rec = read_pid()