Skip to content

Commit 9d9813c

Browse files
committed
fix+feat: bugs, optimizations, new integrations, dashboard keys page
Bug fixes: - loop.py: async function passed to sync run() now raises TypeError with a clear message pointing to arun(); fixes silent hang/crash - loop.py apply_proposal: replace fragile `"proposed" in locals()` guard with an explicit `proposed: str | None = None` initializer before the try block - control_plane.py: 401/403 responses now log at ERROR level and set an _auth_failed flag that blocks all further enqueues, preventing the client from silently dropping all events with a bad key forever - cli.py: replace local naive _apply_patch (no backup, no libcst) with apply_function_patch from _patch.py; add is_git_dirty guard on --apply path so dirty working trees are rejected cleanly - events.py, pytest_plugin.py, integrations/__init__.py: remove stale version-number comments that described unshipped future work - site/app/page.tsx: version badge v0.4.0 -> v0.5.0 Optimizations: - propose.py: cache REPAIR_SYSTEM + SANDBOX_IMPORT_HINT as a module-level constant _REPAIR_SYSTEM_SANDBOX instead of re-concatenating on every build_messages() call New features: - src/self_heal/integrations/openai_agents.py: first-class OpenAI Agents SDK integration (healing_tool decorator, mirrors claude_agent_sdk.py) - site/app/dashboard/keys/page.tsx: API key management page (was linked from runs page empty state but missing) - pyproject.toml: add pytest-cov>=4.0.0 to dev extras; enable --cov flags in pytest addopts with 80% minimum threshold - ci.yml: upload .coverage artifact on Python 3.13 matrix leg
1 parent c046462 commit 9d9813c

12 files changed

Lines changed: 383 additions & 50 deletions

File tree

.github/workflows/ci.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,10 @@ jobs:
3535

