expluro 0.2.0__tar.gz → 0.3.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.2.0 → expluro-0.3.0}/PKG-INFO +1 -1
- {expluro-0.2.0 → expluro-0.3.0}/expluro.egg-info/PKG-INFO +1 -1
- {expluro-0.2.0 → expluro-0.3.0}/pyproject.toml +1 -1
- {expluro-0.2.0 → expluro-0.3.0}/topo/capture.py +40 -5
- {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/_branch_detection.py +12 -3
- {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/_permissions.py +25 -4
- {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/acp.py +125 -44
- {expluro-0.2.0 → expluro-0.3.0}/topo/picker.py +24 -5
- {expluro-0.2.0 → expluro-0.3.0}/topo/renderer.py +32 -2
- {expluro-0.2.0 → expluro-0.3.0}/topo/session.py +109 -1
- expluro-0.3.0/topo/snapshot.py +482 -0
- expluro-0.2.0/topo/snapshot.py +0 -3
- {expluro-0.2.0 → expluro-0.3.0}/expluro.egg-info/SOURCES.txt +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/expluro.egg-info/dependency_links.txt +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/expluro.egg-info/entry_points.txt +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/expluro.egg-info/requires.txt +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/expluro.egg-info/top_level.txt +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/setup.cfg +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/topo/__init__.py +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/topo/auth.py +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/topo/backend.py +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/topo/constants.py +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/topo/display.py +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/__init__.py +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/base.py +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/claude.py +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/topo/drivers/kiro_session_files.py +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/topo/main.py +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/topo/session_files.py +0 -0
- {expluro-0.2.0 → expluro-0.3.0}/topo/update_check.py +0 -0
|
@@ -27,6 +27,7 @@ from .constants import APP_DIR
|
|
|
27
27
|
if TYPE_CHECKING:
|
|
28
28
|
from .backend import BackendClient
|
|
29
29
|
from .drivers.base import BaseDriver, DriverEvent, ToolHookPayload
|
|
30
|
+
from .snapshot import SnapshotManager
|
|
30
31
|
|
|
31
32
|
logger = logging.getLogger(__name__)
|
|
32
33
|
|
|
@@ -56,10 +57,16 @@ class EventCapture:
|
|
|
56
57
|
backend: "BackendClient",
|
|
57
58
|
graph_id: str,
|
|
58
59
|
node_id: str,
|
|
60
|
+
snapshot_manager: "SnapshotManager | None" = None,
|
|
59
61
|
) -> None:
|
|
60
62
|
self._backend = backend
|
|
61
63
|
self._graph_id = graph_id
|
|
62
64
|
self._node_id = node_id
|
|
65
|
+
# Optional — when present, every side-effecting tool call gets a
|
|
66
|
+
# filesystem snapshot committed to a shadow git repo and the resulting
|
|
67
|
+
# commit hash + diff summary attached to the post_tool_use event.
|
|
68
|
+
# Powers `/branch` and `/checkout` filesystem rewind.
|
|
69
|
+
self._snapshot_manager: "SnapshotManager | None" = snapshot_manager
|
|
63
70
|
self._last_event_id: str | None = None
|
|
64
71
|
# Accumulated streaming text for the current turn.
|
|
65
72
|
# Flushed as message_ai on turn_result if text_complete never fires
|
|
@@ -297,16 +304,44 @@ class EventCapture:
|
|
|
297
304
|
|
|
298
305
|
async def _on_post_tool(self, payload: "ToolHookPayload") -> None:
|
|
299
306
|
is_side_effecting = payload.tool_name in SIDE_EFFECTING_TOOLS
|
|
307
|
+
|
|
308
|
+
# Snapshot the filesystem after a side-effecting tool. Even tools we
|
|
309
|
+
# think are read-only sometimes touch state (e.g. Bash that runs a
|
|
310
|
+
# build, Grep with --rewrite), so we let the SnapshotManager dedupe:
|
|
311
|
+
# if nothing changed, the commit is a no-op and returns the existing
|
|
312
|
+
# HEAD hash. Diff summary is attached to the event for browser display.
|
|
313
|
+
snapshot_hash: str | None = None
|
|
314
|
+
diff_summary: dict | None = None
|
|
315
|
+
if is_side_effecting and self._snapshot_manager is not None:
|
|
316
|
+
try:
|
|
317
|
+
snapshot_hash, diff_summary = await self._snapshot_manager.commit(
|
|
318
|
+
label=f"{payload.tool_name} {payload.tool_use_id[:8]}",
|
|
319
|
+
)
|
|
320
|
+
except Exception:
|
|
321
|
+
logger.exception("snapshot commit failed for %s", payload.tool_name)
|
|
322
|
+
|
|
323
|
+
event_data: dict[str, Any] = {
|
|
324
|
+
"tool_output": _truncate(payload.tool_output),
|
|
325
|
+
"is_error": payload.is_error,
|
|
326
|
+
"is_side_effecting": is_side_effecting,
|
|
327
|
+
}
|
|
328
|
+
if snapshot_hash:
|
|
329
|
+
event_data["snapshot_hash"] = snapshot_hash
|
|
330
|
+
if diff_summary:
|
|
331
|
+
event_data["diff_summary"] = diff_summary
|
|
332
|
+
|
|
300
333
|
event = self._build_event(
|
|
301
334
|
event_type="post_tool_use",
|
|
302
335
|
tool_name=payload.tool_name,
|
|
303
336
|
tool_use_id=payload.tool_use_id,
|
|
304
|
-
data=
|
|
305
|
-
"tool_output": _truncate(payload.tool_output),
|
|
306
|
-
"is_error": payload.is_error,
|
|
307
|
-
"is_side_effecting": is_side_effecting,
|
|
308
|
-
},
|
|
337
|
+
data=event_data,
|
|
309
338
|
)
|
|
339
|
+
# The Event model has a snapshot_hash field — the backend pulls it
|
|
340
|
+
# from the top level of the request payload, not from `data`. Set it
|
|
341
|
+
# here so it lands in the indexed column, not just the JSON blob.
|
|
342
|
+
if snapshot_hash:
|
|
343
|
+
event["snapshot_hash"] = snapshot_hash
|
|
344
|
+
|
|
310
345
|
if self._pending_user_message_text is not None:
|
|
311
346
|
self._buffered_hook_events.append(event)
|
|
312
347
|
return
|
|
@@ -83,8 +83,17 @@ def parse_branch_flag(buffered_head: str) -> tuple[float | None, str]:
|
|
|
83
83
|
Returns (confidence, remaining_text). When the head doesn't start with
|
|
84
84
|
a valid tag, confidence is None and the entire buffered_head is
|
|
85
85
|
returned as remaining_text — caller should treat it as regular content.
|
|
86
|
+
|
|
87
|
+
If the tag matches structurally but the digits are malformed (e.g.
|
|
88
|
+
`<!--BRANCH:.-->`), we still consume the tag bytes from the remaining
|
|
89
|
+
text but return None for confidence. That way a weird model emission
|
|
90
|
+
can't leak the tag to the user OR crash the dispatcher.
|
|
86
91
|
"""
|
|
87
92
|
m = BRANCH_FLAG_START_RE.match(buffered_head)
|
|
88
|
-
if m:
|
|
89
|
-
return
|
|
90
|
-
|
|
93
|
+
if not m:
|
|
94
|
+
return None, buffered_head
|
|
95
|
+
try:
|
|
96
|
+
confidence = float(m.group(1))
|
|
97
|
+
except ValueError:
|
|
98
|
+
return None, buffered_head[m.end():]
|
|
99
|
+
return confidence, buffered_head[m.end():]
|
|
@@ -18,6 +18,7 @@ from __future__ import annotations
|
|
|
18
18
|
import logging
|
|
19
19
|
from dataclasses import dataclass, field
|
|
20
20
|
from enum import Enum
|
|
21
|
+
from html import escape as _html_escape
|
|
21
22
|
from typing import Any
|
|
22
23
|
|
|
23
24
|
logger = logging.getLogger(__name__)
|
|
@@ -72,24 +73,44 @@ async def ask_user_for_permission(
|
|
|
72
73
|
"""
|
|
73
74
|
# Late imports to avoid cycles with picker / renderer at module-load time.
|
|
74
75
|
from ..picker import pick_option
|
|
75
|
-
from ..renderer import
|
|
76
|
+
from ..renderer import (
|
|
77
|
+
pause_active_md_stream,
|
|
78
|
+
pause_active_spinner,
|
|
79
|
+
resume_active_spinner,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
# prompt_toolkit's HTML(...) is strict XML; any literal `&`, `<`, `>`
|
|
83
|
+
# or quote in agent-provided text (Kiro tool titles like
|
|
84
|
+
# "Run command: cat /etc/hosts 2>&1" — note the bare `&`) crashes the
|
|
85
|
+
# picker with an ExpatError, the wrapper deny-fails closed, and the
|
|
86
|
+
# user sees no prompt at all. Escape user/agent-provided substrings
|
|
87
|
+
# before embedding them inside our HTML-formatted picker text.
|
|
88
|
+
safe_tool_name = _html_escape(tool_name)
|
|
89
|
+
safe_summary = _html_escape(summary) if summary else ""
|
|
76
90
|
|
|
77
91
|
summary_line = (
|
|
78
|
-
f"\n <ansibrightblack> {
|
|
92
|
+
f"\n <ansibrightblack> {safe_summary}</ansibrightblack>" if safe_summary else ""
|
|
79
93
|
)
|
|
80
94
|
header = (
|
|
81
|
-
f" <ansicyan>⚙ {
|
|
95
|
+
f" <ansicyan>⚙ {safe_tool_name}</ansicyan> "
|
|
82
96
|
f"<ansibrightblack>wants to run:</ansibrightblack>{summary_line}"
|
|
83
97
|
)
|
|
84
98
|
|
|
85
99
|
options: list[tuple[str, str]] = [
|
|
86
100
|
("Allow", "allow"),
|
|
87
|
-
(f"Always allow {
|
|
101
|
+
(f"Always allow {safe_tool_name}", "always"),
|
|
88
102
|
("Deny", "deny"),
|
|
89
103
|
("Bypass all checks", "bypass"),
|
|
90
104
|
]
|
|
91
105
|
|
|
106
|
+
# Hand the terminal off to prompt_toolkit cleanly: stop the spinner and
|
|
107
|
+
# flush+stop any active rich.Live markdown stream. If we skip the
|
|
108
|
+
# md-stream flush, an in-progress streaming response will keep
|
|
109
|
+
# repainting the cursor area on every refresh tick and the picker is
|
|
110
|
+
# invisible — the user thinks the agent went silent, but the agent is
|
|
111
|
+
# actually waiting on an approval that never gets a chance to render.
|
|
92
112
|
pause_active_spinner()
|
|
113
|
+
pause_active_md_stream()
|
|
93
114
|
try:
|
|
94
115
|
result = await pick_option(header, options)
|
|
95
116
|
except KeyboardInterrupt:
|
|
@@ -45,6 +45,7 @@ from acp.schema import AllowedOutcome, DeniedOutcome
|
|
|
45
45
|
|
|
46
46
|
from ._branch_detection import (
|
|
47
47
|
BRANCH_DETECTION_INSTRUCTION,
|
|
48
|
+
BRANCH_FLAG_START_RE,
|
|
48
49
|
HEAD_BUFFER_MAX,
|
|
49
50
|
parse_branch_flag,
|
|
50
51
|
)
|
|
@@ -103,15 +104,19 @@ class ACPDriver(BaseDriver):
|
|
|
103
104
|
# Permission tracking. always_allowed survives across branches;
|
|
104
105
|
# bypass_all is reset on branch switch (see reset_context).
|
|
105
106
|
self._permission_state: PermissionState = PermissionState()
|
|
106
|
-
# Auto-branch head buffering. Per-
|
|
107
|
-
#
|
|
108
|
-
# HEAD_BUFFER_MAX, then parse for the BRANCH:X.XX tag
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
#
|
|
112
|
-
#
|
|
107
|
+
# Auto-branch head buffering. Per-segment state: accumulate the
|
|
108
|
+
# start of each streamed text segment until we either see a newline
|
|
109
|
+
# or hit HEAD_BUFFER_MAX, then parse for the BRANCH:X.XX tag.
|
|
110
|
+
# A "segment" begins at turn start AND after every tool call, because
|
|
111
|
+
# Kiro re-emits the tag at the head of post-tool text. Resetting the
|
|
112
|
+
# head buffer per segment is what catches chunked tags
|
|
113
|
+
# (`<!--BRANCH:0.10` + `-->...`) that a single regex sub on one chunk
|
|
114
|
+
# would miss.
|
|
115
|
+
# branch_signal only fires once per turn even if Kiro repeats the
|
|
116
|
+
# tag — we use the very first parse as authoritative.
|
|
113
117
|
self._head_buf: str = ""
|
|
114
118
|
self._head_parsed: bool = False
|
|
119
|
+
self._branch_signal_fired_this_turn: bool = False
|
|
115
120
|
|
|
116
121
|
# ------------------------------------------------------------------
|
|
117
122
|
# Lifecycle
|
|
@@ -371,10 +376,10 @@ class ACPDriver(BaseDriver):
|
|
|
371
376
|
except asyncio.QueueEmpty:
|
|
372
377
|
break
|
|
373
378
|
|
|
374
|
-
# Reset auto-branch head state at the start of every turn
|
|
375
|
-
# response we're about to receive should begin with the BRANCH tag.
|
|
379
|
+
# Reset auto-branch head state at the start of every turn.
|
|
376
380
|
self._head_buf = ""
|
|
377
381
|
self._head_parsed = False
|
|
382
|
+
self._branch_signal_fired_this_turn = False
|
|
378
383
|
|
|
379
384
|
# Fire-and-forget the prompt as a background task. The connection's
|
|
380
385
|
# `prompt()` returns a PromptResponse when the agent finishes. We
|
|
@@ -400,6 +405,12 @@ class ACPDriver(BaseDriver):
|
|
|
400
405
|
session_id=self._session_id,
|
|
401
406
|
prompt=[text_block(framed_prompt)],
|
|
402
407
|
)
|
|
408
|
+
# Flush any text still pending in the head buffer. Short post-
|
|
409
|
+
# tool responses (e.g. "Your username is yab", 21 chars, no
|
|
410
|
+
# newline) never trip the size/newline thresholds during
|
|
411
|
+
# streaming and would otherwise be lost when the turn ends.
|
|
412
|
+
assert self._session_id is not None
|
|
413
|
+
await self._flush_head_buffer(self._session_id)
|
|
403
414
|
# Emit a result event capturing usage if present, then end-of-turn.
|
|
404
415
|
data: dict[str, Any] = {}
|
|
405
416
|
if response is not None:
|
|
@@ -435,12 +446,11 @@ class ACPDriver(BaseDriver):
|
|
|
435
446
|
"""
|
|
436
447
|
Text-streaming entry point with auto-branch head buffering.
|
|
437
448
|
|
|
438
|
-
Buffers the
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
without a tag), text streams through normally.
|
|
449
|
+
Buffers the start of each text segment (turn-start, or post-tool
|
|
450
|
+
re-entry) until we see a newline or hit HEAD_BUFFER_MAX, then
|
|
451
|
+
delegates to _flush_head_buffer for tag parsing + emission. That
|
|
452
|
+
helper centralises the multi-tag, partial-tag, and malformed-tag
|
|
453
|
+
defenses so this handler stays a thin entry point.
|
|
444
454
|
"""
|
|
445
455
|
if self._head_parsed:
|
|
446
456
|
await self._emit(
|
|
@@ -454,45 +464,89 @@ class ACPDriver(BaseDriver):
|
|
|
454
464
|
return
|
|
455
465
|
|
|
456
466
|
self._head_parsed = True
|
|
457
|
-
|
|
467
|
+
await self._flush_head_buffer(session_id)
|
|
468
|
+
|
|
469
|
+
async def _flush_head_buffer(self, session_id: str) -> None:
|
|
470
|
+
"""
|
|
471
|
+
Drain the current head buffer (if any), emitting at most one
|
|
472
|
+
branch_signal event (only on the first parsed tag of the turn) and
|
|
473
|
+
a text event for whatever non-tag content remains.
|
|
474
|
+
|
|
475
|
+
Called from two sites:
|
|
476
|
+
1. _resolve_head_for_non_text — when a tool call arrives.
|
|
477
|
+
2. End of _run_prompt — when the turn finishes, to ensure any
|
|
478
|
+
short post-tool text response (e.g. "Your username is yab"
|
|
479
|
+
with no newline) doesn't get stuck in the buffer and lost.
|
|
480
|
+
|
|
481
|
+
Defensive against three model misbehaviours:
|
|
482
|
+
- Multiple stacked tags (`<!--BRANCH:0.0--><!--BRANCH:0.5-->Hi`):
|
|
483
|
+
strip ALL leading tags, not just the first, so none leak.
|
|
484
|
+
- Partial tag at flush time (`<!--BRANCH:0.5` with no `-->`):
|
|
485
|
+
happens if a tool call interrupts mid-tag-stream. Drop the
|
|
486
|
+
partial-tag bytes rather than emitting them as visible text.
|
|
487
|
+
- Malformed digits (`<!--BRANCH:.-->`): handled inside
|
|
488
|
+
parse_branch_flag — the tag is consumed, no confidence
|
|
489
|
+
recorded.
|
|
490
|
+
|
|
491
|
+
Caller is responsible for resetting `_head_parsed` after this if
|
|
492
|
+
appropriate. We deliberately do not reset state here so the
|
|
493
|
+
function is reusable.
|
|
494
|
+
"""
|
|
495
|
+
if not self._head_buf:
|
|
496
|
+
return
|
|
497
|
+
buf = self._head_buf
|
|
458
498
|
self._head_buf = ""
|
|
459
|
-
|
|
460
|
-
|
|
499
|
+
|
|
500
|
+
first_confidence: float | None = None
|
|
501
|
+
# Strip every leading tag. Defensive against double-emission.
|
|
502
|
+
while True:
|
|
503
|
+
confidence, rest = parse_branch_flag(buf)
|
|
504
|
+
if confidence is None and rest == buf:
|
|
505
|
+
# No tag matched at all — buf is plain content.
|
|
506
|
+
break
|
|
507
|
+
# A tag was consumed (confidence may still be None if the
|
|
508
|
+
# tag's digits were malformed). Capture the first valid
|
|
509
|
+
# confidence we see.
|
|
510
|
+
if first_confidence is None and confidence is not None:
|
|
511
|
+
first_confidence = confidence
|
|
512
|
+
buf = rest
|
|
513
|
+
if not buf:
|
|
514
|
+
break
|
|
515
|
+
|
|
516
|
+
# If the buffer is now a partial tag prefix (no closing `-->`),
|
|
517
|
+
# the model started a tag we never saw the end of. Drop it
|
|
518
|
+
# rather than leak `<!--BRANCH:0.5` to the renderer.
|
|
519
|
+
if buf.startswith("<!--BRANCH:") and "-->" not in buf:
|
|
520
|
+
buf = ""
|
|
521
|
+
|
|
522
|
+
if first_confidence is not None and not self._branch_signal_fired_this_turn:
|
|
523
|
+
self._branch_signal_fired_this_turn = True
|
|
461
524
|
await self._emit(
|
|
462
525
|
DriverEvent(
|
|
463
526
|
type="branch_signal",
|
|
464
|
-
data={"confidence":
|
|
465
|
-
raw=
|
|
527
|
+
data={"confidence": first_confidence, "session_id": session_id},
|
|
528
|
+
raw=None,
|
|
466
529
|
)
|
|
467
530
|
)
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
else:
|
|
473
|
-
# No tag — flush whatever buffered as regular text so nothing is
|
|
474
|
-
# silently dropped even if the model ignored the instruction.
|
|
475
|
-
if remaining:
|
|
476
|
-
await self._emit(
|
|
477
|
-
DriverEvent(type="text", data={"text": remaining, "session_id": session_id}, raw=raw)
|
|
478
|
-
)
|
|
531
|
+
if buf:
|
|
532
|
+
await self._emit(
|
|
533
|
+
DriverEvent(type="text", data={"text": buf, "session_id": session_id}, raw=None)
|
|
534
|
+
)
|
|
479
535
|
|
|
480
536
|
async def _resolve_head_for_non_text(self, session_id: str) -> None:
|
|
481
537
|
"""
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
538
|
+
Called when a non-text event (tool call) arrives. Flushes any
|
|
539
|
+
buffered head text and resets so the next text segment re-enters
|
|
540
|
+
tag parsing.
|
|
541
|
+
|
|
542
|
+
That re-entry is what catches Kiro's habit of repeating the
|
|
543
|
+
BRANCH tag at the head of each post-tool text segment, including
|
|
544
|
+
when the tag is split across multiple stream chunks (a single
|
|
545
|
+
regex sub on one chunk would miss the second half).
|
|
486
546
|
"""
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
self._head_parsed =
|
|
490
|
-
if self._head_buf:
|
|
491
|
-
buffered = self._head_buf
|
|
492
|
-
self._head_buf = ""
|
|
493
|
-
await self._emit(
|
|
494
|
-
DriverEvent(type="text", data={"text": buffered, "session_id": session_id}, raw=None)
|
|
495
|
-
)
|
|
547
|
+
await self._flush_head_buffer(session_id)
|
|
548
|
+
# Reset for the next text segment so it re-enters head parsing.
|
|
549
|
+
self._head_parsed = False
|
|
496
550
|
|
|
497
551
|
async def _fire_pre_tool_hook(self, payload: ToolHookPayload) -> None:
|
|
498
552
|
for cb in self._pre_tool_hooks:
|
|
@@ -666,6 +720,33 @@ class _ACPClient(Client):
|
|
|
666
720
|
await self._driver._emit(
|
|
667
721
|
DriverEvent(type="tool_use", data=event_data, raw=update)
|
|
668
722
|
)
|
|
723
|
+
# Fast tools (Kiro's `read` for small files, for example) emit a
|
|
724
|
+
# single ToolCallStart with status already "completed" or
|
|
725
|
+
# "failed" and never send a follow-up ToolCallProgress. If we
|
|
726
|
+
# only emit tool_use the browser shows the call as "running…"
|
|
727
|
+
# forever. Emit tool_result inline when the start update is
|
|
728
|
+
# already terminal.
|
|
729
|
+
start_status = getattr(update, "status", None)
|
|
730
|
+
if start_status is not None and str(start_status).lower() in ("completed", "failed"):
|
|
731
|
+
tool_output = _extract_tool_output(update)
|
|
732
|
+
is_error = str(start_status).lower() == "failed"
|
|
733
|
+
payload_out = self._driver._active_tool_calls.pop(tool_use_id, None)
|
|
734
|
+
if payload_out is not None:
|
|
735
|
+
payload_out.tool_output = tool_output
|
|
736
|
+
payload_out.is_error = is_error
|
|
737
|
+
await self._driver._fire_post_tool_hook(payload_out)
|
|
738
|
+
await self._driver._emit(
|
|
739
|
+
DriverEvent(
|
|
740
|
+
type="tool_result",
|
|
741
|
+
data={
|
|
742
|
+
"tool_use_id": tool_use_id,
|
|
743
|
+
"tool_output": tool_output,
|
|
744
|
+
"is_error": is_error,
|
|
745
|
+
"session_id": session_id,
|
|
746
|
+
},
|
|
747
|
+
raw=update,
|
|
748
|
+
)
|
|
749
|
+
)
|
|
669
750
|
return
|
|
670
751
|
|
|
671
752
|
if isinstance(update, ToolCallProgress):
|
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
"""
|
|
2
2
|
picker.py — Arrow-key pickers for session selection and permission prompts.
|
|
3
|
+
|
|
4
|
+
prompt_toolkit's HTML(...) is strict XML, so any literal `&`, `<`, `>`, or
|
|
5
|
+
quote in user/agent-provided text (graph names, node labels, message bodies,
|
|
6
|
+
tool titles) crashes the picker with an ExpatError. We escape every such
|
|
7
|
+
substring before embedding it into the HTML format strings below. Static
|
|
8
|
+
markup (tags like <ansicyan>...</ansicyan>) is hand-written and stays raw.
|
|
3
9
|
"""
|
|
4
10
|
from __future__ import annotations
|
|
5
11
|
|
|
6
12
|
import datetime
|
|
13
|
+
from html import escape as _html_escape
|
|
7
14
|
from typing import Any
|
|
8
15
|
|
|
9
16
|
from prompt_toolkit import Application
|
|
@@ -87,13 +94,16 @@ async def pick_node(nodes: list[dict[str, Any]], has_more: bool, current_node_id
|
|
|
87
94
|
is_current = choice.get("id") == current_node_id
|
|
88
95
|
label = choice.get("label") or "(untitled)"
|
|
89
96
|
truncated = label[:52] + ("…" if len(label) > 52 else "")
|
|
97
|
+
# Node labels can contain `&`, `<`, `>` (assistant-generated
|
|
98
|
+
# summaries often do). Escape before embedding in HTML.
|
|
99
|
+
safe_truncated = _html_escape(truncated)
|
|
90
100
|
node_id_short = str(choice.get("id", ""))[:8]
|
|
91
101
|
status = choice.get("status", "active")
|
|
92
102
|
sc = STATUS_COLOR.get(status, "ansibrightblack")
|
|
93
103
|
msg_count = len(choice.get("materialized_context", []))
|
|
94
104
|
time_str = _relative_time(choice.get("updated_at", ""))
|
|
95
105
|
current_tag = " <ansibrightblack>(current)</ansibrightblack>" if is_current else ""
|
|
96
|
-
label_fmt = f"<ansiwhite><b>{
|
|
106
|
+
label_fmt = f"<ansiwhite><b>{safe_truncated}</b></ansiwhite>" if selected else f"<ansiwhite>{safe_truncated}</ansiwhite>"
|
|
97
107
|
lines.append(f" {cursor}{label_fmt}{current_tag}\n")
|
|
98
108
|
time_part = f" · {time_str}" if time_str else ""
|
|
99
109
|
lines.append(f" <ansibrightblack>{node_id_short} · <{sc}>{status}</{sc}> · {msg_count} msg{'s' if msg_count != 1 else ''}{time_part}</ansibrightblack>\n\n")
|
|
@@ -155,15 +165,19 @@ async def pick_message(context: list[dict[str, Any]]) -> int | None:
|
|
|
155
165
|
for i, msg in enumerate(reversed_ctx):
|
|
156
166
|
role = msg.get("role", "user")
|
|
157
167
|
content = (msg.get("content") or "").replace("\n", " ").strip()
|
|
168
|
+
# Message bodies routinely contain `<`, `>`, `&` (code snippets,
|
|
169
|
+
# diffs, shell commands). Escape before HTML interpolation.
|
|
170
|
+
preview = content[:64] + ("…" if len(content) > 64 else "")
|
|
171
|
+
safe_preview = _html_escape(preview)
|
|
158
172
|
is_active = i == active_display_idx
|
|
159
173
|
if role == "assistant":
|
|
160
174
|
cursor = "<ansiwhite>▶ </ansiwhite>" if is_active else " "
|
|
161
175
|
role_fmt = "<ansigreen>[assistant]</ansigreen>"
|
|
162
|
-
text_fmt = f"<ansiwhite><b>{
|
|
176
|
+
text_fmt = f"<ansiwhite><b>{safe_preview}</b></ansiwhite>" if is_active else f"<ansiwhite>{safe_preview}</ansiwhite>"
|
|
163
177
|
lines.append(f" {cursor}{role_fmt} {text_fmt}\n\n")
|
|
164
178
|
else:
|
|
165
179
|
# User message — display only, dimmed, no cursor
|
|
166
|
-
lines.append(f" <ansibrightblack>[user] {
|
|
180
|
+
lines.append(f" <ansibrightblack>[user] {safe_preview}</ansibrightblack>\n\n")
|
|
167
181
|
lines.append(" <ansibrightblack>↑/↓ to navigate · Enter to branch here · Ctrl+C to cancel</ansibrightblack>\n")
|
|
168
182
|
return HTML("".join(lines))
|
|
169
183
|
|
|
@@ -248,9 +262,11 @@ async def pick_session(graphs: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
|
248
262
|
lines.append(f" {cursor}{label}\n")
|
|
249
263
|
else:
|
|
250
264
|
name = choice.get("name", "Untitled")
|
|
265
|
+
# Graph names are user-controlled; escape before embedding.
|
|
266
|
+
safe_name = _html_escape(name)
|
|
251
267
|
updated = _relative_time(choice.get("updated_at", ""))
|
|
252
268
|
active_node = choice.get("active_node")
|
|
253
|
-
name_fmt = f"<ansiwhite><b>{
|
|
269
|
+
name_fmt = f"<ansiwhite><b>{safe_name}</b></ansiwhite>" if selected else f"<ansiwhite>{safe_name}</ansiwhite>"
|
|
254
270
|
time_fmt = f"<ansibrightblack>{updated}</ansibrightblack>" if updated else ""
|
|
255
271
|
# Line 1: graph name + last active time
|
|
256
272
|
lines.append(f" {cursor}{name_fmt} {time_fmt}\n")
|
|
@@ -258,8 +274,11 @@ async def pick_session(graphs: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
|
258
274
|
if active_node and active_node.get("label"):
|
|
259
275
|
node_lbl = active_node["label"]
|
|
260
276
|
truncated = node_lbl[:60] + ("…" if len(node_lbl) > 60 else "")
|
|
277
|
+
# Node labels are assistant-generated and routinely
|
|
278
|
+
# contain HTML-unsafe characters; escape.
|
|
279
|
+
safe_truncated = _html_escape(truncated)
|
|
261
280
|
node_id_short = active_node["id"][:8]
|
|
262
|
-
lines.append(f" <ansibrightblack>↳ resume node {node_id_short} · \"{
|
|
281
|
+
lines.append(f" <ansibrightblack>↳ resume node {node_id_short} · \"{safe_truncated}\"</ansibrightblack>\n")
|
|
263
282
|
elif active_node:
|
|
264
283
|
node_id_short = active_node["id"][:8]
|
|
265
284
|
lines.append(f" <ansibrightblack>↳ resume node {node_id_short} · (untitled)</ansibrightblack>\n")
|
|
@@ -168,11 +168,26 @@ class _MarkdownStreamState:
|
|
|
168
168
|
|
|
169
169
|
|
|
170
170
|
# ---------------------------------------------------------------------------
|
|
171
|
-
# Module-level
|
|
172
|
-
#
|
|
171
|
+
# Module-level pause/resume hooks for interactive UI overlays (permission
|
|
172
|
+
# pickers, in-line confirmations) that need to take over the terminal.
|
|
173
|
+
#
|
|
174
|
+
# Two pieces of in-flight terminal state can collide with a prompt_toolkit
|
|
175
|
+
# Application:
|
|
176
|
+
# 1. The turn-level spinner thread (rewrites the same line).
|
|
177
|
+
# 2. The streaming-markdown rich.Live context, which owns the cursor for
|
|
178
|
+
# partial re-renders. If a picker tries to render while rich.Live is
|
|
179
|
+
# mid-refresh, prompt_toolkit's draw is overwritten on the next Live
|
|
180
|
+
# tick and the picker is invisible to the user — the visible symptom
|
|
181
|
+
# is "the agent went silent" because the user never sees a prompt to
|
|
182
|
+
# approve and the agent is sitting on a permission request.
|
|
183
|
+
#
|
|
184
|
+
# pause_* committs/stops both pieces; resume_* restarts only the spinner
|
|
185
|
+
# (the markdown stream restarts itself the next time a chunk arrives, so
|
|
186
|
+
# there's nothing to resume there).
|
|
173
187
|
# ---------------------------------------------------------------------------
|
|
174
188
|
|
|
175
189
|
_active_spinner: _Spinner | None = None
|
|
190
|
+
_active_md_stream: _MarkdownStreamState | None = None
|
|
176
191
|
|
|
177
192
|
|
|
178
193
|
def pause_active_spinner() -> None:
|
|
@@ -185,6 +200,15 @@ def resume_active_spinner() -> None:
|
|
|
185
200
|
_active_spinner.resume()
|
|
186
201
|
|
|
187
202
|
|
|
203
|
+
def pause_active_md_stream() -> None:
|
|
204
|
+
"""Flush and stop the streaming markdown rich.Live so a prompt_toolkit
|
|
205
|
+
picker can take over the terminal cleanly. Already-rendered text stays
|
|
206
|
+
committed above; the next text chunk after the picker exits will start
|
|
207
|
+
a fresh Live below it."""
|
|
208
|
+
if _active_md_stream is not None:
|
|
209
|
+
_active_md_stream.flush()
|
|
210
|
+
|
|
211
|
+
|
|
188
212
|
# ---------------------------------------------------------------------------
|
|
189
213
|
# TerminalRenderer — main public class
|
|
190
214
|
# ---------------------------------------------------------------------------
|
|
@@ -199,8 +223,14 @@ class TerminalRenderer:
|
|
|
199
223
|
"""
|
|
200
224
|
|
|
201
225
|
def __init__(self) -> None:
|
|
226
|
+
global _active_md_stream
|
|
202
227
|
self._spinner: _Spinner | None = None
|
|
203
228
|
self._md = _MarkdownStreamState()
|
|
229
|
+
# Register the markdown stream so pause_active_md_stream() can
|
|
230
|
+
# flush it ahead of any interactive picker. The renderer is a
|
|
231
|
+
# singleton per session, so this assignment is safe to leave in
|
|
232
|
+
# place for the lifetime of the process.
|
|
233
|
+
_active_md_stream = self._md
|
|
204
234
|
self._spinner_active = False
|
|
205
235
|
|
|
206
236
|
# ------------------------------------------------------------------
|
|
@@ -37,6 +37,7 @@ from .drivers.acp import ACPDriver, AgentLaunchError
|
|
|
37
37
|
from .drivers.claude import ClaudeDriver
|
|
38
38
|
from .picker import pick_session, pick_node, pick_message
|
|
39
39
|
from .renderer import TerminalRenderer, pause_active_spinner, resume_active_spinner
|
|
40
|
+
from .snapshot import SnapshotManager
|
|
40
41
|
|
|
41
42
|
logger = logging.getLogger(__name__)
|
|
42
43
|
console = Console()
|
|
@@ -128,6 +129,24 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
|
|
|
128
129
|
await backend.aclose()
|
|
129
130
|
return
|
|
130
131
|
|
|
132
|
+
# ----------------------------------------------------------------
|
|
133
|
+
# World-state snapshots — shadow git repo for filesystem state per graph.
|
|
134
|
+
# One shadow repo per graph_id (not per project path) so explicit "+ Start
|
|
135
|
+
# new session" gives a clean filesystem slate too. Resuming the same graph
|
|
136
|
+
# reuses the same shadow.
|
|
137
|
+
# ----------------------------------------------------------------
|
|
138
|
+
snapshot_manager: SnapshotManager | None = None
|
|
139
|
+
try:
|
|
140
|
+
snapshot_manager = SnapshotManager(cwd, graph_id)
|
|
141
|
+
await snapshot_manager.init()
|
|
142
|
+
except Exception:
|
|
143
|
+
# Snapshot subsystem is best-effort — never block the session on it.
|
|
144
|
+
# Common reasons it fails: git not on PATH, cwd not writable, broken
|
|
145
|
+
# shadow repo. Log loudly but continue without snapshots; /branch
|
|
146
|
+
# and /checkout will fall back to conversation-only forking.
|
|
147
|
+
logger.exception("SnapshotManager: init failed — snapshots disabled this session")
|
|
148
|
+
snapshot_manager = None
|
|
149
|
+
|
|
131
150
|
# ----------------------------------------------------------------
|
|
132
151
|
# Wire up event capture
|
|
133
152
|
# ----------------------------------------------------------------
|
|
@@ -135,6 +154,7 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
|
|
|
135
154
|
backend=backend,
|
|
136
155
|
graph_id=graph_id,
|
|
137
156
|
node_id=node_id,
|
|
157
|
+
snapshot_manager=snapshot_manager,
|
|
138
158
|
)
|
|
139
159
|
|
|
140
160
|
# Shared state for branch switching — written by WS listener, read by REPL
|
|
@@ -238,10 +258,20 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
|
|
|
238
258
|
_toolbar_style = Style.from_dict({'bottom-toolbar': 'bg:default fg:ansibrightblack noreverse'})
|
|
239
259
|
|
|
240
260
|
def _toolbar() -> HTML:
|
|
261
|
+
# Node labels are user prompts and assistant-generated summaries —
|
|
262
|
+
# both routinely contain `&`, `<`, `>` that would crash prompt_toolkit's
|
|
263
|
+
# HTML parser. Escape before embedding. Otherwise the toolbar redraws
|
|
264
|
+
# on every keystroke, hits the ExpatError, and prompts the user with
|
|
265
|
+
# "Press ENTER to continue…" indefinitely.
|
|
266
|
+
from html import escape as _html_escape
|
|
241
267
|
label = current_node_label['value']
|
|
242
268
|
status = current_node_status['value']
|
|
243
269
|
node_short = capture.node_id[:8] if capture.node_id else '--------'
|
|
244
|
-
|
|
270
|
+
if label:
|
|
271
|
+
preview = label[:40] + ('…' if len(label) > 40 else '')
|
|
272
|
+
label_part = f" · {_html_escape(preview)}"
|
|
273
|
+
else:
|
|
274
|
+
label_part = ""
|
|
245
275
|
status_badge = " [branched]" if status == 'branched' else ""
|
|
246
276
|
border = '─' * 200 # prompt_toolkit clips to terminal width
|
|
247
277
|
return HTML(
|
|
@@ -263,6 +293,52 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
|
|
|
263
293
|
else:
|
|
264
294
|
await driver.connect(cwd=cwd)
|
|
265
295
|
|
|
296
|
+
async def _restore_fs_snapshot(node_data: dict) -> None:
|
|
297
|
+
"""
|
|
298
|
+
Best-effort restore of the work-tree to a node's filesystem snapshot.
|
|
299
|
+
|
|
300
|
+
Surfaces dirty-tree status to the user before the rewind: if the
|
|
301
|
+
current work-tree has uncommitted edits that will be auto-
|
|
302
|
+
preserved, we tell them inline so they understand the rewind
|
|
303
|
+
isn't silently destroying anything. After the restore we echo
|
|
304
|
+
the rescue commit hash so the user has a handle to recover
|
|
305
|
+
from if they ever want those edits back.
|
|
306
|
+
|
|
307
|
+
Logs and continues on any failure — we never want a snapshot
|
|
308
|
+
issue to block conversation-side branching/checkout, which is
|
|
309
|
+
still partially useful even with FS divergence.
|
|
310
|
+
"""
|
|
311
|
+
if snapshot_manager is None:
|
|
312
|
+
return
|
|
313
|
+
git_hash = (node_data.get("git_hash") or "").strip()
|
|
314
|
+
if not git_hash:
|
|
315
|
+
# Node was created before snapshots existed, or the source
|
|
316
|
+
# node had no FS changes captured. Nothing to restore.
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
# Pre-restore: tell the user if pending edits will be auto-
|
|
320
|
+
# preserved. Best-effort — never block the restore on this.
|
|
321
|
+
try:
|
|
322
|
+
if await snapshot_manager.has_uncommitted_changes():
|
|
323
|
+
console.print(
|
|
324
|
+
"[dim yellow] ⚠ Saving uncommitted edits as a rescue snapshot before rewinding…[/dim yellow]"
|
|
325
|
+
)
|
|
326
|
+
except Exception:
|
|
327
|
+
logger.debug("snapshot: dirty-tree check failed", exc_info=True)
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
rescue_hash = await snapshot_manager.restore(git_hash)
|
|
331
|
+
logger.debug("snapshot: restored FS to %s", git_hash[:8])
|
|
332
|
+
if rescue_hash:
|
|
333
|
+
console.print(
|
|
334
|
+
f"[dim] edits preserved as snapshot [bold]{rescue_hash[:8]}[/bold] in shadow git[/dim]"
|
|
335
|
+
)
|
|
336
|
+
except Exception:
|
|
337
|
+
logger.exception(
|
|
338
|
+
"snapshot: failed to restore FS to %s — continuing without rewind",
|
|
339
|
+
git_hash[:8],
|
|
340
|
+
)
|
|
341
|
+
|
|
266
342
|
async def _handle_pending_switch(*, print_notification: bool = False) -> None:
|
|
267
343
|
"""
|
|
268
344
|
Handle a pending branch switch if one is queued.
|
|
@@ -288,6 +364,9 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
|
|
|
288
364
|
if fetched_label:
|
|
289
365
|
current_node_label['value'] = fetched_label
|
|
290
366
|
node_label_cache[new_node_id] = fetched_label
|
|
367
|
+
# Rewind the work-tree to this node's snapshot before the
|
|
368
|
+
# agent restart — agent and FS need to agree on state.
|
|
369
|
+
await _restore_fs_snapshot(result)
|
|
291
370
|
new_cc_session_id = await driver.reset_context(
|
|
292
371
|
result.get("materialized_context", []),
|
|
293
372
|
cc_session_id=result.get("cc_session_id") or None,
|
|
@@ -332,6 +411,11 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
|
|
|
332
411
|
child_checkout = await backend.checkout_node(child_id)
|
|
333
412
|
current_node_status['value'] = child_checkout.get("status", "active")
|
|
334
413
|
current_node_label['value'] = child_checkout.get("label") or None
|
|
414
|
+
# Rewind work-tree to the snapshot at the chosen branch
|
|
415
|
+
# point. Backend's branch_node populated child.git_hash by
|
|
416
|
+
# walking parent's events to the right index — see
|
|
417
|
+
# backend/nodes/views.py branch_node().
|
|
418
|
+
await _restore_fs_snapshot(child_checkout)
|
|
335
419
|
new_cc_session_id = await driver.reset_context(
|
|
336
420
|
child_checkout.get("materialized_context", []),
|
|
337
421
|
cc_session_id=child_checkout.get("cc_session_id") or None,
|
|
@@ -402,6 +486,8 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
|
|
|
402
486
|
if fetched_label:
|
|
403
487
|
node_label_cache[target_id] = fetched_label
|
|
404
488
|
|
|
489
|
+
# Rewind work-tree to target node's snapshot before agent restart.
|
|
490
|
+
await _restore_fs_snapshot(node_data)
|
|
405
491
|
new_cc_session_id = await driver.reset_context(
|
|
406
492
|
node_data.get("materialized_context", []),
|
|
407
493
|
cc_session_id=node_data.get("cc_session_id") or None,
|
|
@@ -456,6 +542,8 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
|
|
|
456
542
|
# Force active — user explicitly reverted, bypass the branched-node guard
|
|
457
543
|
current_node_status['value'] = 'active'
|
|
458
544
|
current_node_label['value'] = parent_data.get("label") or None
|
|
545
|
+
# Rewind FS to parent's snapshot before agent restart.
|
|
546
|
+
await _restore_fs_snapshot(parent_data)
|
|
459
547
|
new_cc_session_id = await driver.reset_context(
|
|
460
548
|
parent_data.get("materialized_context", []),
|
|
461
549
|
cc_session_id=parent_data.get("cc_session_id") or None,
|
|
@@ -510,6 +598,8 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
|
|
|
510
598
|
child_checkout = await backend.checkout_node(child_id)
|
|
511
599
|
current_node_status['value'] = child_checkout.get("status", "active")
|
|
512
600
|
current_node_label['value'] = child_checkout.get("label") or None
|
|
601
|
+
# Rewind FS to the new child's inherited snapshot before agent restart.
|
|
602
|
+
await _restore_fs_snapshot(child_checkout)
|
|
513
603
|
new_cc_session_id = await driver.reset_context(
|
|
514
604
|
child_checkout.get("materialized_context", []),
|
|
515
605
|
cc_session_id=child_checkout.get("cc_session_id") or None,
|
|
@@ -705,6 +795,24 @@ async def _run(driver_name: str, cwd: str, no_browser: bool = False) -> None:
|
|
|
705
795
|
await backend.patch_node(capture.node_id, {"cc_session_id": cc_session_id})
|
|
706
796
|
except Exception:
|
|
707
797
|
logger.warning("Failed to store cc_session_id for node %s", capture.node_id)
|
|
798
|
+
|
|
799
|
+
# Take a final snapshot at turn end and pin it as the
|
|
800
|
+
# node's git_hash. Catches any user-side or final-tool
|
|
801
|
+
# FS changes that happened after the last hook fired
|
|
802
|
+
# so /branch and /checkout always have a concrete state
|
|
803
|
+
# to rewind to. No-op if nothing changed.
|
|
804
|
+
if snapshot_manager is not None:
|
|
805
|
+
try:
|
|
806
|
+
snap_hash, _ = await snapshot_manager.commit(
|
|
807
|
+
f"node {capture.node_id[:8]} turn end"
|
|
808
|
+
)
|
|
809
|
+
if snap_hash:
|
|
810
|
+
try:
|
|
811
|
+
await backend.patch_node(capture.node_id, {"git_hash": snap_hash})
|
|
812
|
+
except Exception:
|
|
813
|
+
logger.warning("Failed to persist git_hash for node %s", capture.node_id)
|
|
814
|
+
except Exception:
|
|
815
|
+
logger.exception("turn-end snapshot commit failed")
|
|
708
816
|
# If auto-branch happened mid-stream, reset driver context to child node now
|
|
709
817
|
# that the stream is fully done (can't disconnect/reconnect mid-stream).
|
|
710
818
|
# The pivot user message already lives on the child via the in-stream
|
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""
|
|
2
|
+
World-state snapshots — shadow git repo capturing filesystem state per tool call.
|
|
3
|
+
|
|
4
|
+
The product promise is "graph layer on top of agentic AI tools." If `/branch`
|
|
5
|
+
doesn't actually rewind the filesystem when forking the conversation, that
|
|
6
|
+
promise is incomplete. SnapshotManager closes that gap by tagging every
|
|
7
|
+
side-effecting tool call with a git commit and rewinding the work-tree on
|
|
8
|
+
`/branch` and `/checkout`.
|
|
9
|
+
|
|
10
|
+
Architecture
|
|
11
|
+
------------
|
|
12
|
+
We use git's `--git-dir` / `--work-tree` separation. The shadow repo's `.git`
|
|
13
|
+
lives at `~/.expluro/snapshots/<graph-id>/.git`, where `graph-id` is the
|
|
14
|
+
backend's UUID for the graph (one per "topology graph session" in the
|
|
15
|
+
user's mental model). Multiple graphs in the same project directory each
|
|
16
|
+
get their own isolated shadow — explicit "+ Start new session" means a
|
|
17
|
+
fresh slate at the filesystem level too.
|
|
18
|
+
|
|
19
|
+
The work-tree is the user's actual project directory. Two consequences:
|
|
20
|
+
|
|
21
|
+
1. The user's own `.git` is never touched. Their repo state, their staged
|
|
22
|
+
index, their stash — all untouched. Their normal git workflow is
|
|
23
|
+
unaffected.
|
|
24
|
+
2. Non-git projects work transparently. A project that's just loose files
|
|
25
|
+
gets snapshots without the user even initializing git.
|
|
26
|
+
|
|
27
|
+
We respect the user's `.gitignore` (shadow git reads it from the work-tree),
|
|
28
|
+
so build artifacts, `node_modules`, and friends stay out of snapshot history.
|
|
29
|
+
|
|
30
|
+
Edge cases handled
|
|
31
|
+
------------------
|
|
32
|
+
- Binary files: git handles natively (hashed, no text diff).
|
|
33
|
+
- Large files: respected via `.gitignore` if user excludes them; otherwise
|
|
34
|
+
we cap diff text reported in events at SNAPSHOT_DIFF_MAX_BYTES.
|
|
35
|
+
- User edits between tool calls: commit captures all current work-tree
|
|
36
|
+
state, so manual edits get rolled into the next snapshot.
|
|
37
|
+
- `/branch` with uncommitted vs-last-snapshot work: we auto-commit those as
|
|
38
|
+
a "user-edits" snapshot before the restore so nothing is lost.
|
|
39
|
+
- User's git hooks: shadow commits use `--no-verify` to skip them — the
|
|
40
|
+
shadow repo is a tool-internal concern, not a code-review concern.
|
|
41
|
+
- Concurrent operations: each call awaits its subprocess to completion,
|
|
42
|
+
no parallel git operations on the same dir.
|
|
43
|
+
"""
|
|
44
|
+
from __future__ import annotations
|
|
45
|
+
|
|
46
|
+
import asyncio
|
|
47
|
+
import logging
|
|
48
|
+
import os
|
|
49
|
+
import shutil
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Optional
|
|
52
|
+
|
|
53
|
+
from .constants import APP_DIR
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
# Cap diff text reported in events. Full diffs for large changes blow up
|
|
58
|
+
# the event log and don't render meaningfully in the browser anyway —
|
|
59
|
+
# truncate and let users fetch the full snapshot via the CLI later.
|
|
60
|
+
SNAPSHOT_DIFF_MAX_BYTES = 50_000
|
|
61
|
+
|
|
62
|
+
SNAPSHOTS_ROOT = Path.home() / APP_DIR / "snapshots"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SnapshotError(RuntimeError):
|
|
66
|
+
"""Raised when a snapshot operation fails in a way the caller should know about."""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class SnapshotManager:
|
|
70
|
+
"""Shadow-git snapshot manager scoped to a single graph (topology session).
|
|
71
|
+
|
|
72
|
+
Each graph gets its own shadow repo — explicit "+ Start new session"
|
|
73
|
+
means a clean slate at the filesystem level too. Cleanup is natural:
|
|
74
|
+
deleting a graph maps 1:1 to deleting its shadow directory.
|
|
75
|
+
|
|
76
|
+
Lifecycle:
|
|
77
|
+
sm = SnapshotManager(cwd, graph_id)
|
|
78
|
+
await sm.init() # ensure shadow repo exists
|
|
79
|
+
hash, diff = await sm.commit(label) # snapshot current state
|
|
80
|
+
await sm.restore(hash) # rewind work-tree to that snapshot
|
|
81
|
+
# no explicit close needed — shadow repo persists across sessions
|
|
82
|
+
# for the SAME graph (resume preserves history); deleted with the graph
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(self, cwd: str, graph_id: str) -> None:
|
|
86
|
+
if not os.path.isdir(cwd):
|
|
87
|
+
raise SnapshotError(f"cwd is not a directory: {cwd}")
|
|
88
|
+
if not graph_id:
|
|
89
|
+
raise SnapshotError("graph_id is required for SnapshotManager")
|
|
90
|
+
self._cwd = os.path.realpath(cwd)
|
|
91
|
+
self._graph_id = str(graph_id)
|
|
92
|
+
self._git_dir = SNAPSHOTS_ROOT / self._graph_id / ".git"
|
|
93
|
+
self._initialized = False
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def graph_id(self) -> str:
|
|
97
|
+
return self._graph_id
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def git_dir(self) -> Path:
|
|
101
|
+
return self._git_dir
|
|
102
|
+
|
|
103
|
+
# ------------------------------------------------------------------
|
|
104
|
+
# Lifecycle
|
|
105
|
+
# ------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
async def init(self) -> None:
|
|
108
|
+
"""Ensure the shadow repo exists and has at least an initial commit.
|
|
109
|
+
|
|
110
|
+
Idempotent — safe to call on every session start.
|
|
111
|
+
"""
|
|
112
|
+
if self._initialized:
|
|
113
|
+
return
|
|
114
|
+
|
|
115
|
+
SNAPSHOTS_ROOT.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
|
|
117
|
+
# Already initialized?
|
|
118
|
+
if (self._git_dir / "HEAD").exists():
|
|
119
|
+
self._initialized = True
|
|
120
|
+
logger.debug("SnapshotManager: shadow repo already exists at %s", self._git_dir)
|
|
121
|
+
return
|
|
122
|
+
|
|
123
|
+
# Create the shadow repo. We do NOT init the work-tree here —
|
|
124
|
+
# `git --git-dir=X --work-tree=Y init` would point HEAD into Y
|
|
125
|
+
# which is fine, but we want to be explicit about which dir the
|
|
126
|
+
# repo metadata lives in.
|
|
127
|
+
self._git_dir.parent.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
await self._git("init", "--quiet", "--bare=false", create_anyway=True)
|
|
129
|
+
|
|
130
|
+
# Configure shadow repo identity — needed for commits to succeed
|
|
131
|
+
# without leaning on whatever the user has globally configured.
|
|
132
|
+
await self._git("config", "user.name", "Expluro Snapshot")
|
|
133
|
+
await self._git("config", "user.email", "snapshots@expluro.local")
|
|
134
|
+
# Don't sign shadow commits even if user has commit.gpgsign=true
|
|
135
|
+
# globally — we don't want to spam the user with gpg prompts.
|
|
136
|
+
await self._git("config", "commit.gpgsign", "false")
|
|
137
|
+
|
|
138
|
+
# Initial baseline commit. May be empty if cwd is empty — git
|
|
139
|
+
# accepts empty-tree commits with --allow-empty.
|
|
140
|
+
await self._git("add", "--all", "--", ".")
|
|
141
|
+
await self._git(
|
|
142
|
+
"commit",
|
|
143
|
+
"--allow-empty",
|
|
144
|
+
"--no-verify",
|
|
145
|
+
"--quiet",
|
|
146
|
+
"-m",
|
|
147
|
+
"expluro: initial snapshot",
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
self._initialized = True
|
|
151
|
+
logger.info("SnapshotManager: initialized shadow repo at %s", self._git_dir)
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------
|
|
154
|
+
# Capture
|
|
155
|
+
# ------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
async def commit(self, label: str) -> tuple[Optional[str], Optional[dict]]:
|
|
158
|
+
"""Commit the current work-tree state to the shadow repo.
|
|
159
|
+
|
|
160
|
+
Returns (commit_hash, diff_summary). If there are no changes since
|
|
161
|
+
the last commit, returns (existing_HEAD, None) so the caller still
|
|
162
|
+
has a hash to attach to the event.
|
|
163
|
+
|
|
164
|
+
diff_summary is a dict suitable for embedding in an event payload:
|
|
165
|
+
{
|
|
166
|
+
"files_changed": [{"path": str, "status": str}, ...],
|
|
167
|
+
"insertions": int,
|
|
168
|
+
"deletions": int,
|
|
169
|
+
"diff_text": str, # truncated to SNAPSHOT_DIFF_MAX_BYTES
|
|
170
|
+
"truncated": bool,
|
|
171
|
+
}
|
|
172
|
+
"""
|
|
173
|
+
if not self._initialized:
|
|
174
|
+
await self.init()
|
|
175
|
+
|
|
176
|
+
# Stage everything new/modified/deleted that isn't gitignored.
|
|
177
|
+
await self._git("add", "--all", "--", ".")
|
|
178
|
+
|
|
179
|
+
# Has anything actually changed since HEAD?
|
|
180
|
+
has_changes = await self._has_staged_changes()
|
|
181
|
+
if not has_changes:
|
|
182
|
+
head = await self._git_head()
|
|
183
|
+
return head, None
|
|
184
|
+
|
|
185
|
+
# Capture diff BEFORE committing so we can describe what changed.
|
|
186
|
+
diff_summary = await self._diff_summary_staged()
|
|
187
|
+
|
|
188
|
+
sanitized = label.replace("\n", " ").strip()[:200] or "snapshot"
|
|
189
|
+
await self._git(
|
|
190
|
+
"commit",
|
|
191
|
+
"--no-verify",
|
|
192
|
+
"--quiet",
|
|
193
|
+
"-m",
|
|
194
|
+
f"expluro: {sanitized}",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
commit_hash = await self._git_head()
|
|
198
|
+
logger.debug(
|
|
199
|
+
"SnapshotManager: committed %s (%d files, +%d/-%d)",
|
|
200
|
+
commit_hash[:8] if commit_hash else "?",
|
|
201
|
+
len(diff_summary.get("files_changed", [])),
|
|
202
|
+
diff_summary.get("insertions", 0),
|
|
203
|
+
diff_summary.get("deletions", 0),
|
|
204
|
+
)
|
|
205
|
+
return commit_hash, diff_summary
|
|
206
|
+
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
# Restore
|
|
209
|
+
# ------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
async def has_uncommitted_changes(self) -> bool:
|
|
212
|
+
"""Return True if the work-tree differs from the shadow repo's HEAD.
|
|
213
|
+
|
|
214
|
+
Used to inform the user before a destructive `restore()` that their
|
|
215
|
+
in-flight edits will be auto-preserved as a rescue snapshot. Never
|
|
216
|
+
raises — a git failure is treated as "clean" so this can't block
|
|
217
|
+
the restore path on transient issues.
|
|
218
|
+
"""
|
|
219
|
+
if not self._initialized:
|
|
220
|
+
return False
|
|
221
|
+
try:
|
|
222
|
+
out = await self._git("status", "--porcelain")
|
|
223
|
+
except SnapshotError:
|
|
224
|
+
return False
|
|
225
|
+
return bool(out.strip())
|
|
226
|
+
|
|
227
|
+
async def restore(self, commit_hash: str) -> Optional[str]:
|
|
228
|
+
"""Rewind the work-tree to the given snapshot.
|
|
229
|
+
|
|
230
|
+
Auto-commits any pending uncommitted-vs-last-snapshot work first so
|
|
231
|
+
nothing is lost. The auto-commit is reachable from the shadow repo's
|
|
232
|
+
reflog and we return its hash so the caller can surface it to the
|
|
233
|
+
user as a recovery handle.
|
|
234
|
+
|
|
235
|
+
Returns the rescue commit hash when the auto-preserve actually
|
|
236
|
+
happened (i.e. the work-tree was dirty), or None when there was
|
|
237
|
+
nothing to preserve.
|
|
238
|
+
|
|
239
|
+
Raises SnapshotError if the target commit doesn't exist or the
|
|
240
|
+
reset fails.
|
|
241
|
+
"""
|
|
242
|
+
if not self._initialized:
|
|
243
|
+
await self.init()
|
|
244
|
+
|
|
245
|
+
if not commit_hash:
|
|
246
|
+
raise SnapshotError("restore() called with empty commit hash")
|
|
247
|
+
|
|
248
|
+
# Verify the hash exists in our shadow repo.
|
|
249
|
+
try:
|
|
250
|
+
await self._git("cat-file", "-e", f"{commit_hash}^{{commit}}")
|
|
251
|
+
except SnapshotError as exc:
|
|
252
|
+
raise SnapshotError(
|
|
253
|
+
f"snapshot {commit_hash[:8]} not found in shadow repo"
|
|
254
|
+
) from exc
|
|
255
|
+
|
|
256
|
+
# Auto-commit any pending work to preserve it before we rewind.
|
|
257
|
+
# Most of the time this is a no-op (the last tool call already
|
|
258
|
+
# committed). It matters when the user has manually edited files
|
|
259
|
+
# between turns, or when a turn's snapshot didn't catch the very
|
|
260
|
+
# last write.
|
|
261
|
+
rescue_hash: Optional[str] = None
|
|
262
|
+
await self._git("add", "--all", "--", ".")
|
|
263
|
+
if await self._has_staged_changes():
|
|
264
|
+
await self._git(
|
|
265
|
+
"commit",
|
|
266
|
+
"--no-verify",
|
|
267
|
+
"--quiet",
|
|
268
|
+
"-m",
|
|
269
|
+
"expluro: auto-preserve before restore",
|
|
270
|
+
)
|
|
271
|
+
rescue_hash = await self._git_head()
|
|
272
|
+
logger.info(
|
|
273
|
+
"SnapshotManager: preserved pre-restore changes as %s",
|
|
274
|
+
(rescue_hash or "?")[:8],
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
# Hard-reset the work-tree to the target commit. We use reset
|
|
278
|
+
# rather than checkout because we want the index AND the work-tree
|
|
279
|
+
# to match the target — checkout in detached-HEAD mode plays
|
|
280
|
+
# weirdly with the user's normal git workflow.
|
|
281
|
+
await self._git("reset", "--hard", "--quiet", commit_hash)
|
|
282
|
+
logger.info("SnapshotManager: restored to %s", commit_hash[:8])
|
|
283
|
+
return rescue_hash
|
|
284
|
+
|
|
285
|
+
# ------------------------------------------------------------------
|
|
286
|
+
# Diff helpers
|
|
287
|
+
# ------------------------------------------------------------------
|
|
288
|
+
|
|
289
|
+
async def diff_against_head(self, commit_hash: str) -> Optional[dict]:
|
|
290
|
+
"""Return a diff summary comparing the given commit against current HEAD.
|
|
291
|
+
|
|
292
|
+
Used by the browser node detail panel to render "what changed at this
|
|
293
|
+
node" without us having to ship a separate diff API.
|
|
294
|
+
"""
|
|
295
|
+
if not self._initialized:
|
|
296
|
+
await self.init()
|
|
297
|
+
if not commit_hash:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
try:
|
|
301
|
+
return await self._diff_summary_between(f"{commit_hash}~1", commit_hash)
|
|
302
|
+
except SnapshotError as exc:
|
|
303
|
+
logger.warning("diff_against_head failed: %s", exc)
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
# ------------------------------------------------------------------
|
|
307
|
+
# Internal: git plumbing
|
|
308
|
+
# ------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
async def _git(
|
|
311
|
+
self,
|
|
312
|
+
*args: str,
|
|
313
|
+
create_anyway: bool = False,
|
|
314
|
+
) -> str:
|
|
315
|
+
"""
|
|
316
|
+
Run a git command in the shadow repo. Returns stdout.
|
|
317
|
+
|
|
318
|
+
`create_anyway` is for `init` itself — git init doesn't need
|
|
319
|
+
--git-dir/--work-tree the same way.
|
|
320
|
+
"""
|
|
321
|
+
# GIT_DIR + GIT_WORK_TREE env separates the shadow repo from the
|
|
322
|
+
# user's. Setting via env (not flags) lets `git init` and friends
|
|
323
|
+
# behave correctly.
|
|
324
|
+
env = os.environ.copy()
|
|
325
|
+
env["GIT_DIR"] = str(self._git_dir)
|
|
326
|
+
env["GIT_WORK_TREE"] = self._cwd
|
|
327
|
+
# Avoid leaning on user's per-repo or global config that might
|
|
328
|
+
# interfere (e.g. signing, commit templates, hooks paths).
|
|
329
|
+
env.pop("GIT_CONFIG_GLOBAL", None)
|
|
330
|
+
env.pop("GIT_CONFIG_SYSTEM", None)
|
|
331
|
+
|
|
332
|
+
# `git init` needs the parent dir to exist; its own positional arg
|
|
333
|
+
# is the GIT_DIR location.
|
|
334
|
+
if args and args[0] == "init":
|
|
335
|
+
cmd = ["git", "init", "--quiet", str(self._git_dir)]
|
|
336
|
+
else:
|
|
337
|
+
cmd = ["git", *args]
|
|
338
|
+
|
|
339
|
+
try:
|
|
340
|
+
proc = await asyncio.create_subprocess_exec(
|
|
341
|
+
*cmd,
|
|
342
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
343
|
+
stdout=asyncio.subprocess.PIPE,
|
|
344
|
+
stderr=asyncio.subprocess.PIPE,
|
|
345
|
+
env=env,
|
|
346
|
+
cwd=self._cwd,
|
|
347
|
+
)
|
|
348
|
+
except FileNotFoundError as exc:
|
|
349
|
+
raise SnapshotError(
|
|
350
|
+
"git is not installed or not on PATH — snapshots disabled"
|
|
351
|
+
) from exc
|
|
352
|
+
|
|
353
|
+
stdout_b, stderr_b = await proc.communicate()
|
|
354
|
+
if proc.returncode != 0:
|
|
355
|
+
raise SnapshotError(
|
|
356
|
+
f"git {' '.join(args)} failed (rc={proc.returncode}): "
|
|
357
|
+
f"{stderr_b.decode(errors='replace').strip()}"
|
|
358
|
+
)
|
|
359
|
+
return stdout_b.decode(errors="replace")
|
|
360
|
+
|
|
361
|
+
async def _git_head(self) -> Optional[str]:
|
|
362
|
+
try:
|
|
363
|
+
out = await self._git("rev-parse", "HEAD")
|
|
364
|
+
return out.strip() or None
|
|
365
|
+
except SnapshotError:
|
|
366
|
+
return None
|
|
367
|
+
|
|
368
|
+
async def _has_staged_changes(self) -> bool:
|
|
369
|
+
# `git diff --cached --quiet` exits 0 if nothing staged, 1 if changes.
|
|
370
|
+
# Run it manually since our _git wrapper raises on non-zero.
|
|
371
|
+
env = os.environ.copy()
|
|
372
|
+
env["GIT_DIR"] = str(self._git_dir)
|
|
373
|
+
env["GIT_WORK_TREE"] = self._cwd
|
|
374
|
+
proc = await asyncio.create_subprocess_exec(
|
|
375
|
+
"git", "diff", "--cached", "--quiet",
|
|
376
|
+
stdin=asyncio.subprocess.DEVNULL,
|
|
377
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
378
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
379
|
+
env=env,
|
|
380
|
+
cwd=self._cwd,
|
|
381
|
+
)
|
|
382
|
+
rc = await proc.wait()
|
|
383
|
+
return rc != 0
|
|
384
|
+
|
|
385
|
+
async def _diff_summary_staged(self) -> dict:
|
|
386
|
+
"""Build a diff summary for currently-staged changes."""
|
|
387
|
+
files_changed = await self._diff_files_status("--cached")
|
|
388
|
+
numstat = await self._git("diff", "--cached", "--numstat")
|
|
389
|
+
insertions, deletions = _parse_numstat(numstat)
|
|
390
|
+
diff_text = await self._git("diff", "--cached")
|
|
391
|
+
return _build_summary(files_changed, insertions, deletions, diff_text)
|
|
392
|
+
|
|
393
|
+
async def _diff_summary_between(self, a: str, b: str) -> dict:
|
|
394
|
+
files_changed = await self._diff_files_status(a, b)
|
|
395
|
+
numstat = await self._git("diff", "--numstat", a, b)
|
|
396
|
+
insertions, deletions = _parse_numstat(numstat)
|
|
397
|
+
diff_text = await self._git("diff", a, b)
|
|
398
|
+
return _build_summary(files_changed, insertions, deletions, diff_text)
|
|
399
|
+
|
|
400
|
+
async def _diff_files_status(self, *git_args: str) -> list[dict]:
|
|
401
|
+
"""Run `git diff --name-status` and parse to list of {path, status}."""
|
|
402
|
+
out = await self._git("diff", "--name-status", *git_args)
|
|
403
|
+
files: list[dict] = []
|
|
404
|
+
for line in out.splitlines():
|
|
405
|
+
parts = line.split("\t")
|
|
406
|
+
if len(parts) < 2:
|
|
407
|
+
continue
|
|
408
|
+
status = parts[0].strip()
|
|
409
|
+
# Renames produce: R100\told\tnew — collapse to just the new path.
|
|
410
|
+
path = parts[-1]
|
|
411
|
+
files.append({"path": path, "status": status[:1]})
|
|
412
|
+
return files
|
|
413
|
+
|
|
414
|
+
# ------------------------------------------------------------------
|
|
415
|
+
# Maintenance
|
|
416
|
+
# ------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
@staticmethod
|
|
419
|
+
def shadow_dir_for(graph_id: str) -> Path:
|
|
420
|
+
"""Where the shadow repo for this graph lives. Useful for UI display."""
|
|
421
|
+
return SNAPSHOTS_ROOT / str(graph_id)
|
|
422
|
+
|
|
423
|
+
@staticmethod
|
|
424
|
+
def delete_shadow(graph_id: str) -> None:
|
|
425
|
+
"""
|
|
426
|
+
Remove the shadow repo for this graph. Used when a graph is deleted
|
|
427
|
+
or on explicit user request. Once deleted, all snapshot hashes for
|
|
428
|
+
that graph's nodes become orphan references with no restore path —
|
|
429
|
+
which is correct behavior when the graph itself is gone.
|
|
430
|
+
"""
|
|
431
|
+
shadow = SnapshotManager.shadow_dir_for(graph_id)
|
|
432
|
+
if shadow.exists():
|
|
433
|
+
shutil.rmtree(shadow)
|
|
434
|
+
logger.info("SnapshotManager: deleted shadow for graph %s", graph_id)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
# ----------------------------------------------------------------------
|
|
438
|
+
# Module-level helpers
|
|
439
|
+
# ----------------------------------------------------------------------
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _parse_numstat(numstat: str) -> tuple[int, int]:
|
|
443
|
+
"""Parse `git diff --numstat` output to total (insertions, deletions).
|
|
444
|
+
|
|
445
|
+
Numstat format: `<insertions>\t<deletions>\t<path>` per line. Binary
|
|
446
|
+
files show `-\t-\t<path>` and don't contribute to the count.
|
|
447
|
+
"""
|
|
448
|
+
ins = dele = 0
|
|
449
|
+
for line in numstat.splitlines():
|
|
450
|
+
parts = line.split("\t", 2)
|
|
451
|
+
if len(parts) < 3:
|
|
452
|
+
continue
|
|
453
|
+
a, b = parts[0], parts[1]
|
|
454
|
+
if a == "-" or b == "-":
|
|
455
|
+
continue
|
|
456
|
+
try:
|
|
457
|
+
ins += int(a)
|
|
458
|
+
dele += int(b)
|
|
459
|
+
except ValueError:
|
|
460
|
+
pass
|
|
461
|
+
return ins, dele
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _build_summary(
|
|
465
|
+
files_changed: list[dict],
|
|
466
|
+
insertions: int,
|
|
467
|
+
deletions: int,
|
|
468
|
+
diff_text: str,
|
|
469
|
+
) -> dict:
|
|
470
|
+
truncated = False
|
|
471
|
+
encoded = diff_text.encode("utf-8", errors="replace")
|
|
472
|
+
if len(encoded) > SNAPSHOT_DIFF_MAX_BYTES:
|
|
473
|
+
truncated = True
|
|
474
|
+
diff_text = encoded[:SNAPSHOT_DIFF_MAX_BYTES].decode("utf-8", errors="replace")
|
|
475
|
+
diff_text += f"\n…[{len(encoded) - SNAPSHOT_DIFF_MAX_BYTES} bytes truncated]\n"
|
|
476
|
+
return {
|
|
477
|
+
"files_changed": files_changed,
|
|
478
|
+
"insertions": insertions,
|
|
479
|
+
"deletions": deletions,
|
|
480
|
+
"diff_text": diff_text,
|
|
481
|
+
"truncated": truncated,
|
|
482
|
+
}
|
expluro-0.2.0/topo/snapshot.py
DELETED
|
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
|