witan-code 0.2.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.
witan_code/graph.py ADDED
@@ -0,0 +1,271 @@
1
+ import fcntl
2
+ import json
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import time
8
+ from pathlib import Path
9
+
10
+ # omnigraph local stores use optimistic concurrency (Lance manifest versions) and
11
+ # are NOT safe for concurrent writers — a manual index racing the
12
+ # PostToolUse/SessionStart reindex hooks can leave HEAD ahead of the manifest.
13
+ # We serialize writes with a per-store advisory lock (prevention) and, as a
14
+ # safety net, retry transient "stale view" conflicts and `omnigraph repair`
15
+ # stores already in the drifted state.
16
+ _WRITE_SUBCOMMANDS = {"mutate", "load", "optimize", "cleanup"}
17
+ _RETRYABLE = ("stale view", "manifest table version", "refresh and retry")
18
+ _NEEDS_REPAIR = ("ahead of manifest", "omnigraph repair")
19
+ _MAX_ATTEMPTS = 8
20
+
21
+
22
+ class OmnigraphClient:
23
+ """Subprocess wrapper for the omnigraph CLI.
24
+
25
+ Copied verbatim from witan (queries_dir default adjusted) so the
26
+ two Layer packages stay independent — no cross-package imports.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ graph_uri: str,
32
+ queries_dir: Path,
33
+ token: str | None = None,
34
+ branch: str | None = None,
35
+ ) -> None:
36
+ self.graph_uri = graph_uri
37
+ self.queries_dir = queries_dir
38
+ self.token = token
39
+ # Target omnigraph branch for query/mutate/load; None = the store's
40
+ # main branch. Loads pass `--from main` so the branch forks lazily on
41
+ # first write (docs/BRANCH_INDEXING.md).
42
+ self.branch = branch
43
+ self._binary = self._find_binary()
44
+
45
+ # ── Public API ────────────────────────────────────────────────
46
+
47
+ def read(
48
+ self,
49
+ query_file: str,
50
+ query_name: str,
51
+ params: dict,
52
+ ) -> list[dict]:
53
+ """Run a named read query. Returns a list of result rows."""
54
+ result = self._run(
55
+ "query",
56
+ "--query",
57
+ str(self.queries_dir / query_file),
58
+ query_name,
59
+ "--params",
60
+ json.dumps(params),
61
+ "--format",
62
+ "json",
63
+ )
64
+ if not result.strip():
65
+ return []
66
+ try:
67
+ parsed = json.loads(result)
68
+ except json.JSONDecodeError as exc:
69
+ raise RuntimeError(f"omnigraph returned non-JSON: {result!r}") from exc
70
+ # v0.7.0 wraps results in {rows: [...], columns: [...], ...}
71
+ rows = parsed.get("rows", parsed) if isinstance(parsed, dict) else parsed
72
+ # strip alias prefixes: "s.name" → "name"
73
+ return [{k.split(".", 1)[-1]: v for k, v in row.items()} for row in rows]
74
+
75
+ def change(
76
+ self,
77
+ query_file: str,
78
+ query_name: str,
79
+ params: dict,
80
+ ) -> None:
81
+ """Run a named mutation query."""
82
+ self._run(
83
+ "mutate",
84
+ "--query",
85
+ str(self.queries_dir / query_file),
86
+ query_name,
87
+ "--params",
88
+ json.dumps(params),
89
+ )
90
+
91
+ def load(self, records: list[dict], mode: str = "merge") -> None:
92
+ """Bulk-load node/edge records via one ``omnigraph load`` call.
93
+
94
+ Each record is a JSONL line: ``{"type": Node, "data": {...}}`` for a
95
+ node or ``{"edge": Edge, "from": key, "to": key}`` for an edge. This
96
+ replaces thousands of per-record ``mutate`` subprocesses with a single
97
+ invocation — essential for indexing large repositories.
98
+ """
99
+ if not records:
100
+ return
101
+ fd, tmp = tempfile.mkstemp(suffix=".jsonl", prefix="codegraph-load-")
102
+ try:
103
+ with os.fdopen(fd, "w") as fh:
104
+ for record in records:
105
+ fh.write(json.dumps(record))
106
+ fh.write("\n")
107
+ self._run("load", "--data", tmp, "--mode", mode)
108
+ finally:
109
+ Path(tmp).unlink(missing_ok=True)
110
+
111
+ def optimize(self) -> str:
112
+ """Compact small Lance fragments across every table (non-destructive).
113
+
114
+ Every load()/mutate() appends a new tiny Lance fragment + manifest
115
+ version; an un-compacted store bloats until *opening* it dominates
116
+ query latency (a fixed per-query cost regardless of rows) — the same
117
+ failure mode witan's own store hit (#98). ``omnigraph optimize``
118
+ collapses the fragments. It takes the store's write lock and can run
119
+ for tens of seconds on a bloated store, so callers must throttle it
120
+ and keep it off the indexing hot path (see ``witan_code.maintenance``).
121
+ """
122
+ return self._run("optimize")
123
+
124
+ def cleanup(self, *, keep: int | None = None, older_than: str | None = None) -> str:
125
+ """Reclaim disk by removing old Lance versions (**destructive**).
126
+
127
+ ``optimize`` compacts fragments but leaves old versions behind, so disk
128
+ stays large until they are GC'd. ``cleanup`` removes them, keeping the
129
+ most recent ``keep`` versions per table and/or those newer than
130
+ ``older_than`` (a Go-style duration like ``7d``). At least one bound
131
+ must be given (omnigraph requires it). ``--confirm`` is passed so it
132
+ actually runs.
133
+ """
134
+ if keep is None and older_than is None:
135
+ raise ValueError("cleanup requires keep and/or older_than")
136
+ args = ["--confirm"]
137
+ if keep is not None:
138
+ args += ["--keep", str(keep)]
139
+ if older_than is not None:
140
+ args += ["--older-than", older_than]
141
+ return self._run("cleanup", *args)
142
+
143
+ # ── Internals ─────────────────────────────────────────────────
144
+
145
+ # ── Branch operations ─────────────────────────────────────────
146
+
147
+ def list_branches(self) -> list[str]:
148
+ """Names of all branches on this store (includes ``main``)."""
149
+ result = self._run("branch", "list", "--json")
150
+ try:
151
+ parsed = json.loads(result)
152
+ except json.JSONDecodeError:
153
+ return []
154
+ rows = parsed.get("branches", parsed) if isinstance(parsed, dict) else parsed
155
+ if not isinstance(rows, list):
156
+ return []
157
+ out: list[str] = []
158
+ for row in rows:
159
+ name = row.get("name") if isinstance(row, dict) else row
160
+ if isinstance(name, str):
161
+ out.append(name)
162
+ return out
163
+
164
+ def ensure_branch(self) -> None:
165
+ """Create ``self.branch`` from main if it doesn't exist yet.
166
+
167
+ Needed before the first *read* on a new branch — reads never fork
168
+ (only ``load --from`` does), so a read against a missing branch errors.
169
+ """
170
+ if self.branch is None or self.branch in self.list_branches():
171
+ return
172
+ self._run("branch", "create", self.branch, "--from", "main")
173
+
174
+ def delete_branch(self, name: str) -> None:
175
+ self._run("branch", "delete", name, "--yes")
176
+
177
+ # ── Internals ─────────────────────────────────────────────────
178
+
179
+ def _branch_args(self, subcommand: str) -> list[str]:
180
+ # optimize/cleanup compact the whole store (every branch), not a
181
+ # single one, so they never take --branch even on a branched client.
182
+ if self.branch is None or subcommand in ("branch", "optimize", "cleanup"):
183
+ return []
184
+ if subcommand == "load":
185
+ return ["--branch", self.branch, "--from", "main"]
186
+ return ["--branch", self.branch]
187
+
188
+ def _run(self, subcommand: str, *args: str) -> str:
189
+ quiet = ["--quiet"] if subcommand in _WRITE_SUBCOMMANDS else []
190
+ cmd = [
191
+ self._binary,
192
+ subcommand,
193
+ "--store",
194
+ self.graph_uri,
195
+ *quiet,
196
+ *self._branch_args(subcommand),
197
+ *args,
198
+ ]
199
+ env = dict(os.environ)
200
+ if self.token:
201
+ env["OMNIGRAPH_SERVER_BEARER_TOKEN"] = self.token
202
+
203
+ lock_fh = self._acquire_write_lock(subcommand)
204
+ try:
205
+ for attempt in range(_MAX_ATTEMPTS):
206
+ result = subprocess.run(cmd, capture_output=True, text=True, env=env)
207
+ if result.returncode == 0:
208
+ return result.stdout
209
+ err = result.stderr
210
+ if attempt + 1 < _MAX_ATTEMPTS:
211
+ if any(m in err for m in _NEEDS_REPAIR):
212
+ self._repair(env)
213
+ continue
214
+ if any(m in err for m in _RETRYABLE):
215
+ time.sleep(0.05 * (attempt + 1))
216
+ continue
217
+ raise RuntimeError(
218
+ f"omnigraph {subcommand} failed (exit {result.returncode}):\n"
219
+ f"{err.strip()}"
220
+ )
221
+ raise AssertionError("unreachable") # pragma: no cover
222
+ finally:
223
+ if lock_fh is not None:
224
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_UN)
225
+ lock_fh.close()
226
+
227
+ def _acquire_write_lock(self, subcommand: str):
228
+ """Hold a per-store exclusive lock for write subcommands (local stores)."""
229
+ if subcommand not in _WRITE_SUBCOMMANDS or self.graph_uri.startswith(
230
+ ("http://", "https://", "s3://")
231
+ ):
232
+ return None
233
+ lock_path = Path(f"{self.graph_uri}.lock")
234
+ lock_path.parent.mkdir(parents=True, exist_ok=True)
235
+ fh = open(lock_path, "w") # noqa: SIM115 — released in _run's finally
236
+ fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
237
+ return fh
238
+
239
+ def _repair(self, env: dict) -> None:
240
+ """Reconcile manifest/HEAD drift so the retried write can proceed."""
241
+ subprocess.run(
242
+ [
243
+ self._binary,
244
+ "repair",
245
+ "--store",
246
+ self.graph_uri,
247
+ "--confirm",
248
+ "--force",
249
+ "--quiet",
250
+ ],
251
+ capture_output=True,
252
+ text=True,
253
+ env=env,
254
+ )
255
+
256
+ @staticmethod
257
+ def _find_binary() -> str:
258
+ binary = shutil.which("omnigraph")
259
+ if binary is not None:
260
+ return binary
261
+ # MCP servers are often launched by a desktop app or IDE extension
262
+ # whose process doesn't inherit a shell PATH — `witan setup`/
263
+ # `witan-code setup` always install to this fixed location, so check
264
+ # it directly rather than relying on PATH alone.
265
+ fallback = Path.home() / ".local" / "bin" / "omnigraph"
266
+ if fallback.exists():
267
+ return str(fallback)
268
+ raise RuntimeError(
269
+ "omnigraph binary not found. Install via: witan-code setup "
270
+ "(or `witan setup`, if witan is also installed)"
271
+ )
witan_code/hooks.py ADDED
@@ -0,0 +1,116 @@
1
+ """SessionStart and PostToolUse hook logic, as plain Python.
2
+
3
+ Ported from the former codegraph-session-init.sh / codegraph-reindex.sh bash
4
+ scripts so hook invocation is a portable CLI command everywhere the
5
+ `witan-code` binary installs — Windows included, where bash/setsid don't
6
+ exist — matching the bare `witan-code inject-context`/`checkpoint` pattern
7
+ this package's other two hooks already use (and witan's own `witan
8
+ inject-context`/`session-checkpoint`).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import subprocess
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from . import indexer
19
+ from . import repo as repo_module
20
+ from ._detach import popen_detached
21
+ from .context import _lock_path, _project_dir
22
+
23
+
24
+ def session_init() -> None:
25
+ """SessionStart: seed/refresh the whole repo's code graph in the
26
+ background, at most once across overlapping sessions.
27
+
28
+ Best-effort and non-blocking: skips non-git directories, never raises,
29
+ and returns immediately — the actual indexing happens in a fully detached
30
+ child process (see :func:`_index_and_unlock`), which is what makes this
31
+ safe to call from a hook that must not block session start.
32
+ """
33
+ project_dir = _project_dir()
34
+ if repo_module.root(project_dir) is None:
35
+ return
36
+
37
+ lock = _lock_path(project_dir)
38
+ try:
39
+ lock.mkdir(parents=True)
40
+ except FileExistsError:
41
+ return # another session is already indexing this repo
42
+ except OSError:
43
+ return
44
+
45
+ try:
46
+ popen_detached(
47
+ [
48
+ sys.executable,
49
+ "-m",
50
+ "witan_code",
51
+ "_index-and-unlock",
52
+ str(project_dir),
53
+ str(lock),
54
+ ],
55
+ stdin=subprocess.DEVNULL,
56
+ stdout=subprocess.DEVNULL,
57
+ stderr=subprocess.DEVNULL,
58
+ )
59
+ except OSError:
60
+ try:
61
+ lock.rmdir()
62
+ except OSError:
63
+ pass
64
+
65
+
66
+ def index_and_unlock(target: Path, lock: Path) -> None:
67
+ """Run by the detached child :func:`session_init` spawns — never called
68
+ directly by a hook. Indexes ``target``, then always releases ``lock``,
69
+ however indexing turns out, so a parse failure can't wedge the lock and
70
+ permanently block future sessions from indexing this repo."""
71
+ try:
72
+ indexer.index_path(target, force=False)
73
+ except Exception: # noqa: BLE001 — a bad repo must not leave the lock held
74
+ pass
75
+ finally:
76
+ try:
77
+ lock.rmdir()
78
+ except OSError:
79
+ pass
80
+
81
+
82
+ def reindex_hook(payload: str) -> None:
83
+ """PostToolUse (matcher ``Edit|Write``): incrementally reindex the edited
84
+ file. ``payload`` is the raw hook JSON read from stdin. Foreground and
85
+ fast (a single file), unlike :func:`session_init`'s full-repo background
86
+ index — mirrors the synchronous ``witan-code index <file>`` the old
87
+ reindex hook script ran.
88
+
89
+ Best-effort: a missing/malformed payload, an untracked tool, or a parse
90
+ failure all degrade to a silent no-op rather than interrupting the agent.
91
+ """
92
+ try:
93
+ data = json.loads(payload) if payload else {}
94
+ except json.JSONDecodeError:
95
+ return
96
+ tool_input = data.get("tool_input") if isinstance(data, dict) else None
97
+ if not isinstance(tool_input, dict):
98
+ return
99
+ raw = (
100
+ tool_input.get("file_path")
101
+ or tool_input.get("path")
102
+ or tool_input.get("filename")
103
+ )
104
+ if not isinstance(raw, str) or not raw:
105
+ return
106
+
107
+ path = Path(raw)
108
+ if not path.is_absolute():
109
+ path = Path.cwd() / path
110
+ if not path.is_file():
111
+ return
112
+
113
+ try:
114
+ indexer.index_path(path, force=False)
115
+ except Exception: # noqa: BLE001 — a parse failure must not fail the hook
116
+ pass