3636
- name: Run tests
3737
run: pytest -v
38+
39+
- name: Upload coverage report
40+
if: matrix.python-version == '3.13'
41+
uses: actions/upload-artifact@v4
42+
with:
43+
name: coverage-report
44+
path: .coverage

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ all = [
5656
]
5757
dev = [
5858
"pytest>=8.0.0",
59+
"pytest-cov>=4.0.0",
5960
"ruff>=0.6.0",
6061
"anthropic>=0.40.0",
6162
"cohere>=5.0.0",
@@ -112,4 +113,4 @@ extend-immutable-calls = [
112113
[tool.pytest.ini_options]
113114
testpaths = ["tests"]
114115
python_files = ["test_*.py"]
115-
addopts = "-ra -q"
116+
addopts = "-ra -q --cov=src/self_heal --cov-report=term-missing --cov-fail-under=80"

site/app/dashboard/keys/page.tsx

Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"use client";
2+
3+
import { useState, useEffect, useCallback } from "react";
4+
import { Copy, Trash2, Plus, Eye, EyeOff } from "lucide-react";
5+
6+
type ApiKey = {
7+
id: string;
8+
name: string;
9+
prefix: string;
10+
created_at: string;
11+
last_used_at: string | null;
12+
};
13+
14+
type RevealedKey = {
15+
id: string;
16+
key: string;
17+
};
18+
19+
function fmt(iso: string): string {
20+
return new Date(iso).toLocaleString();
21+
}
22+
23+
function CopyButton({ text }: { text: string }) {
24+
const [copied, setCopied] = useState(false);
25+
const copy = () => {
26+
navigator.clipboard.writeText(text);
27+
setCopied(true);
28+
setTimeout(() => setCopied(false), 1500);
29+
};
30+
return (
31+
<button
32+
onClick={copy}
33+
className="ml-1 inline-flex items-center rounded p-0.5 text-neutral-500 hover:text-neutral-200 transition"
34+
title="Copy"
35+
>
36+
<Copy size={13} />
37+
{copied && <span className="ml-1 text-[10px] text-emerald-400">copied</span>}
38+
</button>
39+
);
40+
}
41+
42+
export default function KeysPage() {
43+
const [keys, setKeys] = useState<ApiKey[]>([]);
44+
const [loading, setLoading] = useState(true);
45+
const [creating, setCreating] = useState(false);
46+
const [newName, setNewName] = useState("");
47+
const [revealed, setRevealed] = useState<RevealedKey | null>(null);
48+
const [error, setError] = useState<string | null>(null);
49+
50+
const fetchKeys = useCallback(async () => {
51+
try {
52+
const res = await fetch("/api/cp/v1/keys", { credentials: "include" });
53+
if (!res.ok) throw new Error(`${res.status}`);
54+
const data = await res.json();
55+
setKeys(data.keys ?? []);
56+
} catch {
57+
setError("Failed to load API keys.");
58+
} finally {
59+
setLoading(false);
60+
}
61+
}, []);
62+
63+
useEffect(() => {
64+
fetchKeys();
65+
}, [fetchKeys]);
66+
67+
const createKey = async () => {
68+
if (!newName.trim()) return;
69+
setCreating(true);
70+
setError(null);
71+
try {
72+
const res = await fetch("/api/cp/v1/keys", {
73+
method: "POST",
74+
credentials: "include",
75+
headers: { "Content-Type": "application/json" },
76+
body: JSON.stringify({ name: newName.trim() }),
77+
});
78+
if (!res.ok) throw new Error(`${res.status}`);
79+
const data = await res.json();
80+
setRevealed({ id: data.id, key: data.key });
81+
setNewName("");
82+
await fetchKeys();
83+
} catch {
84+
setError("Failed to create API key.");
85+
} finally {
86+
setCreating(false);
87+
}
88+
};
89+
90+
const deleteKey = async (id: string) => {
91+
if (!confirm("Revoke this key? This cannot be undone.")) return;
92+
try {
93+
const res = await fetch(`/api/cp/v1/keys/${id}`, {
94+
method: "DELETE",
95+
credentials: "include",
96+
});
97+
if (!res.ok) throw new Error(`${res.status}`);
98+
setKeys((prev) => prev.filter((k) => k.id !== id));
99+
if (revealed?.id === id) setRevealed(null);
100+
} catch {
101+
setError("Failed to revoke key.");
102+
}
103+
};
104+
105+
return (
106+
<div className="max-w-3xl">
107+
<div className="flex items-end justify-between">
108+
<div>
109+
<h1 className="text-2xl font-semibold tracking-tight">API Keys</h1>
110+
<p className="mt-1 text-sm text-neutral-400">
111+
Keys authenticate the{" "}
112+
<span className="font-mono text-neutral-300">ControlPlaneClient</span> in the OSS library.
113+
</p>
114+
</div>
115+
</div>
116+
117+
{/* Create key */}
118+
<div className="mt-8 rounded-xl border border-neutral-900 bg-neutral-950/40 p-5">
119+
<h2 className="text-sm font-medium text-neutral-200">Create a new key</h2>
120+
<div className="mt-3 flex gap-2">
121+
<input
122+
value={newName}
123+
onChange={(e) => setNewName(e.target.value)}
124+
onKeyDown={(e) => e.key === "Enter" && createKey()}
125+
placeholder="Key name (e.g. production)"
126+
className="flex-1 rounded-lg border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm text-neutral-100 placeholder-neutral-600 focus:border-neutral-600 focus:outline-none"
127+
/>
128+
<button
129+
onClick={createKey}
130+
disabled={creating || !newName.trim()}
131+
className="inline-flex items-center gap-1.5 rounded-lg bg-white px-3.5 py-2 text-xs font-medium text-black hover:bg-neutral-200 transition disabled:opacity-40"
132+
>
133+
<Plus size={13} />
134+
{creating ? "Creating…" : "Create"}
135+
</button>
136+
</div>
137+
138+
{/* One-time reveal */}
139+
{revealed && (
140+
<div className="mt-4 rounded-lg border border-emerald-900/60 bg-emerald-950/30 p-4">
141+
<div className="flex items-center justify-between">
142+
<p className="text-xs font-medium text-emerald-400">
143+
Copy this key now — it won&apos;t be shown again.
144+
</p>
145+
<button
146+
onClick={() => setRevealed(null)}
147+
className="text-xs text-neutral-500 hover:text-neutral-300"
148+
>
149+
dismiss
150+
</button>
151+
</div>
152+
<div className="mt-2 flex items-center gap-1 font-mono text-sm text-emerald-300">
153+
<span className="break-all">{revealed.key}</span>
154+
<CopyButton text={revealed.key} />
155+
</div>
156+
</div>
157+
)}
158+
</div>
159+
160+
{error && (
161+
<p className="mt-3 text-xs text-red-400">{error}</p>
162+
)}
163+
164+
{/* Key list */}
165+
<div className="mt-6">
166+
{loading ? (
167+
<div className="py-10 text-center text-sm text-neutral-600">Loading…</div>
168+
) : keys.length === 0 ? (
169+
<div className="rounded-xl border border-neutral-900 bg-neutral-950/40 p-10 text-center">
170+
<div className="text-sm font-medium text-neutral-200">No keys yet</div>
171+
<p className="mt-2 text-xs text-neutral-500">
172+
Create your first key above to start sending events.
173+
</p>
174+
</div>
175+
) : (
176+
<div className="overflow-hidden rounded-xl border border-neutral-900 bg-neutral-950/40">
177+
<table className="w-full text-left text-sm">
178+
<thead className="border-b border-neutral-900 bg-neutral-950/80 text-xs uppercase tracking-wider text-neutral-500">
179+
<tr>
180+
<th className="px-4 py-3 font-medium">Name</th>
181+
<th className="px-4 py-3 font-medium">Prefix</th>
182+
<th className="px-4 py-3 font-medium">Created</th>
183+
<th className="px-4 py-3 font-medium">Last used</th>
184+
<th className="px-4 py-3 font-medium" />
185+
</tr>
186+
</thead>
187+
<tbody>
188+
{keys.map((k) => (
189+
<tr
190+
key={k.id}
191+
className="border-b border-neutral-900 last:border-0 hover:bg-neutral-950 transition"
192+
>
193+
<td className="px-4 py-3 text-neutral-100">{k.name}</td>
194+
<td className="px-4 py-3 font-mono text-xs text-neutral-400">
195+
{k.prefix}
196+
<CopyButton text={k.prefix} />
197+
</td>
198+
<td className="px-4 py-3 text-neutral-400 text-xs">{fmt(k.created_at)}</td>
199+
<td className="px-4 py-3 text-neutral-500 text-xs">
200+
{k.last_used_at ? fmt(k.last_used_at) : "Never"}
201+
</td>
202+
<td className="px-4 py-3 text-right">
203+
<button
204+
onClick={() => deleteKey(k.id)}
205+
className="inline-flex items-center gap-1 rounded p-1 text-neutral-600 hover:text-red-400 transition"
206+
title="Revoke"
207+
>
208+
<Trash2 size={14} />
209+
</button>
210+
</td>
211+
</tr>
212+
))}
213+
</tbody>
214+
</table>
215+
</div>
216+
)}
217+
</div>
218+
</div>
219+
);
220+
}

site/app/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export default function Home() {
1414
<span className="inline-block h-2 w-2 rounded-full bg-emerald-400" />
1515
<span>self-heal</span>
1616
<span className="ml-2 rounded-md border border-neutral-800 px-1.5 py-0.5 text-xs text-neutral-500">
17-
v0.4.0
17+
v0.5.0
1818
</span>
1919
</div>
2020
<nav className="flex items-center gap-6 text-sm text-neutral-400">

src/self_heal/cli.py

Lines changed: 15 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,21 @@ def _cmd_heal(args) -> int:
156156
print(_format_diff(original_source, winning))
157157

158158
if args.apply:
159-
_apply_patch(src_path, fn_name, original_source, winning)
160-
print(f"\nApplied patch to {src_path}.")
159+
from self_heal._patch import PatchError, apply_function_patch, is_git_dirty
160+
161+
if is_git_dirty(src_path):
162+
print(
163+
f"error: {src_path} has uncommitted changes. "
164+
"Commit or stash your changes before using --apply.",
165+
file=sys.stderr,
166+
)
167+
return 1
168+
try:
169+
backup = apply_function_patch(src_path, fn_name, original_source, winning)
170+
except PatchError as exc:
171+
print(f"error: patch failed: {exc}", file=sys.stderr)
172+
return 1
173+
print(f"\nApplied patch to {src_path} (backup: {backup.name}).")
161174

162175
return 0
163176

@@ -274,38 +287,6 @@ def _format_diff(before: str, after: str) -> str:
274287
)
275288

276289

277-
def _apply_patch(
278-
src_path: Path, fn_name: str, original_source: str, repaired_source: str
279-
) -> None:
280-
"""Replace the function definition in the file with the repaired source.
281-
282-
Naive text-based replacement. If the original source appears verbatim in
283-
the file, we swap it for the repaired version. Otherwise we fall back to
284-
appending the repaired function at the end.
285-
"""
286-
text = src_path.read_text(encoding="utf-8")
287-
if original_source in text:
288-
new_text = text.replace(original_source, repaired_source, 1)
289-
else:
290-
# Dedent the original, try again.
291-
dedented = _dedent(original_source)
292-
if dedented and dedented in text:
293-
new_text = text.replace(dedented, _dedent(repaired_source), 1)
294-
else:
295-
new_text = text.rstrip() + "\n\n\n# --- self-heal repaired ---\n" + repaired_source + "\n"
296-
print(
297-
f"warning: could not locate original {fn_name} in {src_path}; "
298-
"appended repaired version at the end.",
299-
file=sys.stderr,
300-
)
301-
src_path.write_text(new_text, encoding="utf-8")
302-
303-
304-
def _dedent(s: str) -> str:
305-
import textwrap
306-
307-
return textwrap.dedent(s)
308-
309290

310291
if __name__ == "__main__":
311292
raise SystemExit(main())

src/self_heal/control_plane.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ def __init__(
128128
self._lock = threading.Lock()
129129
self._wake = threading.Event()
130130
self._stop = threading.Event()
131+
self._auth_failed = threading.Event()
131132

132133
self._client = httpx.Client(timeout=timeout)
133134
self._flusher = threading.Thread(
@@ -186,6 +187,8 @@ def close(self) -> None:
186187
# -- internals -------------------------------------------------------
187188

188189
def _enqueue(self, wire: dict[str, Any]) -> None:
190+
if self._auth_failed.is_set():
191+
return
189192
with self._lock:
190193
if len(self._buffer) >= self._max_buffer:
191194
# drop oldest to bound memory under sustained outage
@@ -229,14 +232,23 @@ def _post_with_retry(self, batch: list[dict[str, Any]]) -> bool:
229232
resp = self._client.post(self._endpoint, headers=headers, json=payload)
230233
if 200 <= resp.status_code < 300:
231234
return True
232-
if resp.status_code in (400, 401, 403, 422):
233-
# Non-retryable client error.
235+
if resp.status_code in (401, 403):
236+
_log.error(
237+
"control plane authentication failed (%s): %s — "
238+
"all further events will be dropped until the client is "
239+
"recreated with a valid API key",
240+
resp.status_code,
241+
resp.text[:200],
242+
)
243+
self._auth_failed.set()
244+
return True # drop this batch; future enqueues are blocked
245+
if resp.status_code in (400, 422):
234246
_log.warning(
235247
"control plane rejected batch (%s): %s",
236248
resp.status_code,
237249
resp.text[:200],
238250
)
239-
return True # drop and move on
251+
return True # drop malformed batch, move on
240252
_log.warning(
241253
"control plane %s on attempt %d", resp.status_code, attempt + 1
242254
)

src/self_heal/events.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
receives a `RepairEvent` on every significant step. Agent UIs can stream
55
progress; observability backends can record metrics.
66
7-
Streaming (token-level) is deferred to v0.3. For now, events are
8-
discrete: attempt start, failure, propose start/complete, install,
9-
verify, success/failure.
7+
Token-level streaming is live: proposers that implement `propose_stream`
8+
or `apropose_stream` emit `propose_chunk` events for each delta. Falls
9+
back to discrete events if streaming is unavailable or raises.
1010
"""
1111

1212
from __future__ import annotations

src/self_heal/integrations/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@
44
`import self_heal`.
55
66
Available integrations:
7-
- `self_heal.integrations.claude_agent_sdk` — healing_tool decorator
8-
combining `@tool` (Claude Agent SDK) with `@repair` (self-heal).
7+
- `self_heal.integrations.claude_agent_sdk` — healing_tool for Claude Agent SDK
8+
- `self_heal.integrations.langgraph` — healing_tool for LangChain / LangGraph
9+
- `self_heal.integrations.openai_agents` — healing_tool for OpenAI Agents SDK
910
10-
More integrations (CrewAI, LangGraph) are roadmap items; see the
11-
`examples/` directory for the decorator-stacking pattern today.
11+
See `examples/` for CrewAI and other decorator-stacking patterns.
1212
"""

0 commit comments

Comments
 (0)