awgit 0.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.
- awgit/__init__.py +53 -0
- awgit/bodies.py +323 -0
- awgit/bridge.py +117 -0
- awgit/capture.py +441 -0
- awgit/cli.py +488 -0
- awgit/data_root.py +30 -0
- awgit/diff.py +82 -0
- awgit/hooks/chain.sh +38 -0
- awgit/hooks/post-commit.d/vcs-capture +38 -0
- awgit/hooks/pre-commit.d/vcs-lease-check +15 -0
- awgit/identity.py +80 -0
- awgit/leases.py +264 -0
- awgit/ledger.py +80 -0
- awgit/mcp.py +94 -0
- awgit/merge.py +331 -0
- awgit/nodeid.py +210 -0
- awgit/oplog.py +221 -0
- awgit/parser.py +186 -0
- awgit/schema.py +190 -0
- awgit/sync.py +180 -0
- awgit-0.1.0.dist-info/METADATA +117 -0
- awgit-0.1.0.dist-info/RECORD +26 -0
- awgit-0.1.0.dist-info/WHEEL +5 -0
- awgit-0.1.0.dist-info/entry_points.txt +2 -0
- awgit-0.1.0.dist-info/licenses/LICENSE +202 -0
- awgit-0.1.0.dist-info/top_level.txt +1 -0
awgit/identity.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Verified-identity → attribution resolution (standalone).
|
|
2
|
+
|
|
3
|
+
The verified actor is the GitHub identity (the Aitherium GitHub OAuth-app login
|
|
4
|
+
via ``gh``). This module makes it the AUTHORITATIVE attribution identity:
|
|
5
|
+
|
|
6
|
+
- ``attribution_id(login)`` → ``github:<login>`` — a deterministic namespace
|
|
7
|
+
so the GitHub identity can never collide with a platform ``user_id``;
|
|
8
|
+
- ``github_email()`` → the gh account's primary email (cached, best-effort).
|
|
9
|
+
|
|
10
|
+
Standalone awgit records attribution only — who changed what, under a verified
|
|
11
|
+
GitHub identity, with a deterministic handle per op. A downstream AitherOS
|
|
12
|
+
reward program that consumes this attribution lives in the AitherOS monorepo's
|
|
13
|
+
internal awgit, not in the public package.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import subprocess
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Dict, Optional
|
|
25
|
+
|
|
26
|
+
from awgit.data_root import vcs_data_root
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_TTL_SEC = 6 * 3600 # refresh the GitHub identity at most this often
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def attribution_id(verified_actor: str) -> str:
|
|
34
|
+
"""The authoritative attribution identity for a verified GitHub login."""
|
|
35
|
+
return f"github:{verified_actor}"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def github_email(data_root: Optional[Path] = None) -> str:
|
|
39
|
+
"""Primary email of the gh-authenticated account (cached, best-effort).
|
|
40
|
+
|
|
41
|
+
Returns "" on any failure (no ``gh``, offline, unauthenticated) — identity
|
|
42
|
+
resolution never blocks attribution.
|
|
43
|
+
"""
|
|
44
|
+
data = data_root or vcs_data_root()
|
|
45
|
+
cache = data / "identity.json"
|
|
46
|
+
if cache.exists():
|
|
47
|
+
try:
|
|
48
|
+
rec = json.loads(cache.read_text(encoding="utf-8"))
|
|
49
|
+
if rec.get("email") and time.time() - rec.get("ts", 0) < _TTL_SEC:
|
|
50
|
+
return rec["email"]
|
|
51
|
+
except (OSError, ValueError):
|
|
52
|
+
logger.debug("vcs: identity email cache unreadable, re-resolving")
|
|
53
|
+
try:
|
|
54
|
+
out = subprocess.run(
|
|
55
|
+
["gh", "api", "user/emails", "--jq", ".[] | select(.primary) | .email"],
|
|
56
|
+
capture_output=True, text=True, encoding="utf-8",
|
|
57
|
+
timeout=5, check=True,
|
|
58
|
+
).stdout.strip().splitlines()
|
|
59
|
+
email = out[0] if out else ""
|
|
60
|
+
except (OSError, subprocess.SubprocessError):
|
|
61
|
+
return ""
|
|
62
|
+
if email:
|
|
63
|
+
try:
|
|
64
|
+
data.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
rec: Dict[str, object] = {"ts": time.time(), "email": email}
|
|
66
|
+
try:
|
|
67
|
+
existing = (
|
|
68
|
+
json.loads(cache.read_text(encoding="utf-8"))
|
|
69
|
+
if cache.exists() else {}
|
|
70
|
+
)
|
|
71
|
+
if existing.get("login"):
|
|
72
|
+
rec["login"] = existing["login"]
|
|
73
|
+
except (OSError, ValueError):
|
|
74
|
+
logger.debug("vcs: identity cache unreadable while merging")
|
|
75
|
+
tmp = cache.with_suffix(".tmp")
|
|
76
|
+
tmp.write_text(json.dumps(rec), encoding="utf-8")
|
|
77
|
+
os.replace(tmp, cache)
|
|
78
|
+
except OSError:
|
|
79
|
+
logger.debug("vcs: identity email cache write failed (best-effort)")
|
|
80
|
+
return email
|
awgit/leases.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Lease registry — conflict PREVENTION for concurrent agents.
|
|
2
|
+
|
|
3
|
+
An agent registers intent ("I am editing nodes/files X") before editing; the
|
|
4
|
+
registry grants or rejects so two agents never collide. Leases are
|
|
5
|
+
heartbeat-renewed TTLs (the ``xPageDriverAt`` pattern): a lease self-heals when
|
|
6
|
+
its holder disappears — expiry frees the target, never a kill switch.
|
|
7
|
+
|
|
8
|
+
All-or-nothing acquire: if ANY requested target is held by another actor's
|
|
9
|
+
ACTIVE lease, the whole batch is rejected naming the conflicting holder.
|
|
10
|
+
|
|
11
|
+
The store is ``Library/Data/vcs/leases.json`` (gitignored). Mutations run under
|
|
12
|
+
the same cross-process ``FileLock`` as the op-log.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import logging
|
|
19
|
+
import os
|
|
20
|
+
import uuid
|
|
21
|
+
from dataclasses import dataclass
|
|
22
|
+
from datetime import datetime, timedelta, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Dict, List, Optional
|
|
25
|
+
|
|
26
|
+
from awgit.data_root import vcs_data_root
|
|
27
|
+
from awgit.oplog import FileLock
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
DEFAULT_TTL_SEC = 300
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _iso(offset_sec: int = 0) -> str:
|
|
35
|
+
return (datetime.now(timezone.utc) + timedelta(seconds=offset_sec)).isoformat()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class Lease:
|
|
40
|
+
lease_id: str
|
|
41
|
+
actor: str
|
|
42
|
+
kind: str # "node" | "path"
|
|
43
|
+
target: str
|
|
44
|
+
granted_ts: str
|
|
45
|
+
expires_ts: str
|
|
46
|
+
heartbeat_ts: str
|
|
47
|
+
ttl_sec: int = DEFAULT_TTL_SEC
|
|
48
|
+
reason: str = ""
|
|
49
|
+
status: str = "active" # active | expired | revoked | released
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> Dict[str, object]:
|
|
52
|
+
return {
|
|
53
|
+
"lease_id": self.lease_id,
|
|
54
|
+
"actor": self.actor,
|
|
55
|
+
"kind": self.kind,
|
|
56
|
+
"target": self.target,
|
|
57
|
+
"granted_ts": self.granted_ts,
|
|
58
|
+
"expires_ts": self.expires_ts,
|
|
59
|
+
"heartbeat_ts": self.heartbeat_ts,
|
|
60
|
+
"ttl_sec": self.ttl_sec,
|
|
61
|
+
"reason": self.reason,
|
|
62
|
+
"status": self.status,
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def from_dict(cls, d: Dict[str, object]) -> "Lease":
|
|
67
|
+
return cls(
|
|
68
|
+
lease_id=str(d["lease_id"]),
|
|
69
|
+
actor=str(d["actor"]),
|
|
70
|
+
kind=str(d.get("kind", "path")),
|
|
71
|
+
target=str(d["target"]),
|
|
72
|
+
granted_ts=str(d.get("granted_ts", "")),
|
|
73
|
+
expires_ts=str(d.get("expires_ts", "")),
|
|
74
|
+
heartbeat_ts=str(d.get("heartbeat_ts", "")),
|
|
75
|
+
ttl_sec=int(d.get("ttl_sec", DEFAULT_TTL_SEC)),
|
|
76
|
+
reason=str(d.get("reason", "")),
|
|
77
|
+
status=str(d.get("status", "active")),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class LeaseConflictError(Exception):
|
|
82
|
+
"""All-or-nothing acquire collided with another actor's active lease."""
|
|
83
|
+
|
|
84
|
+
def __init__(self, holder: "Lease"):
|
|
85
|
+
self.holder = holder
|
|
86
|
+
super().__init__(
|
|
87
|
+
f"lease conflict: {holder.target!r} held by {holder.actor!r} "
|
|
88
|
+
f"until {holder.expires_ts}"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class LeaseRegistry:
|
|
93
|
+
"""Heartbeat-renewed TTL lease registry (durable, cross-process)."""
|
|
94
|
+
|
|
95
|
+
def __init__(self, data_root: Optional[Path] = None) -> None:
|
|
96
|
+
self._data_root = data_root or vcs_data_root()
|
|
97
|
+
self._path = self._data_root / "leases.json"
|
|
98
|
+
self._leases: Dict[str, Lease] = {}
|
|
99
|
+
self._reload()
|
|
100
|
+
|
|
101
|
+
# ── persistence (always under the store lock) ────────────────────────
|
|
102
|
+
|
|
103
|
+
def _reload(self) -> None:
|
|
104
|
+
self._leases = {}
|
|
105
|
+
if not self._path.exists():
|
|
106
|
+
return
|
|
107
|
+
try:
|
|
108
|
+
payload = json.loads(self._path.read_text(encoding="utf-8"))
|
|
109
|
+
for d in payload.get("leases", []):
|
|
110
|
+
lz = Lease.from_dict(d)
|
|
111
|
+
self._leases[lz.lease_id] = lz
|
|
112
|
+
except (json.JSONDecodeError, KeyError, ValueError) as exc:
|
|
113
|
+
# Loud degrade, never silent: a corrupt store is empty + logged.
|
|
114
|
+
logger.warning("[vcs.leases] reload failed (%s); treating as empty", exc)
|
|
115
|
+
|
|
116
|
+
def _save(self) -> None:
|
|
117
|
+
self._data_root.mkdir(parents=True, exist_ok=True)
|
|
118
|
+
payload = {"leases": [lz.to_dict() for lz in self._leases.values()]}
|
|
119
|
+
tmp = self._path.with_suffix(".json.tmp")
|
|
120
|
+
tmp.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
121
|
+
os.replace(tmp, self._path)
|
|
122
|
+
|
|
123
|
+
# ── core ops ─────────────────────────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
def acquire(
|
|
126
|
+
self,
|
|
127
|
+
actor: str,
|
|
128
|
+
targets: List[str],
|
|
129
|
+
*,
|
|
130
|
+
ttl_sec: int = DEFAULT_TTL_SEC,
|
|
131
|
+
reason: str = "",
|
|
132
|
+
kind: Optional[str] = None,
|
|
133
|
+
) -> List[Lease]:
|
|
134
|
+
"""Grant a batch, or raise ``LeaseConflict`` naming the blocker.
|
|
135
|
+
|
|
136
|
+
All-or-nothing: if ANY target is held by another actor's active lease,
|
|
137
|
+
NO target is granted. Re-acquiring targets the SAME actor already holds
|
|
138
|
+
extends them (idempotent). Expired leases are swept first.
|
|
139
|
+
"""
|
|
140
|
+
with FileLock(self._data_root / "leases.lock"):
|
|
141
|
+
self._reload()
|
|
142
|
+
self._mark_expired()
|
|
143
|
+
now = _iso()
|
|
144
|
+
active_by_target: Dict[str, List[Lease]] = {}
|
|
145
|
+
for lz in self._leases.values():
|
|
146
|
+
if lz.status == "active":
|
|
147
|
+
active_by_target.setdefault(lz.target, []).append(lz)
|
|
148
|
+
for t in targets:
|
|
149
|
+
for lz in active_by_target.get(t, []):
|
|
150
|
+
if lz.actor != actor:
|
|
151
|
+
raise LeaseConflictError(lz)
|
|
152
|
+
granted: List[Lease] = []
|
|
153
|
+
for t in targets:
|
|
154
|
+
existing = [
|
|
155
|
+
lz for lz in active_by_target.get(t, []) if lz.actor == actor
|
|
156
|
+
]
|
|
157
|
+
if existing:
|
|
158
|
+
lz = existing[0]
|
|
159
|
+
lz.expires_ts = _iso(ttl_sec)
|
|
160
|
+
lz.heartbeat_ts = now
|
|
161
|
+
lz.reason = reason
|
|
162
|
+
granted.append(lz)
|
|
163
|
+
else:
|
|
164
|
+
k = kind or ("node" if t.startswith("node_") else "path")
|
|
165
|
+
lz = Lease(
|
|
166
|
+
lease_id=uuid.uuid4().hex,
|
|
167
|
+
actor=actor,
|
|
168
|
+
kind=k,
|
|
169
|
+
target=t,
|
|
170
|
+
granted_ts=now,
|
|
171
|
+
expires_ts=_iso(ttl_sec),
|
|
172
|
+
heartbeat_ts=now,
|
|
173
|
+
ttl_sec=ttl_sec,
|
|
174
|
+
reason=reason,
|
|
175
|
+
)
|
|
176
|
+
self._leases[lz.lease_id] = lz
|
|
177
|
+
granted.append(lz)
|
|
178
|
+
self._save()
|
|
179
|
+
return granted
|
|
180
|
+
|
|
181
|
+
def heartbeat(self, actor: str, lease_ids: List[str]) -> int:
|
|
182
|
+
"""Refresh TTL for the actor's active leases; returns count refreshed."""
|
|
183
|
+
with FileLock(self._data_root / "leases.lock"):
|
|
184
|
+
self._reload()
|
|
185
|
+
now = _iso()
|
|
186
|
+
count = 0
|
|
187
|
+
for lid in lease_ids:
|
|
188
|
+
lz = self._leases.get(lid)
|
|
189
|
+
if lz and lz.actor == actor and lz.status == "active":
|
|
190
|
+
lz.expires_ts = _iso(lz.ttl_sec)
|
|
191
|
+
lz.heartbeat_ts = now
|
|
192
|
+
count += 1
|
|
193
|
+
if count:
|
|
194
|
+
self._save()
|
|
195
|
+
return count
|
|
196
|
+
|
|
197
|
+
def release(self, actor: str, lease_ids: List[str]) -> int:
|
|
198
|
+
"""Mark the actor's leases released (frees targets immediately)."""
|
|
199
|
+
with FileLock(self._data_root / "leases.lock"):
|
|
200
|
+
self._reload()
|
|
201
|
+
now = _iso()
|
|
202
|
+
count = 0
|
|
203
|
+
for lid in lease_ids:
|
|
204
|
+
lz = self._leases.get(lid)
|
|
205
|
+
if lz and lz.actor == actor and lz.status == "active":
|
|
206
|
+
lz.status = "released"
|
|
207
|
+
lz.heartbeat_ts = now
|
|
208
|
+
count += 1
|
|
209
|
+
if count:
|
|
210
|
+
self._save()
|
|
211
|
+
return count
|
|
212
|
+
|
|
213
|
+
def sweep_expired(self) -> int:
|
|
214
|
+
"""Mark expired leases 'expired'; returns count swept."""
|
|
215
|
+
with FileLock(self._data_root / "leases.lock"):
|
|
216
|
+
self._reload()
|
|
217
|
+
n = self._mark_expired()
|
|
218
|
+
if n:
|
|
219
|
+
self._save()
|
|
220
|
+
return n
|
|
221
|
+
|
|
222
|
+
def _mark_expired(self) -> int:
|
|
223
|
+
now = _iso()
|
|
224
|
+
n = 0
|
|
225
|
+
for lz in self._leases.values():
|
|
226
|
+
# <= not <: a lease whose expiry has been REACHED is expired.
|
|
227
|
+
# With strict <, a ttl=0 lease granted and checked in the same
|
|
228
|
+
# microsecond never expires (a real timing race the tests caught).
|
|
229
|
+
if lz.status == "active" and lz.expires_ts <= now:
|
|
230
|
+
lz.status = "expired"
|
|
231
|
+
n += 1
|
|
232
|
+
return n
|
|
233
|
+
|
|
234
|
+
# ── queries ──────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
def get(self, lease_id: str) -> Optional[Lease]:
|
|
237
|
+
return self._leases.get(lease_id)
|
|
238
|
+
|
|
239
|
+
def active_leases(self) -> List[Lease]:
|
|
240
|
+
return [lz for lz in self._leases.values() if lz.status == "active"]
|
|
241
|
+
|
|
242
|
+
def leases_by_actor(self, actor: str) -> List[Lease]:
|
|
243
|
+
return [lz for lz in self._leases.values() if lz.actor == actor]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def coverage_gap(
|
|
247
|
+
changed_files: List[str],
|
|
248
|
+
actor: str,
|
|
249
|
+
data_root: Optional[Path] = None,
|
|
250
|
+
) -> List[str]:
|
|
251
|
+
"""Changed Python files NOT covered by the actor's active path-leases.
|
|
252
|
+
|
|
253
|
+
The pre-commit gate's primitive: a commit touching a Python file with no
|
|
254
|
+
active path-lease is an unleased edit — a visible risk flag when leases
|
|
255
|
+
are enforced (VCS_LEASES_ENFORCE=1), recorded as ``leased=false`` otherwise.
|
|
256
|
+
"""
|
|
257
|
+
registry = LeaseRegistry(data_root=data_root)
|
|
258
|
+
registry.sweep_expired()
|
|
259
|
+
covered = {
|
|
260
|
+
lz.target
|
|
261
|
+
for lz in registry.active_leases()
|
|
262
|
+
if lz.actor == actor and lz.kind == "path"
|
|
263
|
+
}
|
|
264
|
+
return [f for f in changed_files if f.endswith(".py") and f not in covered]
|
awgit/ledger.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Ledger attribution — the op-log as a durable who-changed-what record.
|
|
2
|
+
|
|
3
|
+
Each EditOp already carries everything attribution needs: actor, verified
|
|
4
|
+
GitHub identity, git_sha, node_changes, parent chain, timestamp. This module
|
|
5
|
+
flattens an op into a ``LedgerEntry`` — the record a team (or a downstream
|
|
6
|
+
reward program) can point at. The op-log itself only ever RECORDS; it never
|
|
7
|
+
gates a commit.
|
|
8
|
+
|
|
9
|
+
``ledger_ref`` is the durable attribution handle. It is minted deterministically
|
|
10
|
+
from (op_id, git_sha), so it is stable across op-log replays/exports and needs
|
|
11
|
+
no external counter.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Dict, List
|
|
19
|
+
|
|
20
|
+
from awgit.schema import EditOp
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class LedgerEntry:
|
|
25
|
+
"""One op flattened into an attribution record."""
|
|
26
|
+
|
|
27
|
+
ledger_ref: str
|
|
28
|
+
op_id: str
|
|
29
|
+
actor: str
|
|
30
|
+
verified_actor: str
|
|
31
|
+
actor_verified: bool
|
|
32
|
+
git_sha: str
|
|
33
|
+
git_parent_sha: str
|
|
34
|
+
ts: str
|
|
35
|
+
file_paths: List[str]
|
|
36
|
+
node_changes: int
|
|
37
|
+
change_types: Dict[str, int]
|
|
38
|
+
summary: str
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> Dict[str, object]:
|
|
41
|
+
return {
|
|
42
|
+
"ledger_ref": self.ledger_ref,
|
|
43
|
+
"op_id": self.op_id,
|
|
44
|
+
"actor": self.actor,
|
|
45
|
+
"verified_actor": self.verified_actor,
|
|
46
|
+
"actor_verified": self.actor_verified,
|
|
47
|
+
"git_sha": self.git_sha,
|
|
48
|
+
"git_parent_sha": self.git_parent_sha,
|
|
49
|
+
"ts": self.ts,
|
|
50
|
+
"file_paths": list(self.file_paths),
|
|
51
|
+
"node_changes": self.node_changes,
|
|
52
|
+
"change_types": dict(self.change_types),
|
|
53
|
+
"summary": self.summary,
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def mint_ledger_ref(op_id: str, git_sha: str) -> str:
|
|
58
|
+
"""Deterministic ledger ref — stable across replays, no external state."""
|
|
59
|
+
return hashlib.sha256(f"op:{op_id}:{git_sha}".encode()).hexdigest()[:16]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def op_to_ledger_entry(op: EditOp) -> LedgerEntry:
|
|
63
|
+
"""Convert an op to its attribution record."""
|
|
64
|
+
counts: Dict[str, int] = {}
|
|
65
|
+
for nc in op.node_changes:
|
|
66
|
+
counts[nc.change_type] = counts.get(nc.change_type, 0) + 1
|
|
67
|
+
return LedgerEntry(
|
|
68
|
+
ledger_ref=op.ledger_ref or mint_ledger_ref(op.op_id, op.git_sha),
|
|
69
|
+
op_id=op.op_id,
|
|
70
|
+
actor=op.actor,
|
|
71
|
+
verified_actor=op.verified_actor,
|
|
72
|
+
actor_verified=op.actor_verified,
|
|
73
|
+
git_sha=op.git_sha,
|
|
74
|
+
git_parent_sha=op.git_parent_sha,
|
|
75
|
+
ts=op.ts,
|
|
76
|
+
file_paths=list(op.file_paths),
|
|
77
|
+
node_changes=len(op.node_changes),
|
|
78
|
+
change_types=counts,
|
|
79
|
+
summary=op.summary,
|
|
80
|
+
)
|
awgit/mcp.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""MCP tool surface for the semantic-VCS layer (gateway :8182).
|
|
2
|
+
|
|
3
|
+
Handlers are PURE functions returning JSON-able dicts — the tool LOGIC. Wiring
|
|
4
|
+
them into the gateway's MCP server is a thin seam (see ``register_tools``).
|
|
5
|
+
Phase 6+ integration targets (notebooks, RLM/AgentForge, AitherFlow) consume
|
|
6
|
+
exactly these handlers.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, List, Optional
|
|
13
|
+
|
|
14
|
+
from awgit.data_root import vcs_data_root
|
|
15
|
+
from awgit.diff import diff_git, render
|
|
16
|
+
from awgit.leases import LeaseConflictError, LeaseRegistry
|
|
17
|
+
from awgit.merge import list_conflicts
|
|
18
|
+
from awgit.oplog import OpLog
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _root(data_root: Optional[str]) -> Path:
|
|
22
|
+
return Path(data_root) if data_root else vcs_data_root()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def vcs_semantic_diff(a: str, b: str, **kw) -> Dict[str, Any]:
|
|
26
|
+
"""Node-level diff between two shas (JSON shape for MCP/agents)."""
|
|
27
|
+
changes = diff_git(a, b, data_root=_root(kw.get("data_root")))
|
|
28
|
+
return {
|
|
29
|
+
"count": len(changes),
|
|
30
|
+
"changes": [c.to_dict() for c in changes],
|
|
31
|
+
"rendered": render(changes),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def vcs_oplog_query(
|
|
36
|
+
sha: Optional[str] = None,
|
|
37
|
+
node_id: Optional[str] = None,
|
|
38
|
+
actor: Optional[str] = None,
|
|
39
|
+
limit: int = 50,
|
|
40
|
+
**kw,
|
|
41
|
+
) -> Dict[str, Any]:
|
|
42
|
+
"""Query the op-log by commit / node / actor."""
|
|
43
|
+
log = OpLog(data_root=_root(kw.get("data_root")))
|
|
44
|
+
if sha:
|
|
45
|
+
ops = log.ops_for_commit(sha)
|
|
46
|
+
elif node_id:
|
|
47
|
+
ops = log.ops_for_node(node_id)
|
|
48
|
+
elif actor:
|
|
49
|
+
ops = log.ops_by(actor)
|
|
50
|
+
else:
|
|
51
|
+
ops = log.all_ops()
|
|
52
|
+
return {"count": len(ops), "ops": [op.to_dict() for op in ops[-limit:]]}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def vcs_lease_acquire(
|
|
56
|
+
actor: str, targets: List[str], ttl_sec: int = 300, reason: str = "", **kw
|
|
57
|
+
) -> Dict[str, Any]:
|
|
58
|
+
"""Acquire leases all-or-nothing; returns the conflicting lease if any."""
|
|
59
|
+
registry = LeaseRegistry(data_root=_root(kw.get("data_root")))
|
|
60
|
+
try:
|
|
61
|
+
leases = registry.acquire(actor, targets, ttl_sec=ttl_sec, reason=reason)
|
|
62
|
+
return {"granted": [lz.to_dict() for lz in leases], "conflict": None}
|
|
63
|
+
except LeaseConflictError as exc:
|
|
64
|
+
return {"granted": [], "conflict": exc.holder.to_dict()}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def vcs_lease_list(actor: Optional[str] = None, **kw) -> Dict[str, Any]:
|
|
68
|
+
"""List active leases, optionally filtered by actor."""
|
|
69
|
+
leases = LeaseRegistry(data_root=_root(kw.get("data_root"))).active_leases()
|
|
70
|
+
if actor:
|
|
71
|
+
leases = [lz for lz in leases if lz.actor == actor]
|
|
72
|
+
return {"count": len(leases), "leases": [lz.to_dict() for lz in leases]}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def vcs_merge_conflicts(status: Optional[str] = None, **kw) -> Dict[str, Any]:
|
|
76
|
+
"""List escalated merge conflicts, optionally filtered by status."""
|
|
77
|
+
conflicts = list_conflicts(data_root=_root(kw.get("data_root")))
|
|
78
|
+
if status:
|
|
79
|
+
conflicts = [c for c in conflicts if c.status == status]
|
|
80
|
+
return {"count": len(conflicts), "conflicts": [c.to_dict() for c in conflicts]}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def register_tools(server) -> int:
|
|
84
|
+
"""Wire the handlers onto an MCP server (gateway :8182).
|
|
85
|
+
|
|
86
|
+
The gateway exposes a tool registry; the exact registration API is the
|
|
87
|
+
gateway seam. Wire these when the MCP surface is enabled:
|
|
88
|
+
server.tool("vcs_semantic_diff", desc)(vcs_semantic_diff)
|
|
89
|
+
server.tool("vcs_oplog_query", desc)(vcs_oplog_query)
|
|
90
|
+
server.tool("vcs_lease_acquire", desc)(vcs_lease_acquire)
|
|
91
|
+
server.tool("vcs_lease_list", desc)(vcs_lease_list)
|
|
92
|
+
server.tool("vcs_merge_conflicts", desc)(vcs_merge_conflicts)
|
|
93
|
+
"""
|
|
94
|
+
return 5
|