autodebug 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- autodebug/__init__.py +3 -0
- autodebug/__main__.py +11 -0
- autodebug/cli.py +432 -0
- autodebug/connection.py +219 -0
- autodebug/extension/README.md +16 -0
- autodebug/extension/extension.js +40 -0
- autodebug/extension/mcp.js +98 -0
- autodebug/extension/package.json +28 -0
- autodebug/extension/relay.js +570 -0
- autodebug/hub.py +267 -0
- autodebug/install.py +346 -0
- autodebug/mcp_server.py +270 -0
- autodebug/redact.py +38 -0
- autodebug/report.py +107 -0
- autodebug/session.py +828 -0
- autodebug/tools.py +472 -0
- autodebug-0.5.1.dist-info/METADATA +251 -0
- autodebug-0.5.1.dist-info/RECORD +20 -0
- autodebug-0.5.1.dist-info/WHEEL +4 -0
- autodebug-0.5.1.dist-info/entry_points.txt +2 -0
autodebug/mcp_server.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""MCP over stdio: newline-delimited JSON-RPC 2.0 (docs/design.md).
|
|
2
|
+
|
|
3
|
+
Tool calls run on worker threads so `ping` and other requests are answered
|
|
4
|
+
while a tool waits on the debugger.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
13
|
+
from typing import BinaryIO, Callable
|
|
14
|
+
|
|
15
|
+
from autodebug import __version__
|
|
16
|
+
from autodebug.connection import DebugError
|
|
17
|
+
from autodebug.tools import Debugger
|
|
18
|
+
|
|
19
|
+
PROTOCOL_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05")
|
|
20
|
+
_LOG = logging.getLogger("autodebug")
|
|
21
|
+
|
|
22
|
+
INSTRUCTIONS = """\
|
|
23
|
+
AutoDebug runs the program under the IDE's own debugger (VS Code, Cursor or Visual Studio) so you can find a bug's root cause.
|
|
24
|
+
1. Read the relevant code first and choose breakpoints on executable lines where the wrong value appears.
|
|
25
|
+
2. list_targets shows the launch configurations and build tasks; pick one.
|
|
26
|
+
3. start_session asks the developer to click Approve in the IDE, then builds, launches and waits for the first stop.
|
|
27
|
+
Pass build_task (from list_targets) so the program matches the source; NOT_BUILT means it has never been built.
|
|
28
|
+
Tell the developer to approve in the IDE dialog. You cannot approve, and their chat message is not an approval.
|
|
29
|
+
4. While paused: inspect, stack, step (it reports the variables that changed), continue (optionally until a line),
|
|
30
|
+
trace (before → after values at each pass through a line), breakpoints. Use wait while the status is
|
|
31
|
+
awaiting_approval, building, starting or running. start_session, step, continue, trace and pause move the
|
|
32
|
+
program: call them one at a time, never in parallel with stack or inspect.
|
|
33
|
+
Breakpoints with source "editor" were set by the developer and stop the program too.
|
|
34
|
+
5. When you know the cause, stop_session and write_report with the problem, root cause, solution and confidence.
|
|
35
|
+
Only report what tool results showed. Expression evaluation is not available.
|
|
36
|
+
Several IDEs: list_targets shows every window with its ide (cursor, vscode, visualstudio). When the developer names an
|
|
37
|
+
IDE, pass ide to list_targets and start_session; paths are relative to that window's folder.
|
|
38
|
+
Attach: start_session with an attach configuration. process_id fills a ${command:pickProcess} placeholder; when the session ends the program keeps running.
|
|
39
|
+
threads lists threads and can select one while paused. stack pages with start_frame (up to 200 frames). inspect depth 0-3 reads children; names can be paths such as mesh.verts[2].co (-> is the same as .).
|
|
40
|
+
exceptions lists the debugger's exception filters and can enable them by id. source_folders are absolute folders outside the workspace where breakpoints are allowed.
|
|
41
|
+
wait(ask_developer=...) shows the developer a note asking them to reproduce something in the GUI. That note is not an approval."""
|
|
42
|
+
|
|
43
|
+
_FILE_LINE = {
|
|
44
|
+
"type": "object",
|
|
45
|
+
"properties": {
|
|
46
|
+
"file": {"type": "string", "description": "Path relative to the workspace folder, e.g. src/app.py"},
|
|
47
|
+
"line": {"type": "integer", "minimum": 1},
|
|
48
|
+
"condition": {"type": "string", "description": "Optional condition, in the program's language"},
|
|
49
|
+
},
|
|
50
|
+
"required": ["file", "line"],
|
|
51
|
+
"additionalProperties": False,
|
|
52
|
+
}
|
|
53
|
+
_FOLDER = {"type": "string", "description": "Absolute workspace folder; needed only when several IDE windows are open"}
|
|
54
|
+
_IDE = {"type": "string", "enum": ["cursor", "vscode", "visualstudio"],
|
|
55
|
+
"description": "Which IDE's window to use, when windows of several IDEs are open"}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _schema(properties: dict, required: tuple[str, ...] = ()) -> dict:
|
|
59
|
+
return {"type": "object", "properties": properties, "required": list(required), "additionalProperties": False}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
TOOLS = {
|
|
63
|
+
"list_targets": ("List IDE windows running AutoDebug, with their launch configurations and build tasks.",
|
|
64
|
+
_schema({"workspace_folder": _FOLDER, "ide": _IDE})),
|
|
65
|
+
"start_session": (
|
|
66
|
+
"Resumes the program (call alone). Ask the developer to approve in the IDE, then build (optional), set "
|
|
67
|
+
"breakpoints, launch the configuration under the debugger and wait for the first stop. Returns the status, "
|
|
68
|
+
"location, local variables and every breakpoint (session or editor).",
|
|
69
|
+
_schema({
|
|
70
|
+
"launch_configuration": {"type": "string", "description": "A launch configuration name from list_targets"},
|
|
71
|
+
"breakpoints": {"type": "array", "items": _FILE_LINE, "maxItems": 50},
|
|
72
|
+
"goal": {"type": "string", "description": "One sentence: what you are trying to find out"},
|
|
73
|
+
"workspace_folder": _FOLDER,
|
|
74
|
+
"ide": _IDE,
|
|
75
|
+
"build_task": {"type": "string", "description": "A build task label from list_targets to run first"},
|
|
76
|
+
"max_steps": {"type": "integer", "minimum": 1, "maximum": 1000, "default": 100},
|
|
77
|
+
"max_minutes": {"type": "integer", "minimum": 1, "maximum": 480, "default": 30},
|
|
78
|
+
"build_minutes": {"type": "integer", "minimum": 1, "maximum": 600, "default": 30},
|
|
79
|
+
"wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 30},
|
|
80
|
+
"process_id": {"type": "integer", "minimum": 1, "description": "PID to attach to; replaces a ${command:…} processId or CodeLLDB pid"},
|
|
81
|
+
"source_folders": {"type": "array", "items": {"type": "string"}, "maxItems": 10,
|
|
82
|
+
"description": "Absolute folders outside the workspace where breakpoints are allowed"},
|
|
83
|
+
"function_breakpoints": {"type": "array", "items": {"type": "string"}, "maxItems": 50},
|
|
84
|
+
}, ("launch_configuration", "breakpoints", "goal"))),
|
|
85
|
+
"wait": ("Wait until the session stops, ends or is answered (approval, build). ask_developer shows an information message; it is not an approval.",
|
|
86
|
+
_schema({"timeout_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 30},
|
|
87
|
+
"ask_developer": {"type": "string", "maxLength": 300}})),
|
|
88
|
+
"step": ("Resumes the program (call alone). Step over, into or out of the current line, 1-20 times. Returns the "
|
|
89
|
+
"new location and every variable whose value changed. wait_ms is how long to wait for the step (default 15000, max 60000).",
|
|
90
|
+
_schema({"kind": {"type": "string", "enum": ["over", "into", "out"], "default": "over"},
|
|
91
|
+
"count": {"type": "integer", "minimum": 1, "maximum": 20, "default": 1},
|
|
92
|
+
"wait_ms": {"type": "integer", "minimum": 0, "maximum": 60000, "default": 15000}})),
|
|
93
|
+
"continue": ("Resumes the program (call alone). Run until the next breakpoint or the end and return where it "
|
|
94
|
+
"stopped. With until, run past other breakpoints to that line.",
|
|
95
|
+
_schema({"timeout_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 30},
|
|
96
|
+
"until": _FILE_LINE})),
|
|
97
|
+
"pause": ("Pause a running program where it is (for hangs and long loops).", _schema({})),
|
|
98
|
+
"inspect": ("Variables of a frame. Without names: every variable with a short value. With names: full values. depth 0-3 expands children. Names may be paths like mesh.verts[2].co.",
|
|
99
|
+
_schema({"names": {"type": "array", "items": {"type": "string"}, "maxItems": 50},
|
|
100
|
+
"frame_index": {"type": "integer", "minimum": 0, "default": 0,
|
|
101
|
+
"description": "0 is the current frame; see stack"},
|
|
102
|
+
"depth": {"type": "integer", "minimum": 0, "maximum": 3, "default": 0}})),
|
|
103
|
+
"stack": ("The call stack, current frame first. start_frame pages deeper frames. levels is at most 200.",
|
|
104
|
+
_schema({"levels": {"type": "integer", "minimum": 1, "maximum": 200, "default": 20},
|
|
105
|
+
"start_frame": {"type": "integer", "minimum": 0, "default": 0}})),
|
|
106
|
+
"threads": ("List threads. select switches the paused thread used by stack, inspect, step and continue.",
|
|
107
|
+
_schema({"select": {"type": "integer"}})),
|
|
108
|
+
"exceptions": ("List exception breakpoint filters. enable sets them by id.",
|
|
109
|
+
_schema({"enable": {"type": "array", "items": {"type": "string"}, "maxItems": 20}})),
|
|
110
|
+
"breakpoints": ("Add or remove breakpoints during the session; returns all of them and whether they are bound.",
|
|
111
|
+
_schema({"add": {"type": "array", "items": _FILE_LINE, "maxItems": 20},
|
|
112
|
+
"remove": {"type": "array", "items": _FILE_LINE, "maxItems": 20},
|
|
113
|
+
"add_functions": {"type": "array", "items": {"type": "string"}, "maxItems": 20},
|
|
114
|
+
"remove_functions": {"type": "array", "items": {"type": "string"}, "maxItems": 20}})),
|
|
115
|
+
"trace": ("Resumes the program (call alone). Run to a line repeatedly; each row has the caller and every named "
|
|
116
|
+
"variable as 'before → after' the line runs (one step per hit). Stops early at other breakpoints.",
|
|
117
|
+
_schema({"file": {"type": "string"}, "line": {"type": "integer", "minimum": 1},
|
|
118
|
+
"variables": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 20},
|
|
119
|
+
"hits": {"type": "integer", "minimum": 1, "maximum": 50, "default": 10},
|
|
120
|
+
"after": {"type": "boolean", "default": True,
|
|
121
|
+
"description": "Also step over the line to show the values it produces"},
|
|
122
|
+
"timeout_seconds": {"type": "number", "minimum": 1, "maximum": 55, "default": 20}},
|
|
123
|
+
("file", "line", "variables"))),
|
|
124
|
+
"stop_session": ("End the debug session and remove its breakpoints.", _schema({})),
|
|
125
|
+
"write_report": (
|
|
126
|
+
"Write the root-cause report into <workspace>/autodebug-reports/. The session timeline is added automatically.",
|
|
127
|
+
_schema({
|
|
128
|
+
"title": {"type": "string"},
|
|
129
|
+
"problem": {"type": "string", "description": "What goes wrong, as the user sees it"},
|
|
130
|
+
"root_cause": {"type": "string", "description": "Why, citing what the debugger showed"},
|
|
131
|
+
"solution": {"type": "string", "description": "The fix, with code if short"},
|
|
132
|
+
"confidence": {"type": "string", "enum": ["confirmed", "likely", "unconfirmed"]},
|
|
133
|
+
"how_confirmed": {"type": "string", "description": "Required for confirmed"},
|
|
134
|
+
"verification": {"type": "string", "description": "How the fix was checked, if it was"},
|
|
135
|
+
"open_questions": {"type": "array", "items": {"type": "string"}},
|
|
136
|
+
"workspace_folder": _FOLDER,
|
|
137
|
+
}, ("title", "problem", "root_cause", "solution", "confidence"))),
|
|
138
|
+
}
|
|
139
|
+
_METHODS = {"continue": "continue_"}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _valid(value: object, schema: dict) -> bool:
|
|
143
|
+
kind = schema.get("type")
|
|
144
|
+
if kind == "object":
|
|
145
|
+
if not isinstance(value, dict) or (schema.get("additionalProperties") is False
|
|
146
|
+
and value.keys() - schema.get("properties", {}).keys()):
|
|
147
|
+
return False
|
|
148
|
+
if any(key not in value for key in schema.get("required", ())):
|
|
149
|
+
return False
|
|
150
|
+
return all(_valid(v, schema["properties"][k]) for k, v in value.items() if k in schema.get("properties", {}))
|
|
151
|
+
if kind == "array":
|
|
152
|
+
return (isinstance(value, list) and len(value) >= schema.get("minItems", 0)
|
|
153
|
+
and len(value) <= schema.get("maxItems", 10**6) and all(_valid(v, schema["items"]) for v in value))
|
|
154
|
+
if kind == "string":
|
|
155
|
+
return isinstance(value, str) and len(value) <= 20000 and value in schema.get("enum", [value])
|
|
156
|
+
if kind == "boolean":
|
|
157
|
+
return isinstance(value, bool)
|
|
158
|
+
if kind == "integer":
|
|
159
|
+
return (isinstance(value, int) and not isinstance(value, bool)
|
|
160
|
+
and schema.get("minimum", -10**9) <= value <= schema.get("maximum", 10**9))
|
|
161
|
+
if kind == "number":
|
|
162
|
+
return (isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
163
|
+
and schema.get("minimum", -10**9) <= value <= schema.get("maximum", 10**9))
|
|
164
|
+
return True
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _terminal_note() -> str:
|
|
168
|
+
from autodebug.cli import run_command
|
|
169
|
+
|
|
170
|
+
return (f"\nIf these tools disappear from your tool list, keep going in the terminal with the same session: "
|
|
171
|
+
f"`{run_command()} run <tool> key=value ...` (`run help` lists them).")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def call_tool(debugger: Debugger, name: str, arguments: dict) -> tuple[dict, bool]:
|
|
175
|
+
"""Run one tool on `debugger`: (result, is_error). Arguments must already be valid for TOOLS[name]."""
|
|
176
|
+
try:
|
|
177
|
+
return getattr(debugger, _METHODS.get(name, name))(**arguments), False
|
|
178
|
+
except DebugError as exc:
|
|
179
|
+
return {"error": exc.code, "message": exc.message}, True
|
|
180
|
+
except (OSError, ValueError) as exc:
|
|
181
|
+
return {"error": "IDE_ERROR", "message": str(exc)}, True
|
|
182
|
+
except Exception: # noqa: BLE001 — never crash on one tool
|
|
183
|
+
_LOG.exception("tool %s failed", name)
|
|
184
|
+
return {"error": "IDE_ERROR", "message": "internal error; see the server log"}, True
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
Runner = Callable[[str, dict], "tuple[dict, bool]"]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class Server:
|
|
191
|
+
def __init__(self, debugger: Debugger | None = None, stdin: BinaryIO | None = None,
|
|
192
|
+
stdout: BinaryIO | None = None, runner: Runner | None = None) -> None:
|
|
193
|
+
"""Tools run on `runner` (the shared AutoDebug service) or, without one, on `debugger` in this process."""
|
|
194
|
+
self.debugger = None if runner else (debugger or Debugger())
|
|
195
|
+
self._tool_lock = threading.Lock() # one debugger action at a time
|
|
196
|
+
self.runner = runner or self._local
|
|
197
|
+
self.stdin = stdin or sys.stdin.buffer
|
|
198
|
+
self.stdout = stdout or sys.stdout.buffer
|
|
199
|
+
self._write_lock = threading.Lock()
|
|
200
|
+
self._threads: list[threading.Thread] = []
|
|
201
|
+
|
|
202
|
+
def _local(self, name: str, arguments: dict) -> tuple[dict, bool]:
|
|
203
|
+
with self._tool_lock:
|
|
204
|
+
return call_tool(self.debugger, name, arguments)
|
|
205
|
+
|
|
206
|
+
def _send(self, message: dict) -> None:
|
|
207
|
+
data = json.dumps(message, ensure_ascii=False).encode("utf-8") + b"\n"
|
|
208
|
+
with self._write_lock:
|
|
209
|
+
try:
|
|
210
|
+
self.stdout.write(data)
|
|
211
|
+
self.stdout.flush()
|
|
212
|
+
except (OSError, ValueError):
|
|
213
|
+
pass
|
|
214
|
+
|
|
215
|
+
def _reply(self, request_id, result=None, error=None) -> None:
|
|
216
|
+
message = {"jsonrpc": "2.0", "id": request_id}
|
|
217
|
+
message.update({"error": error} if error else {"result": result})
|
|
218
|
+
self._send(message)
|
|
219
|
+
|
|
220
|
+
def serve(self) -> None:
|
|
221
|
+
for line in self.stdin:
|
|
222
|
+
if not line.strip():
|
|
223
|
+
continue
|
|
224
|
+
try:
|
|
225
|
+
message = json.loads(line)
|
|
226
|
+
except ValueError:
|
|
227
|
+
self._reply(None, error={"code": -32700, "message": "parse error"})
|
|
228
|
+
continue
|
|
229
|
+
if isinstance(message, dict):
|
|
230
|
+
self.handle(message)
|
|
231
|
+
for thread in self._threads:
|
|
232
|
+
thread.join(timeout=5)
|
|
233
|
+
if self.debugger is not None:
|
|
234
|
+
self.debugger.close()
|
|
235
|
+
|
|
236
|
+
def handle(self, message: dict) -> None:
|
|
237
|
+
method, request_id, params = message.get("method"), message.get("id"), message.get("params") or {}
|
|
238
|
+
if request_id is None:
|
|
239
|
+
return # notifications (initialized, cancelled) need no answer
|
|
240
|
+
if method == "initialize":
|
|
241
|
+
asked = params.get("protocolVersion")
|
|
242
|
+
self._reply(request_id, {
|
|
243
|
+
"protocolVersion": asked if asked in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0],
|
|
244
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
245
|
+
"serverInfo": {"name": "autodebug", "version": __version__},
|
|
246
|
+
"instructions": INSTRUCTIONS + _terminal_note(),
|
|
247
|
+
})
|
|
248
|
+
elif method == "ping":
|
|
249
|
+
self._reply(request_id, {})
|
|
250
|
+
elif method == "tools/list":
|
|
251
|
+
self._reply(request_id, {"tools": [{"name": n, "description": d, "inputSchema": s}
|
|
252
|
+
for n, (d, s) in TOOLS.items()]})
|
|
253
|
+
elif method == "tools/call":
|
|
254
|
+
thread = threading.Thread(target=self._call_tool, args=(request_id, params), daemon=True)
|
|
255
|
+
self._threads = [t for t in self._threads if t.is_alive()] + [thread]
|
|
256
|
+
thread.start()
|
|
257
|
+
else:
|
|
258
|
+
self._reply(request_id, error={"code": -32601, "message": f"unknown method {method}"})
|
|
259
|
+
|
|
260
|
+
def _call_tool(self, request_id, params: dict) -> None:
|
|
261
|
+
name, arguments = params.get("name"), params.get("arguments") or {}
|
|
262
|
+
if name not in TOOLS:
|
|
263
|
+
self._reply(request_id, error={"code": -32602, "message": f"unknown tool {name}"})
|
|
264
|
+
return
|
|
265
|
+
if not _valid(arguments, TOOLS[name][1]):
|
|
266
|
+
self._reply(request_id, error={"code": -32602, "message": f"invalid arguments for {name}"})
|
|
267
|
+
return
|
|
268
|
+
result, is_error = self.runner(name, arguments)
|
|
269
|
+
self._reply(request_id, {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=1)}],
|
|
270
|
+
"structuredContent": result, "isError": is_error})
|
autodebug/redact.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Hide secret-looking values before an agent or a report sees them."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
REDACTED = "***REDACTED***"
|
|
8
|
+
MAX_VALUE = 2000
|
|
9
|
+
|
|
10
|
+
_SECRET_NAME = re.compile(
|
|
11
|
+
r"pass(word|wd|phrase)?|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credential|auth(?!or)|cookie|session[_-]?id",
|
|
12
|
+
re.IGNORECASE,
|
|
13
|
+
)
|
|
14
|
+
_SECRET_VALUE = re.compile(
|
|
15
|
+
r"AKIA[0-9A-Z]{16}" # AWS access key ID
|
|
16
|
+
r"|(?:sk|rk|pk)-[A-Za-z0-9_-]{20,}" # API keys with sk-/rk-/pk- prefixes
|
|
17
|
+
r"|gh[pousr]_[A-Za-z0-9]{36,}" # GitHub tokens
|
|
18
|
+
r"|xox[abpr]-[A-Za-z0-9-]{10,}" # Slack tokens
|
|
19
|
+
r"|eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}" # JWT
|
|
20
|
+
r"|-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def is_secret_name(name: str) -> bool:
|
|
25
|
+
return bool(_SECRET_NAME.search(name))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def clean_text(text: object, limit: int = MAX_VALUE) -> str:
|
|
29
|
+
"""Mask secret-shaped substrings and cap the length."""
|
|
30
|
+
value = _SECRET_VALUE.sub(REDACTED, str(text))
|
|
31
|
+
return value if len(value) <= limit else value[:limit] + f"… ({len(value) - limit} more characters)"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def clean_value(name: str, value: object, limit: int = MAX_VALUE) -> str:
|
|
35
|
+
"""A variable's value as the agent may see it."""
|
|
36
|
+
if is_secret_name(name):
|
|
37
|
+
return REDACTED
|
|
38
|
+
return clean_text(value, limit)
|
autodebug/report.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""The root-cause report written into the project (docs/design.md, "Report")."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from autodebug.connection import IDE_NAMES
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from autodebug.redact import clean_text
|
|
14
|
+
|
|
15
|
+
FOLDER = "autodebug-reports"
|
|
16
|
+
_CONFIDENCE = {"confirmed": "Confirmed", "likely": "Likely", "unconfirmed": "Unconfirmed"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Report:
|
|
21
|
+
title: str
|
|
22
|
+
problem: str
|
|
23
|
+
root_cause: str
|
|
24
|
+
solution: str
|
|
25
|
+
confidence: str
|
|
26
|
+
how_confirmed: str | None = None
|
|
27
|
+
verification: str | None = None
|
|
28
|
+
open_questions: list[str] = field(default_factory=list)
|
|
29
|
+
plan: dict = field(default_factory=dict)
|
|
30
|
+
timeline: list = field(default_factory=list)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _cell(text: object) -> str:
|
|
34
|
+
return " ".join(clean_text(text, 400).split()).replace("|", "\\|") or "—"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _prose(text: object) -> str:
|
|
38
|
+
return clean_text(text, 8000).strip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def render(report: Report, now: float | None = None) -> str:
|
|
42
|
+
stamp = time.strftime("%Y-%m-%d %H:%M", time.localtime(now or time.time()))
|
|
43
|
+
plan = report.plan
|
|
44
|
+
ide = IDE_NAMES.get(plan.get("ide"), "IDE")
|
|
45
|
+
lines = [
|
|
46
|
+
f"# {_prose(report.title)}",
|
|
47
|
+
"",
|
|
48
|
+
"| | |",
|
|
49
|
+
"|---|---|",
|
|
50
|
+
f"| Date | {stamp} |",
|
|
51
|
+
f"| Root cause | **{_CONFIDENCE[report.confidence]}** |",
|
|
52
|
+
f"| Debugged with | {ide}, launch configuration `{_cell(plan.get('launch'))}` |" if plan else "| Debugged with | — |",
|
|
53
|
+
"",
|
|
54
|
+
"## Problem",
|
|
55
|
+
"",
|
|
56
|
+
_prose(report.problem),
|
|
57
|
+
"",
|
|
58
|
+
"## How it was debugged",
|
|
59
|
+
"",
|
|
60
|
+
]
|
|
61
|
+
if plan:
|
|
62
|
+
lines += [f"- Goal: {_cell(plan.get('goal'))}",
|
|
63
|
+
f"- Build task: `{_cell(plan['task'])}`" if plan.get("task") else "- Build task: none",
|
|
64
|
+
"- Breakpoints: " + (", ".join(f"`{_cell(b)}`" for b in plan.get("breakpoints", [])) or "none")]
|
|
65
|
+
else:
|
|
66
|
+
lines.append("- No debug session was recorded.")
|
|
67
|
+
lines += ["", "### Timeline", ""]
|
|
68
|
+
if report.timeline:
|
|
69
|
+
lines += ["| # | What | Where | Details |", "|---|---|---|---|"]
|
|
70
|
+
lines += [f"| {n} | {_cell(e.kind)} | {_cell(e.where)} | {_cell(e.detail)} |"
|
|
71
|
+
for n, e in enumerate(report.timeline, 1)]
|
|
72
|
+
else:
|
|
73
|
+
lines.append("_Nothing was recorded._")
|
|
74
|
+
lines += ["", "## Root cause", "", _prose(report.root_cause)]
|
|
75
|
+
if report.how_confirmed:
|
|
76
|
+
lines += ["", f"**How it was confirmed:** {_prose(report.how_confirmed)}"]
|
|
77
|
+
lines += ["", "## Solution", "", _prose(report.solution), "", "## Verification", ""]
|
|
78
|
+
lines += ([_prose(report.verification), "", "_Reported by the AI agent._"] if report.verification
|
|
79
|
+
else ["_Not verified yet._"])
|
|
80
|
+
if report.open_questions:
|
|
81
|
+
lines += ["", "## Open questions", ""] + [f"- {_cell(q)}" for q in report.open_questions]
|
|
82
|
+
lines += ["", "---", "", "_Written by AutoDebug. Secret-looking values are shown as `***REDACTED***`._", ""]
|
|
83
|
+
return "\n".join(lines)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def write(workspace_folder: str, report: Report) -> Path:
|
|
87
|
+
"""Write the report under `<workspace>/autodebug-reports/`, never overwriting."""
|
|
88
|
+
workspace = Path(workspace_folder)
|
|
89
|
+
if not workspace.is_dir():
|
|
90
|
+
raise ValueError(f"workspace folder not found: {workspace}")
|
|
91
|
+
folder = workspace / FOLDER
|
|
92
|
+
if folder.is_symlink():
|
|
93
|
+
raise ValueError(f"{folder} is a link; refusing to write there")
|
|
94
|
+
folder.mkdir(exist_ok=True)
|
|
95
|
+
slug = re.sub(r"[^a-z0-9]+", "-", report.title.lower()).strip("-")[:60] or "report"
|
|
96
|
+
base = f"{time.strftime('%Y%m%d-%H%M')}-{slug}"
|
|
97
|
+
text = render(report).encode("utf-8")
|
|
98
|
+
for number in range(1, 100):
|
|
99
|
+
path = folder / (f"{base}.md" if number == 1 else f"{base}-{number}.md")
|
|
100
|
+
try:
|
|
101
|
+
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o644)
|
|
102
|
+
except FileExistsError:
|
|
103
|
+
continue
|
|
104
|
+
with os.fdopen(fd, "wb") as handle:
|
|
105
|
+
handle.write(text)
|
|
106
|
+
return path
|
|
107
|
+
raise ValueError("too many reports with this title in one minute")
|