Skip to content

Commit f37b4b6

Browse files
authored
Merge branch 'main' into fix/benchmark-subprocess-leak-dead-code
2 parents 0eae025 + 7d7faf9 commit f37b4b6

5 files changed

Lines changed: 235 additions & 16 deletions

File tree

CONTRIBUTING.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Thanks for taking the first step in contributing to our project.
66

77
See the [Table of Contents](#table-of-contents) for different ways to contribute and details about how we treat each contribution. Please read the relevant section before making your contribution as it will not only make it a lot easier for us but also ensure you have the very best developer experience too.
88

9-
> ⭐ If you like the project, but don't have time to contribute just now, that's no problem at all! Give the Repo a star and we'll look forward to receiving your future contribution.
9+
> ⭐ If you like the project, but don't have time to contribute just now, that's no problem at all! Give the repo a star and we'll look forward to receiving your future contribution.
1010
1111
## Table of Contents
1212

@@ -93,7 +93,7 @@ Bug reports shouldn't need the project maintainers to clarify or search for more
9393

9494
- Make sure that you are using the latest version of the project.
9595
- **Determine if your bug is really a bug** and not an error on your side e.g. using incompatible environment components/versions.
96-
- To see if other users have experienced (and potentially already solved) the same issue you are having, **check if there is not already a bug report** existing for your bug or error in the [bug list](https://github.com/uber/ADR/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug). If it has and the issue is still open, add a comment to the existing issue instead of opening a new one
96+
- To see if other users have experienced (and potentially already solved) the same issue you are having, **check if there is not already a bug report** existing for your bug or error in the [bug list](https://github.com/uber/ADR/issues?q=is%3Aissue%20state%3Aopen%20label%3Abug). If it has and the issue is still open, add a comment to the existing issue instead of opening a new one.
9797
- Collect information about the bug:
9898
- OS, Platform and Version (Windows, Linux, macOS, x86, ARM)
9999
- Version of the interpreter, compiler, SDK, runtime environment, package manager, depending on what seems relevant – for local instances only.
@@ -135,4 +135,4 @@ If you want to fix a bug or propose a new feature you'll do this through creatin
135135
- Provide a **short description of the solution you proposed** in as many details as possible.
136136
- **Use comments in the code** that you provide to give us more context to any code based submissions.
137137

138-
Thanks for contributing into our project.
138+
Thanks for contributing to our project.

Detection/uv.lock

Lines changed: 10 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Sensor/adr_sensor/observer.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ def __init__(self, output_dir: Optional[Path] = None, max_age_days: Optional[int
5454
)
5555
self.codex_parser = CodexParser()
5656
self.cline_parser = ClineParser()
57-
self.warp_parser = WarpParser()
57+
self.warp_parser = WarpParser(max_age_days=max_age_days) if max_age_days is not None else WarpParser()
5858

5959
self.output_dir = output_dir if output_dir else Path("output")
6060
self.output_dir.mkdir(exist_ok=True)

Sensor/adr_sensor/parsers/warp_parser.py

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,25 +3,31 @@
33
Reads SQLite database from the Warp application data directory.
44
55
Supports macOS path. Linux support can be added when Warp provides Linux paths.
6+
7+
Performance-optimized: Skips conversations older than 2 weeks by default.
68
"""
79

810
import json
911
import sqlite3
1012
import traceback
13+
from datetime import datetime, timedelta, timezone
1114
from pathlib import Path
1215
from typing import Any, Dict, List, Optional
1316

1417
from ..schemas.agent_event_schema import AgentEvent, ChatMessage, ToolUsage
1518
from ..utils.timestamp_utils import normalize_timestamp
1619
from .base_parser import BaseParser
1720

21+
MAX_CONVERSATION_AGE_DAYS = 14
22+
1823

1924
class WarpParser(BaseParser):
2025
"""Parser for Warp Terminal SQLite database."""
2126

22-
def __init__(self):
27+
def __init__(self, max_age_days: int = MAX_CONVERSATION_AGE_DAYS):
2328
self.base_path = Path.home() / "Library/Application Support/dev.warp.Warp-Stable"
2429
self.db_path = self.base_path / "warp.sqlite"
30+
self.max_age_days = max_age_days
2531

2632
def parse_all(self) -> List[AgentEvent]:
2733
"""Parse all available Warp Terminal logs."""
@@ -42,7 +48,12 @@ def parse_all(self) -> List[AgentEvent]:
4248
conversations = self._get_all_conversations(conn)
4349
print(f"[WARP] Found {len(conversations)} conversations")
4450

45-
for conversation in conversations:
51+
recent_conversations = self._filter_recent_conversations(conversations)
52+
skipped_count = len(conversations) - len(recent_conversations)
53+
if skipped_count > 0:
54+
print(f"[WARP] Skipped {skipped_count} conversations older than {self.max_age_days} days")
55+
56+
for conversation in recent_conversations:
4657
conversation_id = conversation["conversation_id"]
4758
try:
4859
exchanges = self._get_conversation_exchanges(conn, conversation_id)
@@ -61,15 +72,43 @@ def parse_all(self) -> List[AgentEvent]:
6172
return entries
6273

6374
def _get_all_conversations(self, conn) -> List[Dict]:
64-
"""Get all conversations from the database."""
75+
"""Get all conversation ids and timestamps from the database.
76+
77+
Deliberately excludes the conversation_data column: it is not used
78+
anywhere in this parser (exchange content comes from ai_queries /
79+
ai_blocks instead), so fetching it here would be wasted I/O for
80+
every conversation on every run.
81+
"""
6582
cursor = conn.cursor()
6683
cursor.execute("""
67-
SELECT conversation_id, conversation_data, last_modified_at
84+
SELECT conversation_id, last_modified_at
6885
FROM agent_conversations
6986
ORDER BY last_modified_at DESC
7087
""")
7188
return [dict(row) for row in cursor.fetchall()]
7289

90+
def _filter_recent_conversations(self, conversations: List[Dict]) -> List[Dict]:
91+
"""Filter out conversations whose last_modified_at is older than max_age_days.
92+
93+
A conversation with a missing or unparseable timestamp is kept rather than
94+
dropped, since we can't determine its age.
95+
"""
96+
cutoff_time = datetime.now(timezone.utc) - timedelta(days=self.max_age_days)
97+
recent = []
98+
for conversation in conversations:
99+
last_modified = conversation.get("last_modified_at")
100+
conv_timestamp = None
101+
if last_modified is not None:
102+
try:
103+
conv_timestamp = normalize_timestamp(last_modified)
104+
except Exception:
105+
pass
106+
107+
if conv_timestamp is None or conv_timestamp >= cutoff_time:
108+
recent.append(conversation)
109+
110+
return recent
111+
73112
def _get_conversation_exchanges(self, conn, conversation_id: str) -> List[Dict]:
74113
"""Get all exchanges for a conversation with LLM output."""
75114
cursor = conn.cursor()

Sensor/tests/test_parsers.py

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Tests for ADR Sensor parsers."""
22

33
import json
4+
import sqlite3
45
import tempfile
5-
from datetime import datetime, timezone
6+
from datetime import datetime, timedelta, timezone
67
from pathlib import Path
78
from unittest.mock import patch
89

@@ -11,6 +12,7 @@
1112
from adr_sensor.parsers.claude_parser import ClaudeParser
1213
from adr_sensor.parsers.cline_parser import ClineParser
1314
from adr_sensor.parsers.codex_parser import CodexParser
15+
from adr_sensor.parsers.warp_parser import WarpParser
1416

1517

1618
class TestClaudeParser:
@@ -211,3 +213,178 @@ def test_parse_no_directory(self):
211213
parser.base_path = Path("/nonexistent/path")
212214
entries = parser.parse_all()
213215
assert entries == []
216+
217+
218+
def _build_warp_db(db_path: Path, conversations: list) -> None:
219+
"""Create a synthetic warp.sqlite matching the schema WarpParser queries.
220+
221+
Each item in `conversations` is a dict with keys:
222+
conversation_id, last_modified_at, exchanges
223+
where `exchanges` is a list of (exchange_id, start_ts, input_json, llm_output_json).
224+
"""
225+
conn = sqlite3.connect(str(db_path))
226+
cursor = conn.cursor()
227+
cursor.execute(
228+
"""
229+
CREATE TABLE agent_conversations (
230+
conversation_id TEXT PRIMARY KEY,
231+
conversation_data TEXT,
232+
last_modified_at TEXT
233+
)
234+
"""
235+
)
236+
cursor.execute(
237+
"""
238+
CREATE TABLE ai_queries (
239+
exchange_id TEXT,
240+
conversation_id TEXT,
241+
start_ts TEXT,
242+
input TEXT,
243+
working_directory TEXT,
244+
output_status TEXT,
245+
model_id TEXT
246+
)
247+
"""
248+
)
249+
cursor.execute("CREATE TABLE ai_blocks (exchange_id TEXT, output TEXT)")
250+
251+
for conv in conversations:
252+
cursor.execute(
253+
"INSERT INTO agent_conversations VALUES (?, ?, ?)",
254+
(conv["conversation_id"], "unused_blob", conv["last_modified_at"]),
255+
)
256+
for exchange_id, start_ts, input_json, llm_output_json in conv.get("exchanges", []):
257+
cursor.execute(
258+
"INSERT INTO ai_queries VALUES (?, ?, ?, ?, ?, ?, ?)",
259+
(exchange_id, conv["conversation_id"], start_ts, input_json, "/tmp/project", "success", "claude"),
260+
)
261+
if llm_output_json is not None:
262+
cursor.execute("INSERT INTO ai_blocks VALUES (?, ?)", (exchange_id, llm_output_json))
263+
264+
conn.commit()
265+
conn.close()
266+
267+
268+
class TestWarpParser:
269+
def _make_parser(self, tmp_path: Path, max_age_days: int = 14) -> WarpParser:
270+
parser = WarpParser(max_age_days=max_age_days)
271+
parser.base_path = tmp_path
272+
parser.db_path = tmp_path / "warp.sqlite"
273+
return parser
274+
275+
def _query_exchange(self, exchange_id: str, timestamp: str) -> tuple:
276+
input_json = json.dumps([{"Query": {"text": f"hello from {exchange_id}"}}])
277+
llm_output_json = json.dumps({"Received": {"output": [{"Text": {"text": f"reply for {exchange_id}"}}]}})
278+
return (exchange_id, timestamp, input_json, llm_output_json)
279+
280+
def test_skips_conversations_older_than_max_age(self, tmp_path):
281+
"""Old conversations should be filtered out before the expensive per-conversation query runs."""
282+
now = datetime.now(timezone.utc)
283+
recent_ts = now.isoformat()
284+
old_ts = (now - timedelta(days=30)).isoformat()
285+
286+
conversations = [
287+
{
288+
"conversation_id": "recent-conv",
289+
"last_modified_at": recent_ts,
290+
"exchanges": [self._query_exchange("ex-recent", recent_ts)],
291+
},
292+
{
293+
"conversation_id": "old-conv",
294+
"last_modified_at": old_ts,
295+
"exchanges": [self._query_exchange("ex-old", old_ts)],
296+
},
297+
]
298+
db_path = tmp_path / "warp.sqlite"
299+
_build_warp_db(db_path, conversations)
300+
301+
parser = self._make_parser(tmp_path, max_age_days=14)
302+
entries = parser.parse_all()
303+
304+
session_ids = {e.session_id for e in entries}
305+
assert "warp_recent-conv" in session_ids
306+
assert "warp_old-conv" not in session_ids
307+
308+
def test_all_history_via_large_max_age_days(self, tmp_path):
309+
"""A larger max_age_days should include conversations that would otherwise be skipped."""
310+
now = datetime.now(timezone.utc)
311+
old_ts = (now - timedelta(days=30)).isoformat()
312+
313+
conversations = [
314+
{
315+
"conversation_id": "old-conv",
316+
"last_modified_at": old_ts,
317+
"exchanges": [self._query_exchange("ex-old", old_ts)],
318+
}
319+
]
320+
db_path = tmp_path / "warp.sqlite"
321+
_build_warp_db(db_path, conversations)
322+
323+
parser = self._make_parser(tmp_path, max_age_days=10000)
324+
entries = parser.parse_all()
325+
326+
assert {e.session_id for e in entries} == {"warp_old-conv"}
327+
328+
def test_parses_conversation_content(self, tmp_path):
329+
"""Recent conversations should still be parsed into chat history correctly."""
330+
now = datetime.now(timezone.utc)
331+
ts1 = now.isoformat()
332+
ts2 = (now + timedelta(seconds=1)).isoformat()
333+
334+
action_result_input = json.dumps(
335+
[
336+
{
337+
"ActionResult": {
338+
"id": "tool-1",
339+
"result": {
340+
"RequestCommandOutput": {
341+
"result": {"Success": {"command": "ls", "output": "file.txt", "exit_code": 0}}
342+
}
343+
},
344+
}
345+
}
346+
]
347+
)
348+
349+
conversations = [
350+
{
351+
"conversation_id": "conv-1",
352+
"last_modified_at": ts2,
353+
"exchanges": [
354+
self._query_exchange("ex-1", ts1),
355+
("ex-2", ts2, action_result_input, None),
356+
],
357+
}
358+
]
359+
db_path = tmp_path / "warp.sqlite"
360+
_build_warp_db(db_path, conversations)
361+
362+
parser = self._make_parser(tmp_path)
363+
entries = parser.parse_all()
364+
365+
assert len(entries) == 1
366+
entry = entries[0]
367+
assert entry.source == "warp"
368+
assert entry.session_id == "warp_conv-1"
369+
assert len(entry.chat_history) == 2
370+
371+
user_msg = entry.chat_history[0]
372+
assert user_msg.role == "user"
373+
assert "hello from ex-1" in user_msg.content
374+
375+
assistant_msg = entry.chat_history[1]
376+
assert assistant_msg.role == "assistant"
377+
assert len(assistant_msg.tools) == 1
378+
assert assistant_msg.tools[0].tool_name == "execute_command"
379+
assert assistant_msg.tools[0].status == "success"
380+
381+
def test_parse_all_no_database(self, tmp_path):
382+
"""Test parse_all when the database file doesn't exist."""
383+
parser = self._make_parser(tmp_path)
384+
entries = parser.parse_all()
385+
assert entries == []
386+
387+
def test_default_max_age_days(self):
388+
"""Constructor should default to the module-level constant when unset."""
389+
parser = WarpParser()
390+
assert parser.max_age_days == 14

0 commit comments

Comments
 (0)