agent-trace-cli 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.
- agent_trace/__init__.py +3 -0
- agent_trace/blame.py +471 -0
- agent_trace/blame_git.py +133 -0
- agent_trace/blame_meta.py +167 -0
- agent_trace/cli.py +2680 -0
- agent_trace/commit_link.py +226 -0
- agent_trace/config.py +137 -0
- agent_trace/context.py +385 -0
- agent_trace/conversations.py +358 -0
- agent_trace/git_notes.py +491 -0
- agent_trace/hooks/__init__.py +163 -0
- agent_trace/hooks/base.py +233 -0
- agent_trace/hooks/claude.py +483 -0
- agent_trace/hooks/codex.py +232 -0
- agent_trace/hooks/cursor.py +278 -0
- agent_trace/hooks/git.py +144 -0
- agent_trace/ledger.py +955 -0
- agent_trace/models.py +618 -0
- agent_trace/record.py +417 -0
- agent_trace/registry.py +230 -0
- agent_trace/remote.py +673 -0
- agent_trace/rewrite.py +176 -0
- agent_trace/rules.py +209 -0
- agent_trace/schemas/commit-link.schema.json +34 -0
- agent_trace/schemas/git-note.schema.json +88 -0
- agent_trace/schemas/ledger.schema.json +97 -0
- agent_trace/schemas/remotes.schema.json +30 -0
- agent_trace/schemas/sync-state.schema.json +49 -0
- agent_trace/schemas/trace-record.schema.json +130 -0
- agent_trace/session.py +136 -0
- agent_trace/storage.py +229 -0
- agent_trace/summary.py +465 -0
- agent_trace/summary_presets.py +188 -0
- agent_trace/sync.py +1182 -0
- agent_trace/telemetry.py +142 -0
- agent_trace/trace.py +460 -0
- agent_trace_cli-0.1.0.dist-info/METADATA +535 -0
- agent_trace_cli-0.1.0.dist-info/RECORD +42 -0
- agent_trace_cli-0.1.0.dist-info/WHEEL +5 -0
- agent_trace_cli-0.1.0.dist-info/entry_points.txt +2 -0
- agent_trace_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- agent_trace_cli-0.1.0.dist-info/top_level.txt +1 -0
agent_trace/record.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Trace recording — reads hook events from stdin, dispatches them to the
|
|
3
|
+
right adapter's translator, and stores the resulting trace locally.
|
|
4
|
+
|
|
5
|
+
This module is **agent-agnostic**: it knows nothing about Cursor,
|
|
6
|
+
Claude, Codex, or any future harness. The dispatcher walks the adapter
|
|
7
|
+
registry — each adapter declares which hook event names it owns
|
|
8
|
+
(``adapter.EVENTS``), which env vars hold the transcript path
|
|
9
|
+
(``transcript_env_vars``), which env vars hold the workspace dir
|
|
10
|
+
(``project_dir_env_vars``), and which events should fire the summary
|
|
11
|
+
command (``summary_only_events`` / ``summary_then_trace_events``).
|
|
12
|
+
|
|
13
|
+
What stays here are *generic* helpers used by every adapter's
|
|
14
|
+
translators: edit-sequence tracking, file IO, range computation, the
|
|
15
|
+
final ``_store_local`` writer.
|
|
16
|
+
|
|
17
|
+
Hooks write *only* to local JSONL. Network calls happen exclusively via
|
|
18
|
+
``sync.py`` (``agent-trace push`` / ``agent-trace pull``).
|
|
19
|
+
|
|
20
|
+
No external dependencies — uses only the Python standard library.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
from .config import get_project_config
|
|
31
|
+
from .session import touch_session_project
|
|
32
|
+
from .storage import (
|
|
33
|
+
ensure_project_dir,
|
|
34
|
+
get_session_state_path,
|
|
35
|
+
get_traces_path,
|
|
36
|
+
resolve_project_id,
|
|
37
|
+
)
|
|
38
|
+
from .trace import (
|
|
39
|
+
compute_range_positions,
|
|
40
|
+
create_trace,
|
|
41
|
+
get_workspace_root,
|
|
42
|
+
resolve_file_project,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# -------------------------------------------------------------------
|
|
47
|
+
# Hook payload helpers (registry-driven)
|
|
48
|
+
# -------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def transcript_path_from_hook(data: dict) -> str | None:
|
|
51
|
+
"""Resolve the conversation transcript path from a hook payload.
|
|
52
|
+
|
|
53
|
+
Order of precedence:
|
|
54
|
+
1. ``data["transcript_path"]`` (Cursor sets this on most hooks)
|
|
55
|
+
2. The first hit across every adapter's ``transcript_env_vars``
|
|
56
|
+
(e.g. Cursor sets ``CURSOR_TRANSCRIPT_PATH`` for ``sessionEnd``
|
|
57
|
+
/ ``Stop`` where the JSON omits the path).
|
|
58
|
+
|
|
59
|
+
New harnesses contribute their own env-var name on the adapter —
|
|
60
|
+
this function never names a tool.
|
|
61
|
+
"""
|
|
62
|
+
tp = data.get("transcript_path")
|
|
63
|
+
if isinstance(tp, str) and tp.strip():
|
|
64
|
+
return tp.strip()
|
|
65
|
+
from .hooks import iter_adapters
|
|
66
|
+
|
|
67
|
+
for adapter in iter_adapters():
|
|
68
|
+
for var in getattr(adapter, "transcript_env_vars", ()) or ():
|
|
69
|
+
v = os.environ.get(var)
|
|
70
|
+
if isinstance(v, str) and v.strip():
|
|
71
|
+
return v.strip()
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def project_dir_from_hook(data: dict) -> str:
|
|
76
|
+
"""Resolve the workspace dir from a hook payload.
|
|
77
|
+
|
|
78
|
+
Order: ``data["cwd"]`` → adapter-declared env vars (e.g.
|
|
79
|
+
``CURSOR_PROJECT_DIR``, ``CLAUDE_PROJECT_DIR``) →
|
|
80
|
+
``data["workspace_roots"][0]`` → ``os.getcwd()``.
|
|
81
|
+
"""
|
|
82
|
+
cwd = data.get("cwd")
|
|
83
|
+
if isinstance(cwd, str) and cwd.strip():
|
|
84
|
+
return cwd.strip()
|
|
85
|
+
from .hooks import iter_adapters
|
|
86
|
+
|
|
87
|
+
for adapter in iter_adapters():
|
|
88
|
+
for var in getattr(adapter, "project_dir_env_vars", ()) or ():
|
|
89
|
+
v = os.environ.get(var)
|
|
90
|
+
if isinstance(v, str) and v.strip():
|
|
91
|
+
return v.strip()
|
|
92
|
+
roots = data.get("workspace_roots")
|
|
93
|
+
if isinstance(roots, list) and roots:
|
|
94
|
+
r0 = roots[0]
|
|
95
|
+
if isinstance(r0, str) and r0.strip():
|
|
96
|
+
return r0.strip()
|
|
97
|
+
return os.getcwd()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def snapshot_transcript_on_session_end(data: dict) -> None:
|
|
101
|
+
"""Snapshot the live transcript bytes into the per-project cache.
|
|
102
|
+
|
|
103
|
+
Runs on every session-end / agent-stop event independent of summary
|
|
104
|
+
config. ``PostToolUse`` snapshots only ever capture transcript state up
|
|
105
|
+
to a tool call; this is the only event that captures the conversation
|
|
106
|
+
tail after the agent's final response. Never raises.
|
|
107
|
+
"""
|
|
108
|
+
try:
|
|
109
|
+
from .conversations import snapshot_transcript_to_cache
|
|
110
|
+
from .storage import resolve_project_id
|
|
111
|
+
|
|
112
|
+
cwd = project_dir_for_summary_hook(data)
|
|
113
|
+
pid = resolve_project_id(cwd, create=False)
|
|
114
|
+
if not pid:
|
|
115
|
+
return
|
|
116
|
+
transcript_path = transcript_path_from_hook(data)
|
|
117
|
+
if not transcript_path:
|
|
118
|
+
return
|
|
119
|
+
snapshot_transcript_to_cache(pid, transcript_path)
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def project_dir_for_summary_hook(data: dict) -> str:
|
|
125
|
+
"""Git root for summary config and storage: prefer session's primary touched repo.
|
|
126
|
+
|
|
127
|
+
File traces resolve the repo from edited paths (nested repos → inner root). Hooks
|
|
128
|
+
often pass only workspace ``cwd``, which may be a parent folder whose git root is
|
|
129
|
+
the outer monorepo — wrong ``project_id``. Session manifests record which
|
|
130
|
+
``project_id`` actually received edits; match that when possible.
|
|
131
|
+
"""
|
|
132
|
+
from .registry import get_project_record
|
|
133
|
+
from .session import primary_project_id_for_session
|
|
134
|
+
|
|
135
|
+
sid = str(
|
|
136
|
+
data.get("conversation_id") or data.get("session_id") or "",
|
|
137
|
+
).strip()
|
|
138
|
+
if sid:
|
|
139
|
+
pid = primary_project_id_for_session(sid)
|
|
140
|
+
if pid and not pid.startswith("detached:"):
|
|
141
|
+
rec = get_project_record(pid)
|
|
142
|
+
if rec:
|
|
143
|
+
root = rec.get("canonical_root")
|
|
144
|
+
if isinstance(root, str) and root.strip() and os.path.isdir(root.strip()):
|
|
145
|
+
return os.path.realpath(root.strip())
|
|
146
|
+
return project_dir_from_hook(data)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# -------------------------------------------------------------------
|
|
150
|
+
# Session edit sequence tracking (shared across all adapters)
|
|
151
|
+
# -------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
def _get_next_sequence(session_id: str, project_dir: str | None = None) -> int:
|
|
154
|
+
"""Return the next edit sequence number for a session, incrementing atomically.
|
|
155
|
+
|
|
156
|
+
State lives in ``<AGENT_TRACE_HOME>/projects/<id>/session-state.json``
|
|
157
|
+
as ``{"seq:<session_id>": N}``.
|
|
158
|
+
"""
|
|
159
|
+
if not session_id:
|
|
160
|
+
return 0
|
|
161
|
+
if project_dir is None:
|
|
162
|
+
project_dir = get_workspace_root()
|
|
163
|
+
pid = resolve_project_id(project_dir, create=True)
|
|
164
|
+
if not pid:
|
|
165
|
+
return 0
|
|
166
|
+
ensure_project_dir(pid)
|
|
167
|
+
state_path = get_session_state_path(pid)
|
|
168
|
+
state_path.parent.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
|
|
170
|
+
state: dict = {}
|
|
171
|
+
if state_path.exists():
|
|
172
|
+
try:
|
|
173
|
+
state = json.loads(state_path.read_text())
|
|
174
|
+
except (json.JSONDecodeError, OSError):
|
|
175
|
+
state = {}
|
|
176
|
+
|
|
177
|
+
key = f"seq:{session_id}"
|
|
178
|
+
seq = state.get(key, 0)
|
|
179
|
+
state[key] = seq + 1
|
|
180
|
+
|
|
181
|
+
try:
|
|
182
|
+
state_path.write_text(json.dumps(state))
|
|
183
|
+
except OSError:
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
return seq
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# -------------------------------------------------------------------
|
|
190
|
+
# File / range helpers (shared)
|
|
191
|
+
# -------------------------------------------------------------------
|
|
192
|
+
|
|
193
|
+
def _try_read_file(path):
|
|
194
|
+
try:
|
|
195
|
+
with open(path) as f:
|
|
196
|
+
return f.read()
|
|
197
|
+
except Exception:
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _file_existed_before(file_path: str) -> bool:
|
|
202
|
+
try:
|
|
203
|
+
return Path(file_path).exists()
|
|
204
|
+
except OSError:
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _ranges_from_write(_file_path: str, content: str) -> tuple[list | None, list | None]:
|
|
209
|
+
"""For Write tool: entire file is the new content."""
|
|
210
|
+
if not content:
|
|
211
|
+
return None, None
|
|
212
|
+
normalized = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
213
|
+
line_count = normalized.count("\n") + (0 if normalized.endswith("\n") else 1)
|
|
214
|
+
if line_count <= 0:
|
|
215
|
+
return None, None
|
|
216
|
+
rp = [{"start_line": 1, "end_line": line_count}]
|
|
217
|
+
rc = [content]
|
|
218
|
+
return rp, rc
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _ranges_from_edit(file_path: str, old_string: str, new_string: str) -> tuple[list | None, list | None]:
|
|
222
|
+
"""For Edit tool: single old/new pair."""
|
|
223
|
+
if not new_string:
|
|
224
|
+
return None, None
|
|
225
|
+
edits = [{"old_string": old_string, "new_string": new_string}]
|
|
226
|
+
fc = _try_read_file(file_path) if file_path else None
|
|
227
|
+
rp = compute_range_positions(edits, fc)
|
|
228
|
+
rc = [new_string]
|
|
229
|
+
return rp, rc
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _ranges_from_multiedit(file_path: str, edits: list) -> tuple[list | None, list | None]:
|
|
233
|
+
"""For MultiEdit: replay edits in order, emit one range per edit."""
|
|
234
|
+
if not edits:
|
|
235
|
+
return None, None
|
|
236
|
+
buffer = _try_read_file(file_path) or ""
|
|
237
|
+
rp: list[dict] = []
|
|
238
|
+
rc: list[str] = []
|
|
239
|
+
for edit in edits:
|
|
240
|
+
if not isinstance(edit, dict):
|
|
241
|
+
continue
|
|
242
|
+
old = edit.get("old_string", "")
|
|
243
|
+
new = edit.get("new_string", "")
|
|
244
|
+
if not new:
|
|
245
|
+
continue
|
|
246
|
+
if old == "":
|
|
247
|
+
start = 1
|
|
248
|
+
end = new.count("\n") + 1
|
|
249
|
+
else:
|
|
250
|
+
idx = buffer.find(old)
|
|
251
|
+
if idx < 0:
|
|
252
|
+
continue
|
|
253
|
+
start = buffer[:idx].count("\n") + 1
|
|
254
|
+
end = start + new.count("\n")
|
|
255
|
+
rp.append({"start_line": start, "end_line": end})
|
|
256
|
+
rc.append(new)
|
|
257
|
+
buffer = buffer.replace(old, new, 1) if old else new
|
|
258
|
+
return (rp if rp else None), (rc if rc else None)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _ranges_from_notebook(_notebook_path: str, ti: dict) -> tuple[list | None, list | None]:
|
|
262
|
+
"""For NotebookEdit: cell source as content; line numbers are cell-local."""
|
|
263
|
+
new_source = ti.get("new_source", "")
|
|
264
|
+
if not new_source:
|
|
265
|
+
return None, None
|
|
266
|
+
line_count = new_source.count("\n") + 1
|
|
267
|
+
rp = [{"start_line": 1, "end_line": line_count}]
|
|
268
|
+
rc = [new_source]
|
|
269
|
+
return rp, rc
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# -------------------------------------------------------------------
|
|
273
|
+
# Storage backend
|
|
274
|
+
# -------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
def _store_local(trace, project_dir=None):
|
|
277
|
+
"""Append trace to <AGENT_TRACE_HOME>/projects/<id>/traces.jsonl."""
|
|
278
|
+
meta = trace.get("metadata") or {}
|
|
279
|
+
pid = meta.get("project_id")
|
|
280
|
+
if not pid:
|
|
281
|
+
if project_dir is None:
|
|
282
|
+
project_dir = meta.get("repo_root") or get_workspace_root()
|
|
283
|
+
pid = resolve_project_id(project_dir, create=True)
|
|
284
|
+
if not pid:
|
|
285
|
+
return
|
|
286
|
+
ensure_project_dir(pid)
|
|
287
|
+
path = get_traces_path(pid)
|
|
288
|
+
with open(path, "a") as f:
|
|
289
|
+
f.write(json.dumps(trace) + "\n")
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# -------------------------------------------------------------------
|
|
293
|
+
# Back-compat re-exports
|
|
294
|
+
# -------------------------------------------------------------------
|
|
295
|
+
#
|
|
296
|
+
# Older code (and tests) imports translator functions directly from
|
|
297
|
+
# ``agent_trace.record``. The translators now live next to their
|
|
298
|
+
# adapter, but we keep stable aliases here so nothing breaks.
|
|
299
|
+
|
|
300
|
+
from .hooks.claude import ( # noqa: E402
|
|
301
|
+
_PostToolUse as _claude_PostToolUse,
|
|
302
|
+
_SessionEnd as _claude_SessionEnd,
|
|
303
|
+
_SessionStart as _claude_SessionStart,
|
|
304
|
+
_model_from_transcript_tail as _model_from_claude_transcript_tail,
|
|
305
|
+
)
|
|
306
|
+
from .hooks.codex import ( # noqa: E402
|
|
307
|
+
_SessionStart as _codex_SessionStart,
|
|
308
|
+
_TurnComplete as _codex_TurnComplete,
|
|
309
|
+
)
|
|
310
|
+
from .hooks.cursor import ( # noqa: E402
|
|
311
|
+
_afterFileEdit as _cursor_afterFileEdit,
|
|
312
|
+
_afterShellExecution as _cursor_afterShellExecution,
|
|
313
|
+
_afterTabFileEdit as _cursor_afterTabFileEdit,
|
|
314
|
+
_sessionEnd as _cursor_sessionEnd,
|
|
315
|
+
_sessionStart as _cursor_sessionStart,
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
# -------------------------------------------------------------------
|
|
320
|
+
# Main entry point
|
|
321
|
+
# -------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
def record_from_stdin():
|
|
324
|
+
"""Read a hook event from stdin, build a trace, and store it locally.
|
|
325
|
+
|
|
326
|
+
Hooks write *only* to local JSONL. Network sync happens via
|
|
327
|
+
``agent-trace push`` / ``agent-trace pull``.
|
|
328
|
+
|
|
329
|
+
Dispatch is fully registry-driven:
|
|
330
|
+
1. The event-name to translator mapping comes from each adapter's
|
|
331
|
+
``EVENTS`` dict.
|
|
332
|
+
2. Whether the event triggers the summary command — and whether
|
|
333
|
+
it should *also* be dispatched as a regular trace — comes from
|
|
334
|
+
each adapter's ``summary_only_events`` /
|
|
335
|
+
``summary_then_trace_events`` declarations.
|
|
336
|
+
3. Each adapter optionally runs ``pre_summary_hook`` before the
|
|
337
|
+
summary command (e.g. Claude refreshes its model cache).
|
|
338
|
+
"""
|
|
339
|
+
raw = sys.stdin.read().strip()
|
|
340
|
+
if not raw:
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
try:
|
|
344
|
+
data = json.loads(raw)
|
|
345
|
+
except json.JSONDecodeError:
|
|
346
|
+
return
|
|
347
|
+
|
|
348
|
+
event = data.get("hook_event_name", "") or ""
|
|
349
|
+
|
|
350
|
+
from .hooks import iter_adapters
|
|
351
|
+
|
|
352
|
+
# Phase 1: session-end / summary classification.
|
|
353
|
+
summary_owner = None
|
|
354
|
+
also_dispatch = False
|
|
355
|
+
for adapter in iter_adapters():
|
|
356
|
+
triggers, dispatch = adapter.is_session_end(event)
|
|
357
|
+
if triggers:
|
|
358
|
+
summary_owner = adapter
|
|
359
|
+
also_dispatch = dispatch
|
|
360
|
+
break
|
|
361
|
+
|
|
362
|
+
if summary_owner is not None:
|
|
363
|
+
try:
|
|
364
|
+
summary_owner.pre_summary_hook(data)
|
|
365
|
+
except Exception:
|
|
366
|
+
pass
|
|
367
|
+
# Capture the transcript tail into the cache before summary runs,
|
|
368
|
+
# so it lands even when summaries are disabled.
|
|
369
|
+
snapshot_transcript_on_session_end(data)
|
|
370
|
+
try:
|
|
371
|
+
from .summary import run_session_summary_hook
|
|
372
|
+
|
|
373
|
+
run_session_summary_hook(data)
|
|
374
|
+
except Exception:
|
|
375
|
+
pass
|
|
376
|
+
if not also_dispatch:
|
|
377
|
+
return
|
|
378
|
+
# Fall through so the event also reaches its adapter's
|
|
379
|
+
# translator (e.g. Cursor's ``sessionEnd`` records a trace).
|
|
380
|
+
|
|
381
|
+
# Phase 2: trace handler dispatch via the registry.
|
|
382
|
+
handler = None
|
|
383
|
+
for adapter in iter_adapters():
|
|
384
|
+
events = getattr(adapter, "EVENTS", None) or {}
|
|
385
|
+
if event in events:
|
|
386
|
+
handler = events[event]
|
|
387
|
+
break
|
|
388
|
+
if handler is None:
|
|
389
|
+
return
|
|
390
|
+
|
|
391
|
+
trace, _hook_event = handler(data)
|
|
392
|
+
if trace is None:
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
meta = trace.get("metadata") or {}
|
|
396
|
+
repo_root = meta.get("repo_root")
|
|
397
|
+
config = get_project_config(project_dir=repo_root) if repo_root else get_project_config()
|
|
398
|
+
if config is None:
|
|
399
|
+
return
|
|
400
|
+
|
|
401
|
+
sid = (
|
|
402
|
+
meta.get("session_id")
|
|
403
|
+
or meta.get("conversation_id")
|
|
404
|
+
or data.get("conversation_id")
|
|
405
|
+
or data.get("session_id")
|
|
406
|
+
)
|
|
407
|
+
pid = meta.get("project_id")
|
|
408
|
+
if sid and pid:
|
|
409
|
+
tool = trace.get("tool") if isinstance(trace.get("tool"), dict) else {}
|
|
410
|
+
touch_session_project(
|
|
411
|
+
str(sid),
|
|
412
|
+
str(pid),
|
|
413
|
+
tool_name=(tool or {}).get("name"),
|
|
414
|
+
transcript_path=transcript_path_from_hook(data),
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
_store_local(trace, project_dir=repo_root)
|
agent_trace/registry.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project metadata registry (``<AGENT_TRACE_HOME>/projects.json``).
|
|
3
|
+
|
|
4
|
+
``project_id`` is now deterministic — it's the repo's canonical absolute path
|
|
5
|
+
with ``/`` replaced by ``-`` (see ``storage.path_to_project_id``). The registry
|
|
6
|
+
keeps per-project metadata (first commit sha, origin URL, known_roots) so
|
|
7
|
+
``agent-trace projects`` can show human context and so tests / tools can cross
|
|
8
|
+
reference past repo locations.
|
|
9
|
+
|
|
10
|
+
POSIX: fcntl advisory lock during read-modify-write. Other platforms: best-effort.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .storage import (
|
|
23
|
+
get_agent_trace_home,
|
|
24
|
+
get_projects_registry_file,
|
|
25
|
+
path_to_project_id,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def __getattr__(name: str):
|
|
30
|
+
if name == "PROJECTS_FILE":
|
|
31
|
+
return get_projects_registry_file()
|
|
32
|
+
raise AttributeError(name)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _projects_file() -> os.PathLike:
|
|
36
|
+
"""Resolve the projects registry file, honoring test-time patches."""
|
|
37
|
+
import sys
|
|
38
|
+
mod = sys.modules[__name__]
|
|
39
|
+
if "PROJECTS_FILE" in mod.__dict__:
|
|
40
|
+
return mod.__dict__["PROJECTS_FILE"]
|
|
41
|
+
return get_projects_registry_file()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _git(args: list[str], cwd: str, timeout: float = 15.0) -> str | None:
|
|
45
|
+
import subprocess
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
r = subprocess.run(
|
|
49
|
+
["git", *args],
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
cwd=cwd,
|
|
53
|
+
timeout=timeout,
|
|
54
|
+
)
|
|
55
|
+
if r.returncode == 0:
|
|
56
|
+
return r.stdout.strip()
|
|
57
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
58
|
+
pass
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _first_commit_sha(repo_root: str) -> str | None:
|
|
63
|
+
out = _git(["log", "--reverse", "--format=%H", "--max-parents=0"], repo_root)
|
|
64
|
+
if not out:
|
|
65
|
+
return None
|
|
66
|
+
line = out.split("\n", 1)[0].strip()
|
|
67
|
+
return line or None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _origin_url(repo_root: str) -> str | None:
|
|
71
|
+
u = _git(["config", "--get", "remote.origin.url"], repo_root)
|
|
72
|
+
return u or None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def compute_project_identity(repo_root: str) -> dict[str, Any]:
|
|
76
|
+
root = os.path.realpath(repo_root)
|
|
77
|
+
return {
|
|
78
|
+
"first_commit_sha": _first_commit_sha(root),
|
|
79
|
+
"origin_url": _origin_url(root),
|
|
80
|
+
"canonical_root": root,
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _empty_registry() -> dict[str, Any]:
|
|
85
|
+
return {"version": 2, "projects": {}}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _load_raw() -> dict[str, Any]:
|
|
89
|
+
if not _projects_file().is_file():
|
|
90
|
+
return _empty_registry()
|
|
91
|
+
try:
|
|
92
|
+
data = json.loads(_projects_file().read_text())
|
|
93
|
+
if not isinstance(data, dict):
|
|
94
|
+
return _empty_registry()
|
|
95
|
+
data.setdefault("projects", {})
|
|
96
|
+
return data
|
|
97
|
+
except (json.JSONDecodeError, OSError):
|
|
98
|
+
return _empty_registry()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _atomic_write(path: os.PathLike[str], text: str) -> None:
|
|
102
|
+
p = os.fspath(path)
|
|
103
|
+
d = os.path.dirname(p)
|
|
104
|
+
os.makedirs(d, exist_ok=True)
|
|
105
|
+
fd, tmp = tempfile.mkstemp(prefix=".projects.", dir=d, text=True)
|
|
106
|
+
try:
|
|
107
|
+
with os.fdopen(fd, "w") as f:
|
|
108
|
+
f.write(text)
|
|
109
|
+
os.replace(tmp, p)
|
|
110
|
+
except Exception:
|
|
111
|
+
try:
|
|
112
|
+
os.unlink(tmp)
|
|
113
|
+
except OSError:
|
|
114
|
+
pass
|
|
115
|
+
raise
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _RegistryLock:
|
|
119
|
+
def __init__(self) -> None:
|
|
120
|
+
self._fp: Any = None
|
|
121
|
+
|
|
122
|
+
def __enter__(self) -> None:
|
|
123
|
+
home = get_agent_trace_home()
|
|
124
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
lock_path = home / ".registry.lock"
|
|
126
|
+
self._fp = open(lock_path, "a+")
|
|
127
|
+
if sys.platform != "win32":
|
|
128
|
+
import fcntl
|
|
129
|
+
|
|
130
|
+
fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX)
|
|
131
|
+
return self
|
|
132
|
+
|
|
133
|
+
def __exit__(self, *args: object) -> None:
|
|
134
|
+
if self._fp:
|
|
135
|
+
if sys.platform != "win32":
|
|
136
|
+
import fcntl
|
|
137
|
+
|
|
138
|
+
fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN)
|
|
139
|
+
self._fp.close()
|
|
140
|
+
self._fp = None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def register_project_metadata(repo_root: str, project_id: str | None = None) -> str:
|
|
144
|
+
"""Upsert metadata for a repo. Returns the anchor-aware project_id.
|
|
145
|
+
|
|
146
|
+
When ``project_id`` is not supplied, resolves it via the same
|
|
147
|
+
anchor-aware path used everywhere else (``storage.resolve_project_id``)
|
|
148
|
+
so the recorder, ``init``, ``blame``, and the registry all agree on
|
|
149
|
+
the same id. Without this, a fresh ``.git/agent-trace-id`` anchor
|
|
150
|
+
would be ignored and the registry would fall back to the path-derived
|
|
151
|
+
id, leaving the recorder writing under one id while ``init`` wrote
|
|
152
|
+
config under another.
|
|
153
|
+
"""
|
|
154
|
+
canon = os.path.realpath(repo_root)
|
|
155
|
+
if project_id is None:
|
|
156
|
+
from .storage import resolve_project_id
|
|
157
|
+
|
|
158
|
+
resolved = resolve_project_id(canon, create=True)
|
|
159
|
+
pid = resolved or path_to_project_id(canon)
|
|
160
|
+
else:
|
|
161
|
+
pid = project_id
|
|
162
|
+
fc = _first_commit_sha(canon)
|
|
163
|
+
origin = _origin_url(canon)
|
|
164
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
165
|
+
|
|
166
|
+
with _RegistryLock():
|
|
167
|
+
data = _load_raw()
|
|
168
|
+
projects: dict[str, Any] = data["projects"]
|
|
169
|
+
rec = projects.get(pid)
|
|
170
|
+
if rec is None:
|
|
171
|
+
rec = {
|
|
172
|
+
"first_commit_sha": fc,
|
|
173
|
+
"origin_url": origin,
|
|
174
|
+
"canonical_root": canon,
|
|
175
|
+
"known_roots": [canon],
|
|
176
|
+
"created_at": now,
|
|
177
|
+
}
|
|
178
|
+
projects[pid] = rec
|
|
179
|
+
else:
|
|
180
|
+
roots = rec.setdefault("known_roots", [])
|
|
181
|
+
if canon not in roots:
|
|
182
|
+
roots.append(canon)
|
|
183
|
+
rec["canonical_root"] = canon
|
|
184
|
+
if fc:
|
|
185
|
+
rec["first_commit_sha"] = fc
|
|
186
|
+
if origin:
|
|
187
|
+
rec["origin_url"] = origin
|
|
188
|
+
_atomic_write(_projects_file(), json.dumps(data, indent=2) + "\n")
|
|
189
|
+
return pid
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def lookup_or_create_project_id(repo_root: str) -> str:
|
|
193
|
+
"""Return the deterministic project_id for a repo and record its metadata."""
|
|
194
|
+
return register_project_metadata(repo_root)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def get_project_record(project_id: str) -> dict[str, Any] | None:
|
|
198
|
+
data = _load_raw()
|
|
199
|
+
rec = data["projects"].get(project_id)
|
|
200
|
+
return dict(rec) if isinstance(rec, dict) else None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def register_known_root(project_id: str, root: str) -> None:
|
|
204
|
+
canon = os.path.realpath(root)
|
|
205
|
+
with _RegistryLock():
|
|
206
|
+
data = _load_raw()
|
|
207
|
+
projects: dict[str, Any] = data["projects"]
|
|
208
|
+
if project_id not in projects:
|
|
209
|
+
return
|
|
210
|
+
rec = projects[project_id]
|
|
211
|
+
roots = rec.setdefault("known_roots", [])
|
|
212
|
+
if canon not in roots:
|
|
213
|
+
roots.append(canon)
|
|
214
|
+
_atomic_write(_projects_file(), json.dumps(data, indent=2) + "\n")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def list_projects() -> list[dict[str, Any]]:
|
|
218
|
+
data = _load_raw()
|
|
219
|
+
out: list[dict[str, Any]] = []
|
|
220
|
+
for pid, rec in data.get("projects", {}).items():
|
|
221
|
+
if isinstance(rec, dict):
|
|
222
|
+
out.append({"project_id": pid, **rec})
|
|
223
|
+
out.sort(key=lambda x: x.get("created_at") or "")
|
|
224
|
+
return out
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def lookup_project_id_by_path(repo_root: str) -> str | None:
|
|
228
|
+
"""Return the deterministic id for this path, regardless of registry state."""
|
|
229
|
+
canon = os.path.realpath(repo_root)
|
|
230
|
+
return path_to_project_id(canon)
|