devin-graph 0.1.1__tar.gz → 0.1.2__tar.gz
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.
- {devin_graph-0.1.1/src/devin_graph.egg-info → devin_graph-0.1.2}/PKG-INFO +14 -1
- {devin_graph-0.1.1 → devin_graph-0.1.2}/README.md +13 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/pyproject.toml +1 -1
- devin_graph-0.1.2/src/devin_graph/__init__.py +1 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/cli.py +78 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/query.py +100 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/store.py +16 -4
- {devin_graph-0.1.1 → devin_graph-0.1.2/src/devin_graph.egg-info}/PKG-INFO +14 -1
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph.egg-info/SOURCES.txt +1 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_cli.py +5 -2
- {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_offline_core.py +4 -1
- devin_graph-0.1.2/tests/test_orphans_blame.py +160 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_store.py +4 -1
- devin_graph-0.1.1/src/devin_graph/__init__.py +0 -1
- {devin_graph-0.1.1 → devin_graph-0.1.2}/LICENSE +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/setup.cfg +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/extract.py +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/identity.py +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/mcp_server.py +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/paths.py +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/view.py +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph/vscdb.py +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph.egg-info/dependency_links.txt +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph.egg-info/entry_points.txt +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph.egg-info/requires.txt +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/src/devin_graph.egg-info/top_level.txt +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_extract.py +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_mcp.py +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_paths.py +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_query.py +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_skill.py +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_smoke.py +0 -0
- {devin_graph-0.1.1 → devin_graph-0.1.2}/tests/test_vscdb.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devin-graph
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Knowledge graph over Devin sessions — projects, files, tools and decisions as queryable nodes.
|
|
5
5
|
Author: Icaro0310
|
|
6
6
|
License: MIT
|
|
@@ -97,6 +97,10 @@ devin-graph query projects-graph --graph graph.db --json
|
|
|
97
97
|
|
|
98
98
|
# D3-friendly dump: {"meta", "nodes", "edges"}
|
|
99
99
|
devin-graph export --format json --graph graph.db --out graph.json
|
|
100
|
+
|
|
101
|
+
# diagnostics: edge-less nodes (report-only) and per-session blame
|
|
102
|
+
devin-graph orphans --graph graph.db
|
|
103
|
+
devin-graph blame-session <id-or-prefix> --graph graph.db
|
|
100
104
|
```
|
|
101
105
|
|
|
102
106
|
Matching is forgiving (`src/app.py` finds `/repo/alpha/src/app.py`);
|
|
@@ -105,6 +109,15 @@ by two or more distinct projects, with the project and session lists —
|
|
|
105
109
|
exportable as JSON like the other queries. The source DB is opened
|
|
106
110
|
`mode=ro` and never written — tests assert its hash is unchanged.
|
|
107
111
|
|
|
112
|
+
`orphans` lists nodes with no incident edges (id, kind, owner, a
|
|
113
|
+
`prunable` flag marking the subset the next `build` would drop —
|
|
114
|
+
shared, ownerless nodes; owned orphans persist with their owner). It is
|
|
115
|
+
report-only and never deletes. `blame-session <id>` shows what a
|
|
116
|
+
session produced and touched: its owned nodes and edges plus the
|
|
117
|
+
resolved files, tools, projects and commits reachable through them,
|
|
118
|
+
with the extraction-ledger timestamp. `<id>` accepts a unique prefix;
|
|
119
|
+
a missing or ambiguous match exits 2 and lists candidates.
|
|
120
|
+
|
|
108
121
|
## Works with Devin alone (Devin-only mode)
|
|
109
122
|
|
|
110
123
|
devin-graph builds `graph.db` locally from Devin's session stores — the whole
|
|
@@ -80,6 +80,10 @@ devin-graph query projects-graph --graph graph.db --json
|
|
|
80
80
|
|
|
81
81
|
# D3-friendly dump: {"meta", "nodes", "edges"}
|
|
82
82
|
devin-graph export --format json --graph graph.db --out graph.json
|
|
83
|
+
|
|
84
|
+
# diagnostics: edge-less nodes (report-only) and per-session blame
|
|
85
|
+
devin-graph orphans --graph graph.db
|
|
86
|
+
devin-graph blame-session <id-or-prefix> --graph graph.db
|
|
83
87
|
```
|
|
84
88
|
|
|
85
89
|
Matching is forgiving (`src/app.py` finds `/repo/alpha/src/app.py`);
|
|
@@ -88,6 +92,15 @@ by two or more distinct projects, with the project and session lists —
|
|
|
88
92
|
exportable as JSON like the other queries. The source DB is opened
|
|
89
93
|
`mode=ro` and never written — tests assert its hash is unchanged.
|
|
90
94
|
|
|
95
|
+
`orphans` lists nodes with no incident edges (id, kind, owner, a
|
|
96
|
+
`prunable` flag marking the subset the next `build` would drop —
|
|
97
|
+
shared, ownerless nodes; owned orphans persist with their owner). It is
|
|
98
|
+
report-only and never deletes. `blame-session <id>` shows what a
|
|
99
|
+
session produced and touched: its owned nodes and edges plus the
|
|
100
|
+
resolved files, tools, projects and commits reachable through them,
|
|
101
|
+
with the extraction-ledger timestamp. `<id>` accepts a unique prefix;
|
|
102
|
+
a missing or ambiguous match exits 2 and lists candidates.
|
|
103
|
+
|
|
91
104
|
## Works with Devin alone (Devin-only mode)
|
|
92
105
|
|
|
93
106
|
devin-graph builds `graph.db` locally from Devin's session stores — the whole
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devin-graph"
|
|
7
|
-
version = "0.1.
|
|
7
|
+
version = "0.1.2"
|
|
8
8
|
description = "Knowledge graph over Devin sessions — projects, files, tools and decisions as queryable nodes."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.2"
|
|
@@ -13,6 +13,8 @@ from devin_internals.parsers import SessionsStore
|
|
|
13
13
|
from devin_graph import __version__
|
|
14
14
|
from devin_graph.paths import default_sessions_db, default_state_vscdb
|
|
15
15
|
from devin_graph.query import (
|
|
16
|
+
blame_session,
|
|
17
|
+
orphaned_nodes,
|
|
16
18
|
project_detail,
|
|
17
19
|
projects_graph,
|
|
18
20
|
sessions_for_file,
|
|
@@ -110,6 +112,24 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
110
112
|
sq.add_argument("--json", action="store_true")
|
|
111
113
|
sq.set_defaults(func=_cmd_query)
|
|
112
114
|
|
|
115
|
+
o = sub.add_parser(
|
|
116
|
+
"orphans", help="nodes with no incident edges — report-only, "
|
|
117
|
+
"never deletes (prunable flags what the next build would drop)")
|
|
118
|
+
_add_graph(o)
|
|
119
|
+
o.add_argument("--json", action="store_true")
|
|
120
|
+
o.set_defaults(func=_cmd_orphans)
|
|
121
|
+
|
|
122
|
+
bl = sub.add_parser(
|
|
123
|
+
"blame-session",
|
|
124
|
+
help="what a session produced/touched in the graph — owned "
|
|
125
|
+
"nodes and edges plus the files, tools, projects and commits "
|
|
126
|
+
"they reach")
|
|
127
|
+
bl.add_argument(
|
|
128
|
+
"session_id", help="session id or unique prefix")
|
|
129
|
+
_add_graph(bl)
|
|
130
|
+
bl.add_argument("--json", action="store_true")
|
|
131
|
+
bl.set_defaults(func=_cmd_blame)
|
|
132
|
+
|
|
113
133
|
e = sub.add_parser("export", help="dump nodes+edges (D3-friendly)")
|
|
114
134
|
e.add_argument("--format", choices=["json"], default="json")
|
|
115
135
|
_add_graph(e)
|
|
@@ -226,6 +246,64 @@ def _print_query(what: str, data: dict) -> None:
|
|
|
226
246
|
print(f"call: {t['id']} → {t['file']}")
|
|
227
247
|
|
|
228
248
|
|
|
249
|
+
def _cmd_orphans(args: argparse.Namespace) -> int:
|
|
250
|
+
with _open_graph(args.graph) as gs:
|
|
251
|
+
data = orphaned_nodes(gs)
|
|
252
|
+
if args.json:
|
|
253
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
254
|
+
return 0
|
|
255
|
+
if not data["nodes"]:
|
|
256
|
+
print("no orphaned nodes")
|
|
257
|
+
return 0
|
|
258
|
+
for n in data["nodes"]:
|
|
259
|
+
label = n.get("name") or ""
|
|
260
|
+
keep = "" if n["prunable"] else " (owned — kept by prune)"
|
|
261
|
+
print(f"{n['id']} {label}{keep}".rstrip())
|
|
262
|
+
print(f"{data['count']} orphan(s)")
|
|
263
|
+
return 0
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _cmd_blame(args: argparse.Namespace) -> int:
|
|
267
|
+
with _open_graph(args.graph) as gs:
|
|
268
|
+
data = blame_session(gs, args.session_id)
|
|
269
|
+
if data["session"] is None:
|
|
270
|
+
cand = data.get("candidates") or []
|
|
271
|
+
hint = f" — candidates: {', '.join(cand[:10])}" if cand else ""
|
|
272
|
+
print(
|
|
273
|
+
f"devin-graph: no session matches {args.session_id!r}{hint}",
|
|
274
|
+
file=sys.stderr,
|
|
275
|
+
)
|
|
276
|
+
return 2
|
|
277
|
+
if args.json:
|
|
278
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
279
|
+
return 0
|
|
280
|
+
s = data["session"]
|
|
281
|
+
print(f"session: {data['session_id']} {s.get('title') or 'Untitled'}")
|
|
282
|
+
if data["projects"]:
|
|
283
|
+
print("project: " + ", ".join(data["projects"]))
|
|
284
|
+
c = data["counts"]
|
|
285
|
+
print(
|
|
286
|
+
f"produced: {c['nodes']} node(s), {c['edges']} edge(s) · "
|
|
287
|
+
f"touched: {c['files']} file(s), {c['tools']} tool(s), "
|
|
288
|
+
f"{c['commits']} commit(s)"
|
|
289
|
+
)
|
|
290
|
+
if data["nodes_owned"]:
|
|
291
|
+
owned = [n for n in data["nodes_owned"] if n["kind"] == "tool_call"]
|
|
292
|
+
if owned:
|
|
293
|
+
print(f"tool calls ({len(owned)}):")
|
|
294
|
+
for n in owned:
|
|
295
|
+
print(f" {n['key']}")
|
|
296
|
+
if data["files"]:
|
|
297
|
+
print(f"files ({len(data['files'])}):")
|
|
298
|
+
for f in data["files"]:
|
|
299
|
+
print(f" {f}")
|
|
300
|
+
if data["tools"]:
|
|
301
|
+
print("tools: " + ", ".join(data["tools"]))
|
|
302
|
+
for cm in data["commits"]:
|
|
303
|
+
print(f"commit {cm['sha'][:7]} ({cm['relation']})")
|
|
304
|
+
return 0
|
|
305
|
+
|
|
306
|
+
|
|
229
307
|
def _cmd_export(args: argparse.Namespace) -> int:
|
|
230
308
|
with _open_graph(args.graph) as gs:
|
|
231
309
|
data = gs.export()
|
|
@@ -232,3 +232,103 @@ def shared_files(gs) -> dict:
|
|
|
232
232
|
"sessions": sorted(sessions_of_file[file_key]),
|
|
233
233
|
})
|
|
234
234
|
return {"count": len(files), "files": files}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def orphaned_nodes(gs) -> dict:
|
|
238
|
+
"""Nodes with no incident edges — report-only, never deletes.
|
|
239
|
+
|
|
240
|
+
``prunable`` marks the subset the builder's orphan prune would drop
|
|
241
|
+
on the next run (shared nodes — ``owner IS NULL`` — with no edges);
|
|
242
|
+
owned orphans (e.g. a session with no tool calls and no project)
|
|
243
|
+
carry provenance through their owner and persist until that owner
|
|
244
|
+
is re-extracted or removed.
|
|
245
|
+
"""
|
|
246
|
+
endpoints = {e.src for e in gs.edges()} | {e.dst for e in gs.edges()}
|
|
247
|
+
nodes = [
|
|
248
|
+
{
|
|
249
|
+
"id": n.id,
|
|
250
|
+
"kind": n.kind,
|
|
251
|
+
"key": n.key,
|
|
252
|
+
"owner": n.owner,
|
|
253
|
+
"name": n.attrs.get("name") or n.attrs.get("title"),
|
|
254
|
+
"prunable": n.owner is None,
|
|
255
|
+
}
|
|
256
|
+
for n in gs.nodes()
|
|
257
|
+
if n.id not in endpoints
|
|
258
|
+
]
|
|
259
|
+
return {"count": len(nodes), "nodes": nodes}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def blame_session(gs, query: str) -> dict:
|
|
263
|
+
"""What graph content session ``query`` produced or touched.
|
|
264
|
+
|
|
265
|
+
``produced`` = nodes and edges the extraction stamped with this
|
|
266
|
+
session as ``owner`` (the session node itself, its tool_call nodes,
|
|
267
|
+
and every edge it emitted); ``touched`` = the shared entities those
|
|
268
|
+
edges reach — files, tools, projects, commits. ``session`` is
|
|
269
|
+
``None`` when ``query`` matches nothing or is an ambiguous prefix —
|
|
270
|
+
``candidates`` then lists session keys for the CLI to hint at.
|
|
271
|
+
"""
|
|
272
|
+
node = gs.node(f"session:{query}")
|
|
273
|
+
candidates: list[str] = []
|
|
274
|
+
if node is None:
|
|
275
|
+
candidates = sorted(
|
|
276
|
+
n.key for n in gs.nodes("session") if n.key.startswith(query)
|
|
277
|
+
)
|
|
278
|
+
if len(candidates) == 1:
|
|
279
|
+
node = gs.node(f"session:{candidates[0]}")
|
|
280
|
+
if node is None:
|
|
281
|
+
return {
|
|
282
|
+
"query": query,
|
|
283
|
+
"session": None,
|
|
284
|
+
"candidates": candidates[:20],
|
|
285
|
+
}
|
|
286
|
+
sid = node.key
|
|
287
|
+
owned_nodes = [n for n in gs.nodes() if n.owner == sid]
|
|
288
|
+
owned_edges = [e for e in gs.edges() if e.owner == sid]
|
|
289
|
+
files = sorted(
|
|
290
|
+
{e.dst.split(":", 1)[1] for e in owned_edges
|
|
291
|
+
if e.kind == "file_touched"}
|
|
292
|
+
)
|
|
293
|
+
tools = sorted(
|
|
294
|
+
{e.dst.split(":", 1)[1] for e in owned_edges
|
|
295
|
+
if e.kind == "tool_used"}
|
|
296
|
+
)
|
|
297
|
+
projects = sorted(
|
|
298
|
+
{e.dst.split(":", 1)[1] for e in owned_edges
|
|
299
|
+
if e.kind == "runs_in"}
|
|
300
|
+
)
|
|
301
|
+
commits = [
|
|
302
|
+
{"sha": sha, "relation": rel}
|
|
303
|
+
for sha, rel in sorted(
|
|
304
|
+
{
|
|
305
|
+
(e.dst.split(":", 1)[1], e.kind) # produced | referenced
|
|
306
|
+
for e in owned_edges
|
|
307
|
+
if e.kind in ("produced", "referenced")
|
|
308
|
+
}
|
|
309
|
+
)
|
|
310
|
+
]
|
|
311
|
+
return {
|
|
312
|
+
"session_id": sid,
|
|
313
|
+
"session": _session_dict(gs, node),
|
|
314
|
+
"extracted_at": gs.extracted_at(sid),
|
|
315
|
+
"projects": projects,
|
|
316
|
+
"files": files,
|
|
317
|
+
"tools": tools,
|
|
318
|
+
"commits": commits,
|
|
319
|
+
"nodes_owned": [
|
|
320
|
+
{"id": n.id, "kind": n.kind, "key": n.key}
|
|
321
|
+
for n in owned_nodes
|
|
322
|
+
],
|
|
323
|
+
"edges_owned": [
|
|
324
|
+
{"kind": e.kind, "src": e.src, "dst": e.dst}
|
|
325
|
+
for e in owned_edges
|
|
326
|
+
],
|
|
327
|
+
"counts": {
|
|
328
|
+
"nodes": len(owned_nodes),
|
|
329
|
+
"edges": len(owned_edges),
|
|
330
|
+
"files": len(files),
|
|
331
|
+
"tools": len(tools),
|
|
332
|
+
"commits": len(commits),
|
|
333
|
+
},
|
|
334
|
+
}
|
|
@@ -60,6 +60,7 @@ class StoredNode:
|
|
|
60
60
|
kind: str
|
|
61
61
|
key: str
|
|
62
62
|
attrs: dict
|
|
63
|
+
owner: str | None = None # session_id that produced the node
|
|
63
64
|
|
|
64
65
|
|
|
65
66
|
@dataclass(frozen=True)
|
|
@@ -118,13 +119,14 @@ class GraphStore:
|
|
|
118
119
|
# -- read API ------------------------------------------------------------
|
|
119
120
|
|
|
120
121
|
def nodes(self, kind: str | None = None) -> list[StoredNode]:
|
|
121
|
-
sql = "SELECT id, kind, key, attrs FROM nodes"
|
|
122
|
+
sql = "SELECT id, kind, key, attrs, owner FROM nodes"
|
|
122
123
|
args: tuple = ()
|
|
123
124
|
if kind is not None:
|
|
124
125
|
sql += " WHERE kind = ?"
|
|
125
126
|
args = (kind,)
|
|
126
127
|
return [
|
|
127
|
-
StoredNode(r["id"], r["kind"], r["key"],
|
|
128
|
+
StoredNode(r["id"], r["kind"], r["key"],
|
|
129
|
+
json.loads(r["attrs"]), r["owner"])
|
|
128
130
|
for r in self._con.execute(sql + " ORDER BY id", args)
|
|
129
131
|
]
|
|
130
132
|
|
|
@@ -142,13 +144,13 @@ class GraphStore:
|
|
|
142
144
|
|
|
143
145
|
def node(self, node_id: str) -> StoredNode | None:
|
|
144
146
|
row = self._con.execute(
|
|
145
|
-
"SELECT id, kind, key, attrs FROM nodes WHERE id = ?",
|
|
147
|
+
"SELECT id, kind, key, attrs, owner FROM nodes WHERE id = ?",
|
|
146
148
|
(node_id,),
|
|
147
149
|
).fetchone()
|
|
148
150
|
if row is None:
|
|
149
151
|
return None
|
|
150
152
|
return StoredNode(row["id"], row["kind"], row["key"],
|
|
151
|
-
json.loads(row["attrs"]))
|
|
153
|
+
json.loads(row["attrs"]), row["owner"])
|
|
152
154
|
|
|
153
155
|
def out_edges(self, node_id: str) -> list[StoredEdge]:
|
|
154
156
|
return [
|
|
@@ -172,6 +174,16 @@ class GraphStore:
|
|
|
172
174
|
)
|
|
173
175
|
]
|
|
174
176
|
|
|
177
|
+
def extracted_at(self, session_id: str) -> int | None:
|
|
178
|
+
"""Ledger timestamp for ``session_id`` — when it was last
|
|
179
|
+
(re)extracted into this graph, or ``None``."""
|
|
180
|
+
row = self._con.execute(
|
|
181
|
+
"SELECT last_activity_at FROM extracted_sessions"
|
|
182
|
+
" WHERE session_id = ?",
|
|
183
|
+
(session_id,),
|
|
184
|
+
).fetchone()
|
|
185
|
+
return row["last_activity_at"] if row else None
|
|
186
|
+
|
|
175
187
|
# -- build ---------------------------------------------------------------
|
|
176
188
|
|
|
177
189
|
def build(self, sessions_store,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devin-graph
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Summary: Knowledge graph over Devin sessions — projects, files, tools and decisions as queryable nodes.
|
|
5
5
|
Author: Icaro0310
|
|
6
6
|
License: MIT
|
|
@@ -97,6 +97,10 @@ devin-graph query projects-graph --graph graph.db --json
|
|
|
97
97
|
|
|
98
98
|
# D3-friendly dump: {"meta", "nodes", "edges"}
|
|
99
99
|
devin-graph export --format json --graph graph.db --out graph.json
|
|
100
|
+
|
|
101
|
+
# diagnostics: edge-less nodes (report-only) and per-session blame
|
|
102
|
+
devin-graph orphans --graph graph.db
|
|
103
|
+
devin-graph blame-session <id-or-prefix> --graph graph.db
|
|
100
104
|
```
|
|
101
105
|
|
|
102
106
|
Matching is forgiving (`src/app.py` finds `/repo/alpha/src/app.py`);
|
|
@@ -105,6 +109,15 @@ by two or more distinct projects, with the project and session lists —
|
|
|
105
109
|
exportable as JSON like the other queries. The source DB is opened
|
|
106
110
|
`mode=ro` and never written — tests assert its hash is unchanged.
|
|
107
111
|
|
|
112
|
+
`orphans` lists nodes with no incident edges (id, kind, owner, a
|
|
113
|
+
`prunable` flag marking the subset the next `build` would drop —
|
|
114
|
+
shared, ownerless nodes; owned orphans persist with their owner). It is
|
|
115
|
+
report-only and never deletes. `blame-session <id>` shows what a
|
|
116
|
+
session produced and touched: its owned nodes and edges plus the
|
|
117
|
+
resolved files, tools, projects and commits reachable through them,
|
|
118
|
+
with the extraction-ledger timestamp. `<id>` accepts a unique prefix;
|
|
119
|
+
a missing or ambiguous match exits 2 and lists candidates.
|
|
120
|
+
|
|
108
121
|
## Works with Devin alone (Devin-only mode)
|
|
109
122
|
|
|
110
123
|
devin-graph builds `graph.db` locally from Devin's session stores — the whole
|
|
@@ -7,6 +7,7 @@ import pytest
|
|
|
7
7
|
from conftest import ALPHA
|
|
8
8
|
from devin_graph.cli import main
|
|
9
9
|
from devin_internals.fixtures import create_sessions_db
|
|
10
|
+
from devin_internals.schema import LATEST_KNOWN_SCHEMA
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
@pytest.fixture
|
|
@@ -123,7 +124,9 @@ def test_cli_export_stdout(built, capsys):
|
|
|
123
124
|
assert main(["export", "--format", "json", "--graph", str(graph)]) == 0
|
|
124
125
|
data = json.loads(capsys.readouterr().out)
|
|
125
126
|
assert len(data["nodes"]) == 20 and len(data["edges"]) == 25
|
|
126
|
-
|
|
127
|
+
# fixture emits the installed spec's latest schema (v17 at 0.3.1,
|
|
128
|
+
# v18 at 0.3.3) — pin to the constant, not a literal
|
|
129
|
+
assert data["meta"]["schema_version"] == LATEST_KNOWN_SCHEMA
|
|
127
130
|
|
|
128
131
|
|
|
129
132
|
def test_cli_export_out_file(built, tmp_path, capsys):
|
|
@@ -145,7 +148,7 @@ def test_cli_missing_sessions_db(tmp_path, capsys):
|
|
|
145
148
|
|
|
146
149
|
|
|
147
150
|
def test_cli_unknown_schema_fails_loud(tmp_path, capsys):
|
|
148
|
-
bad = create_sessions_db(tmp_path / "future.db", schema_version=
|
|
151
|
+
bad = create_sessions_db(tmp_path / "future.db", schema_version=LATEST_KNOWN_SCHEMA + 1)
|
|
149
152
|
with pytest.raises(SystemExit) as exc:
|
|
150
153
|
main(["build", "--sessions-db", str(bad),
|
|
151
154
|
"--graph", str(tmp_path / "g.db")])
|
|
@@ -26,6 +26,7 @@ import socket
|
|
|
26
26
|
|
|
27
27
|
import pytest
|
|
28
28
|
from devin_graph.cli import main
|
|
29
|
+
from devin_internals.schema import LATEST_KNOWN_SCHEMA
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
class OfflineCoreError(RuntimeError):
|
|
@@ -67,7 +68,9 @@ def test_build_and_export_offline(sessions_db, tmp_path, capsys):
|
|
|
67
68
|
assert main(["export", "--format", "json", "--graph", str(graph)]) == 0
|
|
68
69
|
data = json.loads(capsys.readouterr().out)
|
|
69
70
|
assert len(data["nodes"]) > 0 and len(data["edges"]) > 0
|
|
70
|
-
|
|
71
|
+
# fixture emits the installed spec's latest schema (v17 at 0.3.1,
|
|
72
|
+
# v18 at 0.3.3) — pin to the constant, not a literal
|
|
73
|
+
assert data["meta"]["schema_version"] == LATEST_KNOWN_SCHEMA
|
|
71
74
|
|
|
72
75
|
|
|
73
76
|
def test_query_offline(sessions_db, tmp_path, capsys):
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""``graph orphans`` + ``graph blame-session`` — report-only diagnostics."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import sqlite3
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from conftest import ALPHA
|
|
8
|
+
from devin_graph.cli import main
|
|
9
|
+
from devin_graph.query import blame_session, orphaned_nodes
|
|
10
|
+
from devin_graph.store import GraphStore
|
|
11
|
+
from devin_internals.parsers import SessionsStore
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def graph_path(sessions_db, tmp_path):
|
|
16
|
+
p = tmp_path / "graph.db"
|
|
17
|
+
with SessionsStore(sessions_db) as ss, GraphStore(p) as gs:
|
|
18
|
+
gs.build(ss)
|
|
19
|
+
return p
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _insert_orphan(graph_path, node_id="file:/ghost", owner=None):
|
|
23
|
+
"""An edgeless shared node straight into the db — the fixture graph
|
|
24
|
+
prunes real orphans at build time, so the only way to see one is to
|
|
25
|
+
write it after the build."""
|
|
26
|
+
con = sqlite3.connect(graph_path)
|
|
27
|
+
with con:
|
|
28
|
+
con.execute(
|
|
29
|
+
"INSERT INTO nodes(id, kind, key, owner, attrs)"
|
|
30
|
+
" VALUES (?, ?, ?, ?, '{}')",
|
|
31
|
+
(node_id, "file", node_id.split(":", 1)[1], owner),
|
|
32
|
+
)
|
|
33
|
+
con.close()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# -- orphaned_nodes ------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_orphans_empty_in_healthy_graph(graph_path):
|
|
40
|
+
with GraphStore(graph_path, readonly=True) as gs:
|
|
41
|
+
data = orphaned_nodes(gs)
|
|
42
|
+
assert data == {"count": 0, "nodes": []}
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_orphans_reports_and_marks_prunable(graph_path):
|
|
46
|
+
_insert_orphan(graph_path)
|
|
47
|
+
with GraphStore(graph_path, readonly=True) as gs:
|
|
48
|
+
data = orphaned_nodes(gs)
|
|
49
|
+
assert data["count"] == 1
|
|
50
|
+
(n,) = data["nodes"]
|
|
51
|
+
assert n["id"] == "file:/ghost"
|
|
52
|
+
assert n["kind"] == "file" and n["owner"] is None
|
|
53
|
+
assert n["prunable"] is True
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_orphans_report_only_never_deletes(graph_path):
|
|
57
|
+
_insert_orphan(graph_path)
|
|
58
|
+
with GraphStore(graph_path, readonly=True) as gs:
|
|
59
|
+
orphaned_nodes(gs)
|
|
60
|
+
con = sqlite3.connect(graph_path)
|
|
61
|
+
try:
|
|
62
|
+
assert con.execute(
|
|
63
|
+
"SELECT COUNT(*) FROM nodes WHERE id = 'file:/ghost'"
|
|
64
|
+
).fetchone()[0] == 1
|
|
65
|
+
finally:
|
|
66
|
+
con.close()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_owned_orphan_not_prunable(graph_path):
|
|
70
|
+
# an owner-carried orphan persists until its owner re-extracts —
|
|
71
|
+
# report it, but flag it as kept-by-prune.
|
|
72
|
+
_insert_orphan(graph_path, "session:ghost-sess", owner="ghost-sess")
|
|
73
|
+
with GraphStore(graph_path, readonly=True) as gs:
|
|
74
|
+
data = orphaned_nodes(gs)
|
|
75
|
+
(n,) = data["nodes"]
|
|
76
|
+
assert n["prunable"] is False and n["owner"] == "ghost-sess"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# -- blame_session --------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_blame_session_contents(graph_path):
|
|
83
|
+
with GraphStore(graph_path, readonly=True) as gs:
|
|
84
|
+
data = blame_session(gs, "sess-a")
|
|
85
|
+
s = data["session"]
|
|
86
|
+
assert s["title"] == "Alpha work"
|
|
87
|
+
assert data["projects"] == [ALPHA]
|
|
88
|
+
assert data["extracted_at"] is not None
|
|
89
|
+
# produced: 1 session node + 3 tool_call nodes
|
|
90
|
+
kinds = [n["kind"] for n in data["nodes_owned"]]
|
|
91
|
+
assert kinds.count("session") == 1 and kinds.count("tool_call") == 3
|
|
92
|
+
assert data["counts"]["nodes"] == 4
|
|
93
|
+
# touched: the read of ALPHA/src/app.py (resolve joins it under cwd)
|
|
94
|
+
assert f"{ALPHA}/src/app.py" in data["files"]
|
|
95
|
+
assert data["counts"]["files"] == len(data["files"])
|
|
96
|
+
# edges: runs_in + 3× made_call + call_used/tool_used/file_touched
|
|
97
|
+
assert data["counts"]["edges"] == len(data["edges_owned"])
|
|
98
|
+
assert any(e["kind"] == "runs_in" for e in data["edges_owned"])
|
|
99
|
+
assert any(e["kind"] == "file_touched" for e in data["edges_owned"])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_blame_session_no_match(graph_path):
|
|
103
|
+
with GraphStore(graph_path, readonly=True) as gs:
|
|
104
|
+
data = blame_session(gs, "zzz")
|
|
105
|
+
assert data["session"] is None
|
|
106
|
+
assert data["candidates"] == []
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_blame_session_ambiguous_prefix(graph_path):
|
|
110
|
+
with GraphStore(graph_path, readonly=True) as gs:
|
|
111
|
+
data = blame_session(gs, "sess")
|
|
112
|
+
assert data["session"] is None
|
|
113
|
+
assert set(data["candidates"]) == {"sess-a", "sess-b", "sess-c"}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# -- CLI ------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_cli_orphans_empty(graph_path, capsys):
|
|
120
|
+
assert main(["orphans", "--graph", str(graph_path)]) == 0
|
|
121
|
+
assert "no orphaned nodes" in capsys.readouterr().out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def test_cli_orphans_json(graph_path, capsys):
|
|
125
|
+
_insert_orphan(graph_path)
|
|
126
|
+
assert main(["orphans", "--graph", str(graph_path), "--json"]) == 0
|
|
127
|
+
data = json.loads(capsys.readouterr().out)
|
|
128
|
+
assert data["count"] == 1
|
|
129
|
+
assert data["nodes"][0]["id"] == "file:/ghost"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_cli_blame_text(graph_path, capsys):
|
|
133
|
+
assert main(["blame-session", "sess-b", "--graph", str(graph_path)]) == 0
|
|
134
|
+
out = capsys.readouterr().out
|
|
135
|
+
assert "Beta work" in out
|
|
136
|
+
assert "produced:" in out and "touched:" in out
|
|
137
|
+
assert f"{ALPHA}/src/app.py" in out # shared file sess-b also read
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def test_cli_blame_json(graph_path, capsys):
|
|
141
|
+
assert main(
|
|
142
|
+
["blame-session", "sess-c", "--graph", str(graph_path), "--json"]
|
|
143
|
+
) == 0
|
|
144
|
+
data = json.loads(capsys.readouterr().out)
|
|
145
|
+
assert data["session_id"] == "sess-c"
|
|
146
|
+
assert data["session"]["title"] == "Alpha docs"
|
|
147
|
+
assert data["counts"]["nodes"] >= 2
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_cli_blame_no_match_exit_2(graph_path, capsys):
|
|
151
|
+
rc = main(["blame-session", "nope", "--graph", str(graph_path)])
|
|
152
|
+
assert rc == 2
|
|
153
|
+
assert "no session matches" in capsys.readouterr().err
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_cli_blame_ambiguous_lists_candidates(graph_path, capsys):
|
|
157
|
+
rc = main(["blame-session", "sess", "--graph", str(graph_path)])
|
|
158
|
+
assert rc == 2
|
|
159
|
+
err = capsys.readouterr().err
|
|
160
|
+
assert "sess-a" in err and "sess-b" in err
|
|
@@ -6,6 +6,7 @@ import sqlite3
|
|
|
6
6
|
from conftest import ALPHA, BETA, add_tool_call, bump_last_activity, delete_session
|
|
7
7
|
from devin_graph.store import GraphStore
|
|
8
8
|
from devin_internals.parsers import SessionsStore
|
|
9
|
+
from devin_internals.schema import LATEST_KNOWN_SCHEMA
|
|
9
10
|
|
|
10
11
|
EXPECTED_NODE_COUNTS = {
|
|
11
12
|
"session": 3, "project": 2, "tool": 4, "tool_call": 6, "file": 5}
|
|
@@ -95,7 +96,9 @@ def test_export_json_shape(sessions_db, tmp_path):
|
|
|
95
96
|
data = gs.export()
|
|
96
97
|
|
|
97
98
|
assert set(data) == {"meta", "nodes", "edges"}
|
|
98
|
-
|
|
99
|
+
# fixture emits the installed spec's latest schema (v17 at 0.3.1,
|
|
100
|
+
# v18 at 0.3.3) — pin to the constant, not a literal
|
|
101
|
+
assert data["meta"]["schema_version"] == LATEST_KNOWN_SCHEMA
|
|
99
102
|
json.dumps(data) # must be JSON-serializable
|
|
100
103
|
|
|
101
104
|
node = next(n for n in data["nodes"] if n["id"] == "session:sess-a")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.1"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|