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/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