expluro 0.3.1__tar.gz → 0.4.0__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.
- {expluro-0.3.1 → expluro-0.4.0}/PKG-INFO +1 -1
- {expluro-0.3.1 → expluro-0.4.0}/expluro.egg-info/PKG-INFO +1 -1
- {expluro-0.3.1 → expluro-0.4.0}/expluro.egg-info/SOURCES.txt +2 -0
- {expluro-0.3.1 → expluro-0.4.0}/pyproject.toml +1 -1
- expluro-0.4.0/topo/attribution.py +329 -0
- {expluro-0.3.1 → expluro-0.4.0}/topo/backend.py +104 -2
- {expluro-0.3.1 → expluro-0.4.0}/topo/capture.py +54 -2
- {expluro-0.3.1 → expluro-0.4.0}/topo/drivers/acp.py +10 -8
- {expluro-0.3.1 → expluro-0.4.0}/topo/drivers/claude.py +13 -10
- expluro-0.4.0/topo/drivers/codex.py +1622 -0
- {expluro-0.3.1 → expluro-0.4.0}/topo/drivers/kiro_session_files.py +19 -21
- {expluro-0.3.1 → expluro-0.4.0}/topo/main.py +3 -1
- {expluro-0.3.1 → expluro-0.4.0}/topo/renderer.py +105 -12
- {expluro-0.3.1 → expluro-0.4.0}/topo/session.py +627 -35
- {expluro-0.3.1 → expluro-0.4.0}/topo/session_files.py +39 -32
- {expluro-0.3.1 → expluro-0.4.0}/topo/snapshot.py +446 -6
- {expluro-0.3.1 → expluro-0.4.0}/expluro.egg-info/dependency_links.txt +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/expluro.egg-info/entry_points.txt +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/expluro.egg-info/requires.txt +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/expluro.egg-info/top_level.txt +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/setup.cfg +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/topo/__init__.py +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/topo/auth.py +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/topo/constants.py +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/topo/display.py +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/topo/drivers/__init__.py +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/topo/drivers/_branch_detection.py +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/topo/drivers/_permissions.py +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/topo/drivers/base.py +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/topo/picker.py +0 -0
- {expluro-0.3.1 → expluro-0.4.0}/topo/update_check.py +0 -0
|
@@ -6,6 +6,7 @@ expluro.egg-info/entry_points.txt
|
|
|
6
6
|
expluro.egg-info/requires.txt
|
|
7
7
|
expluro.egg-info/top_level.txt
|
|
8
8
|
topo/__init__.py
|
|
9
|
+
topo/attribution.py
|
|
9
10
|
topo/auth.py
|
|
10
11
|
topo/backend.py
|
|
11
12
|
topo/capture.py
|
|
@@ -24,4 +25,5 @@ topo/drivers/_permissions.py
|
|
|
24
25
|
topo/drivers/acp.py
|
|
25
26
|
topo/drivers/base.py
|
|
26
27
|
topo/drivers/claude.py
|
|
28
|
+
topo/drivers/codex.py
|
|
27
29
|
topo/drivers/kiro_session_files.py
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
"""Hunk-level reason attribution for the Files-changed tab.
|
|
2
|
+
|
|
3
|
+
Maps git commit hashes (from `git blame` in the shadow repo) to expluro
|
|
4
|
+
event ids, so each hunk in a node's net diff can be traced back to the
|
|
5
|
+
tool call that produced it. Output feeds the reason-linked diff UI:
|
|
6
|
+
clicking a hunk's attribution chip opens a side rail with the responsible
|
|
7
|
+
prompt + tool call + assistant message.
|
|
8
|
+
|
|
9
|
+
Kept separate from snapshot.py (which stays git-mechanical) — this
|
|
10
|
+
module knows about expluro's event semantics.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any, Iterable
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Flat tool_input keys that carry a file path. Different agents use
|
|
21
|
+
# different conventions:
|
|
22
|
+
# - Claude Code: `file_path` (Write/Edit/MultiEdit), `notebook_path` (NotebookEdit)
|
|
23
|
+
# - ACP (Kiro): `path`
|
|
24
|
+
# - Codex: path is NOT at the top level for fileChange items;
|
|
25
|
+
# it lives at `changes[*].path`. _extract_tool_paths
|
|
26
|
+
# below handles that nested shape. `savedPath` is
|
|
27
|
+
# the flat key on Codex `imageGeneration` items.
|
|
28
|
+
_PATH_KEYS = ('file_path', 'path', 'notebook_path', 'savedPath')
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Best-effort recognition of "this event is a file-mutating tool call
|
|
32
|
+
# by named path." Three drivers' naming conventions need to match:
|
|
33
|
+
# - Claude Code: 'Write', 'Edit', 'MultiEdit', 'NotebookEdit'
|
|
34
|
+
# - ACP (Kiro): kind='edit', titles like 'Edit File: foo.py', 'Create File: bar.py'
|
|
35
|
+
# - Codex: item type='fileChange' (the main one), 'imageGeneration'
|
|
36
|
+
# (writes a generated image to disk).
|
|
37
|
+
#
|
|
38
|
+
# Case-insensitive substring match keeps us robust to capitalization
|
|
39
|
+
# AND to verbose titles ("Edit File") AND to single-word codex item
|
|
40
|
+
# types ("fileChange" → matches via 'filechange'). The explicit
|
|
41
|
+
# read-tool exclusion below prevents `Read`/`Grep` from spuriously
|
|
42
|
+
# naming files.
|
|
43
|
+
#
|
|
44
|
+
# Bash / commandExecution are intentionally NOT mutation tokens —
|
|
45
|
+
# they execute shell commands that may or may not mutate files, and
|
|
46
|
+
# topo has no structured path to attribute. Future: a per-turn
|
|
47
|
+
# snapshot boundary would let us attribute Bash effects too.
|
|
48
|
+
#
|
|
49
|
+
# mcpToolCall / dynamicToolCall are also excluded — arbitrary tools
|
|
50
|
+
# whose file effects are unpredictable; conservatively classify any
|
|
51
|
+
# files they touch as Unattributed rather than misclaiming attribution.
|
|
52
|
+
_MUTATION_TOOL_TOKENS = (
|
|
53
|
+
# Claude Code names
|
|
54
|
+
'write', 'edit', 'multiedit', 'notebookedit',
|
|
55
|
+
# ACP / Kiro verbose titles
|
|
56
|
+
'create file', 'write file', 'edit file',
|
|
57
|
+
# Codex item types (lowercase form because we case-fold)
|
|
58
|
+
'filechange', 'imagegeneration',
|
|
59
|
+
# Patch operations across agents
|
|
60
|
+
'patch', 'apply patch',
|
|
61
|
+
)
|
|
62
|
+
_READ_TOOL_TOKENS = ('read', 'grep', 'glob', 'search', 'ls', 'list')
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _extract_tool_paths(tool_input: dict) -> list[str]:
|
|
66
|
+
"""Pull every file path the agent named out of a tool's input dict.
|
|
67
|
+
|
|
68
|
+
Three shapes to handle:
|
|
69
|
+
- Claude / ACP: flat string at `tool_input.file_path` /
|
|
70
|
+
`tool_input.path` / `tool_input.notebook_path`.
|
|
71
|
+
- Codex `imageGeneration`: flat string at `tool_input.savedPath`.
|
|
72
|
+
- Codex `fileChange`: nested under `tool_input.changes[*].path`,
|
|
73
|
+
where each entry also has `diff` and `kind`. A single
|
|
74
|
+
fileChange item can touch multiple files in one call.
|
|
75
|
+
|
|
76
|
+
Returns the deduplicated path strings, preserving input order so
|
|
77
|
+
the most-specific path appears first when callers iterate.
|
|
78
|
+
"""
|
|
79
|
+
paths: list[str] = []
|
|
80
|
+
seen: set[str] = set()
|
|
81
|
+
|
|
82
|
+
def _add(value: object) -> None:
|
|
83
|
+
if isinstance(value, str) and value and value not in seen:
|
|
84
|
+
seen.add(value)
|
|
85
|
+
paths.append(value)
|
|
86
|
+
|
|
87
|
+
if not isinstance(tool_input, dict):
|
|
88
|
+
return paths
|
|
89
|
+
# Flat string keys first — covers Claude, ACP, and Codex's
|
|
90
|
+
# imageGeneration savedPath.
|
|
91
|
+
for key in _PATH_KEYS:
|
|
92
|
+
_add(tool_input.get(key))
|
|
93
|
+
# Codex fileChange items nest paths under `changes[*].path`.
|
|
94
|
+
changes = tool_input.get('changes')
|
|
95
|
+
if isinstance(changes, list):
|
|
96
|
+
for entry in changes:
|
|
97
|
+
if isinstance(entry, dict):
|
|
98
|
+
_add(entry.get('path'))
|
|
99
|
+
return paths
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_file_mutation_tool(tool_name: str | None) -> bool:
|
|
103
|
+
"""Does this tool_name look like an agent file-mutation operation?
|
|
104
|
+
|
|
105
|
+
Case-insensitive. Mutation tokens take precedence over read tokens
|
|
106
|
+
so an unlikely "edit_read_metadata"-style name still classifies as
|
|
107
|
+
mutation if any mutation token matches. Returns False for empty
|
|
108
|
+
names and for tools whose name is purely a read/list operation.
|
|
109
|
+
"""
|
|
110
|
+
if not tool_name:
|
|
111
|
+
return False
|
|
112
|
+
n = tool_name.strip().lower()
|
|
113
|
+
for token in _MUTATION_TOOL_TOKENS:
|
|
114
|
+
if token in n:
|
|
115
|
+
return True
|
|
116
|
+
# No mutation match — check if it's a clear read-only tool.
|
|
117
|
+
for token in _READ_TOOL_TOKENS:
|
|
118
|
+
if n == token or n.startswith(token + ' ') or n.startswith(token + '_'):
|
|
119
|
+
return False
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def build_hunk_attributions(
|
|
124
|
+
file_changes: list[dict],
|
|
125
|
+
events: Iterable[dict],
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Splice `hunk_attributions` and `attribution_class` into each
|
|
128
|
+
file_change row in place.
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
file_changes: rows from `SnapshotManager.net_diff_summary`. Each
|
|
132
|
+
row may carry a `hunk_blame` field (added by snapshot.py)
|
|
133
|
+
holding per-hunk commit-hash counts. After this call, each
|
|
134
|
+
row also carries `hunk_attributions` (and `hunk_blame` is
|
|
135
|
+
removed — it's an intermediate the backend doesn't need)
|
|
136
|
+
and a per-file `attribution_class` of either
|
|
137
|
+
`'attributed'` (some tool event in this node explicitly
|
|
138
|
+
named this file's path) or `'unattributed'` (file changed
|
|
139
|
+
but no captured tool action referenced it — e.g., a
|
|
140
|
+
background process, an external editor, /tmp cleanup, etc.).
|
|
141
|
+
events: chronologically-ordered list of the node's events. Each
|
|
142
|
+
event is expected to be a dict with at minimum `id` and
|
|
143
|
+
`snapshot_hash`; events touching files have
|
|
144
|
+
`payload.diff_summary.files_changed`.
|
|
145
|
+
|
|
146
|
+
Side effect: each file_change row gains `hunk_attributions` and
|
|
147
|
+
`attribution_class`. The class is used by the UI to render
|
|
148
|
+
unattributed files in a separate section without a misleading
|
|
149
|
+
reason block.
|
|
150
|
+
"""
|
|
151
|
+
events_list = list(events)
|
|
152
|
+
# Map shadow-git commit hash → first event_id with that snapshot_hash.
|
|
153
|
+
# FIRST because subsequent events sharing a hash mean the tool ran
|
|
154
|
+
# but committed nothing — they didn't contribute changes. Earliest
|
|
155
|
+
# event is the one that introduced the snapshot.
|
|
156
|
+
commit_to_event: dict[str, str] = {}
|
|
157
|
+
# Per-path: list of event_ids in chronological order that touched
|
|
158
|
+
# the path. Used for the deletion fallback (last tool call to touch
|
|
159
|
+
# a path) and for diagnostics.
|
|
160
|
+
path_to_events: dict[str, list[str]] = {}
|
|
161
|
+
# Set of file paths that any mutation-capable tool event explicitly
|
|
162
|
+
# named in its tool_input — the agent's stated target, NOT the
|
|
163
|
+
# post-call diff. A file is "Attributed" iff its path matches one
|
|
164
|
+
# of these. Stored as raw (typically absolute) paths; matched
|
|
165
|
+
# against the relative file_change paths via suffix comparison
|
|
166
|
+
# because git diff yields paths relative to the work-tree root
|
|
167
|
+
# while tool inputs use absolute paths.
|
|
168
|
+
tool_named_paths: set[str] = set()
|
|
169
|
+
for ev in events_list:
|
|
170
|
+
ev_id = str(ev.get('id') or '')
|
|
171
|
+
if not ev_id:
|
|
172
|
+
continue
|
|
173
|
+
snap = (ev.get('snapshot_hash') or '').strip()
|
|
174
|
+
if snap and snap not in commit_to_event:
|
|
175
|
+
commit_to_event[snap] = ev_id
|
|
176
|
+
payload = ev.get('payload') or {}
|
|
177
|
+
diff_summary = payload.get('diff_summary') or {}
|
|
178
|
+
files = diff_summary.get('files_changed') or []
|
|
179
|
+
for fc in files:
|
|
180
|
+
if isinstance(fc, dict):
|
|
181
|
+
path = fc.get('path')
|
|
182
|
+
if path:
|
|
183
|
+
path_to_events.setdefault(path, []).append(ev_id)
|
|
184
|
+
# Collect paths the agent explicitly named, regardless of
|
|
185
|
+
# event_type. pre_tool_use carries the intended input;
|
|
186
|
+
# post_tool_use repeats it. Either is fine for set membership.
|
|
187
|
+
# _is_file_mutation_tool() recognizes Claude Code's tool names,
|
|
188
|
+
# ACP kind='edit', and common verbose forms ("Edit File", etc.)
|
|
189
|
+
# so the classifier works across drivers.
|
|
190
|
+
if _is_file_mutation_tool(ev.get('tool_name')):
|
|
191
|
+
tool_input = payload.get('tool_input') or {}
|
|
192
|
+
for named in _extract_tool_paths(tool_input):
|
|
193
|
+
tool_named_paths.add(named)
|
|
194
|
+
|
|
195
|
+
for row in file_changes:
|
|
196
|
+
hunk_blame = row.pop('hunk_blame', None) or []
|
|
197
|
+
path = row.get('path', '')
|
|
198
|
+
attributions: list[dict] = []
|
|
199
|
+
for h_idx, hb in enumerate(hunk_blame):
|
|
200
|
+
attributions.append(_attribute_hunk(
|
|
201
|
+
hunk_index=h_idx,
|
|
202
|
+
hunk_blame=hb,
|
|
203
|
+
path=path,
|
|
204
|
+
commit_to_event=commit_to_event,
|
|
205
|
+
path_to_events=path_to_events,
|
|
206
|
+
))
|
|
207
|
+
row['hunk_attributions'] = attributions
|
|
208
|
+
row['attribution_class'] = (
|
|
209
|
+
'attributed' if _path_matches_tool_named(path, tool_named_paths)
|
|
210
|
+
else 'unattributed'
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _path_matches_tool_named(rel_path: str, tool_named_paths: set[str]) -> bool:
|
|
215
|
+
"""Does `rel_path` (from git diff, relative to project root) refer
|
|
216
|
+
to the same file as any path in `tool_named_paths` (from tool_input,
|
|
217
|
+
typically absolute)?
|
|
218
|
+
|
|
219
|
+
Suffix match with a path-separator boundary so `dir/a.py` doesn't
|
|
220
|
+
spuriously match `otherdir/a.py`. Exact match also accepted for the
|
|
221
|
+
rare case where both sides happen to be relative.
|
|
222
|
+
"""
|
|
223
|
+
if not rel_path or not tool_named_paths:
|
|
224
|
+
return False
|
|
225
|
+
if rel_path in tool_named_paths:
|
|
226
|
+
return True
|
|
227
|
+
suffix = '/' + rel_path
|
|
228
|
+
for named in tool_named_paths:
|
|
229
|
+
if named.endswith(suffix):
|
|
230
|
+
return True
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _attribute_hunk(
|
|
235
|
+
hunk_index: int,
|
|
236
|
+
hunk_blame: dict,
|
|
237
|
+
path: str,
|
|
238
|
+
commit_to_event: dict[str, str],
|
|
239
|
+
path_to_events: dict[str, list[str]],
|
|
240
|
+
) -> dict:
|
|
241
|
+
"""Build the hunk_attributions entry for a single hunk."""
|
|
242
|
+
contributors_map: dict[str, int] = {}
|
|
243
|
+
unresolved = 0
|
|
244
|
+
for sha, count in (hunk_blame.get('commit_counts') or {}).items():
|
|
245
|
+
ev_id = commit_to_event.get(sha)
|
|
246
|
+
if ev_id:
|
|
247
|
+
contributors_map[ev_id] = contributors_map.get(ev_id, 0) + count
|
|
248
|
+
else:
|
|
249
|
+
# Lines blamed to a commit that doesn't map to any event in
|
|
250
|
+
# this node — typically lines that came from the parent
|
|
251
|
+
# branch's history (i.e. lines this node inherited and
|
|
252
|
+
# didn't touch). Skipped from contributors; counted for
|
|
253
|
+
# confidence calculation.
|
|
254
|
+
unresolved += count
|
|
255
|
+
|
|
256
|
+
contributors = [
|
|
257
|
+
{'event_id': ev_id, 'line_count': count}
|
|
258
|
+
for ev_id, count in sorted(
|
|
259
|
+
contributors_map.items(), key=lambda kv: kv[1], reverse=True,
|
|
260
|
+
)
|
|
261
|
+
]
|
|
262
|
+
|
|
263
|
+
added_count = hunk_blame.get('added_count', 0)
|
|
264
|
+
deleted_count = hunk_blame.get('deleted_count', 0)
|
|
265
|
+
|
|
266
|
+
# Method + confidence decision tree:
|
|
267
|
+
# - If we have any contributors AND most blamed lines resolved →
|
|
268
|
+
# blame, high (single contributor) or medium (multiple).
|
|
269
|
+
# - If we have contributors but lots of unresolved → blame, medium.
|
|
270
|
+
# - No added lines at all (pure deletion) → deletion_fallback,
|
|
271
|
+
# primary = last event to touch the path.
|
|
272
|
+
# - Added lines but no contributors mapped → file_fallback,
|
|
273
|
+
# primary = last event to touch the path. Low confidence; the
|
|
274
|
+
# blame went somewhere we couldn't resolve.
|
|
275
|
+
# - No fallback candidate either → unknown, null primary.
|
|
276
|
+
if contributors:
|
|
277
|
+
if unresolved == 0 and len(contributors) == 1:
|
|
278
|
+
method = 'blame'
|
|
279
|
+
confidence = 'high'
|
|
280
|
+
elif unresolved > added_count // 2 and added_count > 0:
|
|
281
|
+
method = 'blame'
|
|
282
|
+
confidence = 'medium'
|
|
283
|
+
else:
|
|
284
|
+
method = 'blame'
|
|
285
|
+
confidence = 'medium' if len(contributors) > 1 else 'high'
|
|
286
|
+
primary = contributors[0]['event_id']
|
|
287
|
+
elif added_count == 0 and deleted_count > 0:
|
|
288
|
+
last_ev = _last_event_for_path(path, path_to_events)
|
|
289
|
+
method = 'deletion_fallback'
|
|
290
|
+
confidence = 'low'
|
|
291
|
+
primary = last_ev
|
|
292
|
+
if last_ev:
|
|
293
|
+
contributors = [{'event_id': last_ev, 'line_count': deleted_count}]
|
|
294
|
+
else:
|
|
295
|
+
last_ev = _last_event_for_path(path, path_to_events)
|
|
296
|
+
if last_ev:
|
|
297
|
+
method = 'file_fallback'
|
|
298
|
+
confidence = 'low'
|
|
299
|
+
primary = last_ev
|
|
300
|
+
contributors = [{'event_id': last_ev, 'line_count': added_count + deleted_count}]
|
|
301
|
+
else:
|
|
302
|
+
method = 'unknown'
|
|
303
|
+
confidence = 'low'
|
|
304
|
+
primary = None
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
'hunk_index': hunk_index,
|
|
308
|
+
'new_start': hunk_blame.get('new_start', 0),
|
|
309
|
+
'new_lines': hunk_blame.get('new_lines', 0),
|
|
310
|
+
'old_start': hunk_blame.get('old_start', 0),
|
|
311
|
+
'old_lines': hunk_blame.get('old_lines', 0),
|
|
312
|
+
'primary_event_id': primary,
|
|
313
|
+
'method': method,
|
|
314
|
+
'confidence': confidence,
|
|
315
|
+
'contributors': contributors,
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def _last_event_for_path(path: str, path_to_events: dict[str, list[str]]) -> str | None:
|
|
320
|
+
"""Return the most recent event_id that touched `path`, or None.
|
|
321
|
+
|
|
322
|
+
Used by the deletion and file fallbacks when blame can't attribute
|
|
323
|
+
the hunk to a specific commit (deletion-only hunks, blame pointing
|
|
324
|
+
at an unmappable commit).
|
|
325
|
+
"""
|
|
326
|
+
ids = path_to_events.get(path)
|
|
327
|
+
if not ids:
|
|
328
|
+
return None
|
|
329
|
+
return ids[-1]
|
|
@@ -64,6 +64,32 @@ class BackendClient:
|
|
|
64
64
|
async def patch_graph(self, graph_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
65
65
|
return await self._patch(f"/api/graphs/{graph_id}/", data)
|
|
66
66
|
|
|
67
|
+
async def delete_graph(self, graph_id: str) -> None:
|
|
68
|
+
"""Delete a graph and all of its nodes/edges/events/marks via Django cascade.
|
|
69
|
+
|
|
70
|
+
Used by scripts/cleanup_playground.py, not by the running CLI.
|
|
71
|
+
DELETE responds 204 No Content, so we can't reuse the JSON-parsing
|
|
72
|
+
_request helper — call _client directly with the same auth+refresh
|
|
73
|
+
pattern in miniature.
|
|
74
|
+
"""
|
|
75
|
+
token = get_access_token()
|
|
76
|
+
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
77
|
+
url = f"{self.base_url}/api/graphs/{graph_id}/"
|
|
78
|
+
try:
|
|
79
|
+
resp = await self._client.request("DELETE", url, headers=headers)
|
|
80
|
+
except httpx.ConnectError as exc:
|
|
81
|
+
raise BackendError(0, f"Cannot reach backend at {self.base_url}") from exc
|
|
82
|
+
except httpx.TimeoutException as exc:
|
|
83
|
+
raise BackendError(0, "Request timed out.") from exc
|
|
84
|
+
if resp.status_code == 401:
|
|
85
|
+
try:
|
|
86
|
+
new_token = await self._refresh_access_token()
|
|
87
|
+
headers["Authorization"] = f"Bearer {new_token}"
|
|
88
|
+
resp = await self._client.request("DELETE", url, headers=headers)
|
|
89
|
+
except BackendError:
|
|
90
|
+
raise BackendError(401, f"Session expired. Please run `{APP_NAME} login` again.")
|
|
91
|
+
_raise_for_status(resp)
|
|
92
|
+
|
|
67
93
|
async def list_graphs_for_path(self, project_path: str) -> list[dict[str, Any]]:
|
|
68
94
|
result = await self._get(f"/api/graphs/?project_path={project_path}")
|
|
69
95
|
return result if isinstance(result, list) else result.get("results", [])
|
|
@@ -77,12 +103,21 @@ class BackendClient:
|
|
|
77
103
|
graph_id: str,
|
|
78
104
|
parent_id: str | None = None,
|
|
79
105
|
tool: str | None = None,
|
|
106
|
+
base_git_hash: str | None = None,
|
|
80
107
|
) -> dict[str, Any]:
|
|
108
|
+
"""Create a node. For root nodes, pass `base_git_hash` (the shadow
|
|
109
|
+
repo's HEAD after init) so the Files-changed tab can compute net
|
|
110
|
+
diff against the project's starting state instead of the empty
|
|
111
|
+
tree. Branch children get their base_git_hash from the backend's
|
|
112
|
+
branch endpoint and don't pass it here.
|
|
113
|
+
"""
|
|
81
114
|
payload: dict[str, Any] = {"graph": graph_id}
|
|
82
115
|
if parent_id:
|
|
83
116
|
payload["parent"] = parent_id
|
|
84
117
|
if tool:
|
|
85
118
|
payload["tool"] = tool
|
|
119
|
+
if base_git_hash:
|
|
120
|
+
payload["base_git_hash"] = base_git_hash
|
|
86
121
|
return await self._post("/api/nodes/", payload)
|
|
87
122
|
|
|
88
123
|
async def patch_node(self, node_id: str, data: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -91,8 +126,34 @@ class BackendClient:
|
|
|
91
126
|
async def checkout_node(self, node_id: str) -> dict[str, Any]:
|
|
92
127
|
return await self._post(f"/api/nodes/{node_id}/checkout/", {})
|
|
93
128
|
|
|
94
|
-
async def
|
|
95
|
-
|
|
129
|
+
async def get_node(self, node_id: str) -> dict[str, Any]:
|
|
130
|
+
"""Fetch the full NodeSerializer payload — kind, generation_count,
|
|
131
|
+
materialized_context, inherited_context_length, branch_reason,
|
|
132
|
+
outcome, etc. Used by the CLI's LLM pipeline because checkout_node
|
|
133
|
+
only returns the checkout response (a slim shape)."""
|
|
134
|
+
return await self._get(f"/api/nodes/{node_id}/")
|
|
135
|
+
|
|
136
|
+
async def branch_node(
|
|
137
|
+
self,
|
|
138
|
+
node_id: str,
|
|
139
|
+
branch_from_index: int,
|
|
140
|
+
edge_kind: str = "manual_branch",
|
|
141
|
+
branch_reason: str = "",
|
|
142
|
+
) -> dict[str, Any]:
|
|
143
|
+
"""Create a child node from `node_id` at the chosen branch point.
|
|
144
|
+
|
|
145
|
+
edge_kind and branch_reason are set atomically during creation so
|
|
146
|
+
the panel banner renders the right label even if a follow-up PATCH
|
|
147
|
+
would have failed. edge_kind is immutable after creation; this is
|
|
148
|
+
the only place a caller gets to set it.
|
|
149
|
+
"""
|
|
150
|
+
payload: dict[str, Any] = {
|
|
151
|
+
"branch_from_index": branch_from_index,
|
|
152
|
+
"edge_kind": edge_kind,
|
|
153
|
+
}
|
|
154
|
+
if branch_reason:
|
|
155
|
+
payload["branch_reason"] = branch_reason[:500]
|
|
156
|
+
return await self._post(f"/api/nodes/{node_id}/branch/", payload)
|
|
96
157
|
|
|
97
158
|
async def list_nodes(self, graph_id: str, limit: int = 10, offset: int = 0) -> dict[str, Any]:
|
|
98
159
|
result = await self._get(f"/api/nodes/?graph={graph_id}&limit={limit}&offset={offset}")
|
|
@@ -109,6 +170,47 @@ class BackendClient:
|
|
|
109
170
|
async def move_event(self, event_id: str, target_node_id: str) -> dict[str, Any]:
|
|
110
171
|
return await self._post(f"/api/nodes/events/{event_id}/move/", {"target_node_id": target_node_id})
|
|
111
172
|
|
|
173
|
+
async def list_events(self, node_id: str) -> Any:
|
|
174
|
+
"""Fetch the durable event list for a node. Used by the LLM
|
|
175
|
+
review-artifact pipeline to read the transcript that produced
|
|
176
|
+
the current node state."""
|
|
177
|
+
return await self._get(f"/api/nodes/{node_id}/events/")
|
|
178
|
+
|
|
179
|
+
async def generate_now(self, node_id: str) -> tuple[int, dict[str, Any] | None]:
|
|
180
|
+
"""Ask the backend to generate the review artifact for `node_id`.
|
|
181
|
+
|
|
182
|
+
Returns (status_code, body). Body is None for 202/204. Callers
|
|
183
|
+
in the CLI fire this as a fire-and-forget background task on
|
|
184
|
+
lifecycle transitions (/branch, /checkpoint, /exit). Backend
|
|
185
|
+
owns the lock, the LLM call, validation, persistence, and the
|
|
186
|
+
node_updated broadcast — the CLI is just a requester.
|
|
187
|
+
|
|
188
|
+
Status semantics (matches endpoint):
|
|
189
|
+
202 — accepted; generation running in a daemon thread on the
|
|
190
|
+
backend. Listen for `node_updated` over Channels to
|
|
191
|
+
know when the artifact lands. This is the happy path.
|
|
192
|
+
204 — skipped (empty node, no new events since last gen, or
|
|
193
|
+
kind != work).
|
|
194
|
+
409 — locked elsewhere, or cap reached. Body has `detail`.
|
|
195
|
+
"""
|
|
196
|
+
token = get_access_token()
|
|
197
|
+
headers = {"Authorization": f"Bearer {token}"} if token else {}
|
|
198
|
+
url = f"{self.base_url}/api/nodes/{node_id}/generate_now/"
|
|
199
|
+
# Backend returns 202 sub-second (it spawns the LLM call in a
|
|
200
|
+
# daemon thread), so the class-default 15s timeout is plenty.
|
|
201
|
+
resp = await self._client.post(url, headers=headers, json={})
|
|
202
|
+
if resp.status_code == 401:
|
|
203
|
+
new_token = await self._refresh_access_token()
|
|
204
|
+
headers["Authorization"] = f"Bearer {new_token}"
|
|
205
|
+
resp = await self._client.post(url, headers=headers, json={})
|
|
206
|
+
body: dict[str, Any] | None = None
|
|
207
|
+
if resp.status_code not in (202, 204):
|
|
208
|
+
try:
|
|
209
|
+
body = resp.json()
|
|
210
|
+
except Exception:
|
|
211
|
+
body = None
|
|
212
|
+
return resp.status_code, body
|
|
213
|
+
|
|
112
214
|
# ------------------------------------------------------------------
|
|
113
215
|
# Internal helpers
|
|
114
216
|
# ------------------------------------------------------------------
|
|
@@ -20,8 +20,9 @@ import logging
|
|
|
20
20
|
import uuid
|
|
21
21
|
from datetime import datetime, timezone
|
|
22
22
|
from pathlib import Path
|
|
23
|
-
from typing import TYPE_CHECKING, Any
|
|
23
|
+
from typing import TYPE_CHECKING, Any, Callable, Optional
|
|
24
24
|
|
|
25
|
+
from .backend import BackendError
|
|
25
26
|
from .constants import APP_DIR
|
|
26
27
|
|
|
27
28
|
if TYPE_CHECKING:
|
|
@@ -58,10 +59,22 @@ class EventCapture:
|
|
|
58
59
|
graph_id: str,
|
|
59
60
|
node_id: str,
|
|
60
61
|
snapshot_manager: "SnapshotManager | None" = None,
|
|
62
|
+
on_node_locked: Optional[Callable[[str], None]] = None,
|
|
61
63
|
) -> None:
|
|
62
64
|
self._backend = backend
|
|
63
65
|
self._graph_id = graph_id
|
|
64
66
|
self._node_id = node_id
|
|
67
|
+
# Invoked when the backend rejects an event with 409 because
|
|
68
|
+
# the target node is branched. Receives the node_id that was
|
|
69
|
+
# rejected. The session loop uses this to mark the node as
|
|
70
|
+
# branched in CLI state so the next user input triggers the
|
|
71
|
+
# auto-checkpoint guard. Set on construction so the loop and
|
|
72
|
+
# capture don't need a shared mutable state object.
|
|
73
|
+
self._on_node_locked = on_node_locked
|
|
74
|
+
# Guard so the callback fires at most once per node_id (an
|
|
75
|
+
# in-flight stream may attempt several event posts after the
|
|
76
|
+
# node flips to branched; we only need to recover once).
|
|
77
|
+
self._locked_node_notified: set[str] = set()
|
|
65
78
|
# Optional — when present, every side-effecting tool call gets a
|
|
66
79
|
# filesystem snapshot committed to a shadow git repo and the resulting
|
|
67
80
|
# commit hash + diff summary attached to the post_tool_use event.
|
|
@@ -92,6 +105,15 @@ class EventCapture:
|
|
|
92
105
|
def node_id(self) -> str:
|
|
93
106
|
return self._node_id
|
|
94
107
|
|
|
108
|
+
def set_on_node_locked(self, callback: Optional[Callable[[str], None]]) -> None:
|
|
109
|
+
"""Set/replace the branched-node-rejection callback.
|
|
110
|
+
|
|
111
|
+
Lets the session loop wire the callback after EventCapture is
|
|
112
|
+
constructed (the session's state dict lives lower in the
|
|
113
|
+
function and can't be closed over at construction time).
|
|
114
|
+
"""
|
|
115
|
+
self._on_node_locked = callback
|
|
116
|
+
|
|
95
117
|
def register(self, driver: "BaseDriver") -> None:
|
|
96
118
|
"""Wire pre/post tool hooks onto the driver."""
|
|
97
119
|
driver.register_pre_tool_hook(self._on_pre_tool)
|
|
@@ -384,9 +406,39 @@ class EventCapture:
|
|
|
384
406
|
self._log_file.flush()
|
|
385
407
|
|
|
386
408
|
# 2. Send to backend — best effort, never crash the session on failure
|
|
409
|
+
target_node_id = self._node_id
|
|
387
410
|
try:
|
|
388
|
-
resp = await self._backend.append_delta_event(
|
|
411
|
+
resp = await self._backend.append_delta_event(target_node_id, event)
|
|
389
412
|
return resp.get("id") if isinstance(resp, dict) else None
|
|
413
|
+
except BackendError as exc:
|
|
414
|
+
# 409 from append_event means the backend now considers this
|
|
415
|
+
# node branched/frozen. The session-loop guard at
|
|
416
|
+
# session.py:876 is supposed to prevent us ever reaching
|
|
417
|
+
# here — if we do, it's a CLI bug, race, or a third-party
|
|
418
|
+
# actor branched the node while a stream was mid-flight.
|
|
419
|
+
#
|
|
420
|
+
# Surface it loudly and trigger session-level recovery via
|
|
421
|
+
# the on_node_locked callback so the next user input runs
|
|
422
|
+
# the auto-checkpoint guard. The 409'd event itself is
|
|
423
|
+
# lost (still in the local JSONL log), which is acceptable
|
|
424
|
+
# for a defense-in-depth path.
|
|
425
|
+
if exc.status_code == 409:
|
|
426
|
+
if target_node_id not in self._locked_node_notified:
|
|
427
|
+
self._locked_node_notified.add(target_node_id)
|
|
428
|
+
logger.error(
|
|
429
|
+
"Backend refused event on branched node %s — durable event "
|
|
430
|
+
"log will be out of sync until the next user input triggers "
|
|
431
|
+
"the auto-checkpoint guard. Failed event: %s",
|
|
432
|
+
target_node_id, event.get("event_type"),
|
|
433
|
+
)
|
|
434
|
+
if self._on_node_locked is not None:
|
|
435
|
+
try:
|
|
436
|
+
self._on_node_locked(target_node_id)
|
|
437
|
+
except Exception:
|
|
438
|
+
logger.exception("on_node_locked callback raised")
|
|
439
|
+
return None
|
|
440
|
+
logger.warning("Failed to send event to backend: %s", exc)
|
|
441
|
+
return None
|
|
390
442
|
except Exception as exc:
|
|
391
443
|
logger.warning("Failed to send event to backend: %s", exc)
|
|
392
444
|
return None
|
|
@@ -645,17 +645,19 @@ class ACPDriver(BaseDriver):
|
|
|
645
645
|
self._resume_session_id = cc_session_id
|
|
646
646
|
else:
|
|
647
647
|
# Fresh branch — copy+truncate the parent's session files.
|
|
648
|
-
#
|
|
649
|
-
#
|
|
650
|
-
#
|
|
651
|
-
#
|
|
652
|
-
#
|
|
653
|
-
|
|
648
|
+
# Pass the count of REAL USER PROMPTS in the inherited slice.
|
|
649
|
+
# Each user prompt maps 1:1 to one user turn in the session
|
|
650
|
+
# file. Previously we passed user+assistant count and let
|
|
651
|
+
# fork_kiro_session compute `(count+1)//2 = user_turns`, but
|
|
652
|
+
# that under-counted whenever assistant entries outnumbered
|
|
653
|
+
# user entries (multi-block responses), and the fork included
|
|
654
|
+
# content from AFTER the branch point. See task #94.
|
|
655
|
+
user_turn_count = sum(
|
|
654
656
|
1 for m in (messages or [])
|
|
655
|
-
if isinstance(m, dict) and m.get('role')
|
|
657
|
+
if isinstance(m, dict) and m.get('role') == 'user'
|
|
656
658
|
)
|
|
657
659
|
new_session_id = fork_kiro_session(
|
|
658
|
-
cc_session_id,
|
|
660
|
+
cc_session_id, user_turn_count, effective_cwd
|
|
659
661
|
)
|
|
660
662
|
if new_session_id:
|
|
661
663
|
self._resume_session_id = new_session_id
|
|
@@ -194,18 +194,21 @@ class ClaudeDriver(BaseDriver):
|
|
|
194
194
|
self._resume_session_id = cc_session_id
|
|
195
195
|
else:
|
|
196
196
|
# Fresh branch — copy+truncate the parent's session file.
|
|
197
|
-
#
|
|
198
|
-
#
|
|
199
|
-
#
|
|
200
|
-
#
|
|
201
|
-
#
|
|
202
|
-
|
|
203
|
-
|
|
197
|
+
# Pass the count of REAL USER PROMPTS in the inherited slice.
|
|
198
|
+
# Each user prompt in materialized_context maps 1:1 to one
|
|
199
|
+
# user turn in the session file (the prompt + all assistant
|
|
200
|
+
# follow-up belongs to that turn). Counting user+assistant
|
|
201
|
+
# used to be the input, but assistant entries can outnumber
|
|
202
|
+
# user entries when responses include thinking/tool_use
|
|
203
|
+
# blocks — the resulting fork over-included content from
|
|
204
|
+
# AFTER the branch point. See task #94.
|
|
205
|
+
from ..session_files import fork_at_user_turns
|
|
206
|
+
user_turn_count = sum(
|
|
204
207
|
1 for m in (messages or [])
|
|
205
|
-
if isinstance(m, dict) and m.get('role')
|
|
208
|
+
if isinstance(m, dict) and m.get('role') == 'user'
|
|
206
209
|
)
|
|
207
|
-
new_session_id =
|
|
208
|
-
cc_session_id, effective_cwd,
|
|
210
|
+
new_session_id = fork_at_user_turns(
|
|
211
|
+
cc_session_id, effective_cwd, user_turn_count
|
|
209
212
|
)
|
|
210
213
|
if new_session_id:
|
|
211
214
|
self._resume_session_id = new_session_id
|