krnl-code 1.0.4__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.
Files changed (56) hide show
  1. krnl_agent/__init__.py +9 -0
  2. krnl_agent/__main__.py +7 -0
  3. krnl_agent/agent_registry.py +95 -0
  4. krnl_agent/agent_selector.py +69 -0
  5. krnl_agent/audit_log.py +155 -0
  6. krnl_agent/background.py +94 -0
  7. krnl_agent/checkpoints.py +67 -0
  8. krnl_agent/ci.py +73 -0
  9. krnl_agent/cli.py +1458 -0
  10. krnl_agent/commands.py +42 -0
  11. krnl_agent/config.py +425 -0
  12. krnl_agent/context.py +352 -0
  13. krnl_agent/depaudit.py +63 -0
  14. krnl_agent/deploy.py +245 -0
  15. krnl_agent/doctor.py +106 -0
  16. krnl_agent/events.py +141 -0
  17. krnl_agent/gitignore.py +47 -0
  18. krnl_agent/graph.py +928 -0
  19. krnl_agent/guardrails.py +70 -0
  20. krnl_agent/headless.py +60 -0
  21. krnl_agent/history.py +49 -0
  22. krnl_agent/hooks.py +72 -0
  23. krnl_agent/ingest.py +129 -0
  24. krnl_agent/llm.py +456 -0
  25. krnl_agent/loop.py +779 -0
  26. krnl_agent/mcp_client.py +128 -0
  27. krnl_agent/memory.py +61 -0
  28. krnl_agent/modelrouter.py +151 -0
  29. krnl_agent/monitor.py +112 -0
  30. krnl_agent/notify.py +119 -0
  31. krnl_agent/parallel_executor.py +139 -0
  32. krnl_agent/permissions.py +128 -0
  33. krnl_agent/plugins.py +105 -0
  34. krnl_agent/pricing.py +85 -0
  35. krnl_agent/prompts.py +60 -0
  36. krnl_agent/repomap.py +133 -0
  37. krnl_agent/sandbox.py +69 -0
  38. krnl_agent/scaffold.py +167 -0
  39. krnl_agent/schedules.py +137 -0
  40. krnl_agent/secrets.py +100 -0
  41. krnl_agent/selfheal.py +87 -0
  42. krnl_agent/server.py +302 -0
  43. krnl_agent/sessions.py +258 -0
  44. krnl_agent/settings.py +59 -0
  45. krnl_agent/skills.py +73 -0
  46. krnl_agent/teams.py +38 -0
  47. krnl_agent/tool_schemas.py +431 -0
  48. krnl_agent/tools.py +694 -0
  49. krnl_agent/webtools.py +139 -0
  50. krnl_code-1.0.4.dist-info/METADATA +214 -0
  51. krnl_code-1.0.4.dist-info/RECORD +56 -0
  52. krnl_code-1.0.4.dist-info/WHEEL +5 -0
  53. krnl_code-1.0.4.dist-info/entry_points.txt +2 -0
  54. krnl_code-1.0.4.dist-info/licenses/LICENSE +147 -0
  55. krnl_code-1.0.4.dist-info/licenses/NOTICE +4 -0
  56. krnl_code-1.0.4.dist-info/top_level.txt +1 -0
krnl_agent/sandbox.py ADDED
@@ -0,0 +1,69 @@
1
+ """Per-tool command sandbox + network egress policy.
2
+
3
+ A deny-by-default safety layer for `run_command`, evaluated *before* a command
4
+ runs (and before the approval prompt). Configured under `sandbox:` in config.yaml:
5
+
6
+ sandbox:
7
+ block_network: true # block curl/wget/ssh/etc. and URLs
8
+ deny_commands: ["rm -rf /", "git push"] # regex, always denied
9
+ allow_commands: ["pytest", "npm (run )?test"] # if set, ONLY these allowed
10
+
11
+ This keeps autonomous / dangerous-mode runs contained: even with every approval
12
+ auto-granted, a denied command never executes.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import re
17
+
18
+ # Commands/binaries that perform network egress.
19
+ _NET = re.compile(
20
+ r"(?i)(?:^|[\s;|&])(curl|wget|nc|ncat|netcat|telnet|ssh|scp|sftp|ftp|rsync|"
21
+ r"http|https)\b|https?://|ftp://")
22
+
23
+ # Always-dangerous shapes blocked when block_network or any policy is active is
24
+ # NOT assumed; these are only used by the built-in 'strict' helper below.
25
+ _DESTRUCTIVE = [
26
+ r"rm\s+-rf\s+/(?:\s|$)",
27
+ r":\(\)\s*\{.*\};:", # fork bomb
28
+ r"mkfs\b", r"dd\s+if=.*of=/dev/",
29
+ r">\s*/dev/sd[a-z]",
30
+ ]
31
+
32
+
33
+ def check_command(sandbox: dict, command: str) -> tuple[bool, str]:
34
+ """Return (allowed, reason). Empty/whitespace commands are allowed (no-op)."""
35
+ if not sandbox:
36
+ return True, ""
37
+ cmd = (command or "").strip()
38
+ if not cmd:
39
+ return True, ""
40
+
41
+ for pat in sandbox.get("deny_commands", []) or []:
42
+ try:
43
+ if re.search(pat, cmd):
44
+ return False, f"blocked by sandbox.deny_commands rule: /{pat}/"
45
+ except re.error:
46
+ if pat in cmd:
47
+ return False, f"blocked by sandbox.deny_commands rule: {pat!r}"
48
+
49
+ if sandbox.get("block_network") and _NET.search(cmd):
50
+ return False, "network egress blocked by sandbox.block_network"
51
+
52
+ allow = sandbox.get("allow_commands") or []
53
+ if allow:
54
+ for pat in allow:
55
+ try:
56
+ if re.search(pat, cmd):
57
+ return True, ""
58
+ except re.error:
59
+ if pat in cmd:
60
+ return True, ""
61
+ return False, "not in sandbox.allow_commands allowlist (deny-by-default)"
62
+
63
+ return True, ""
64
+
65
+
66
+ def is_destructive(command: str) -> bool:
67
+ """True for catastrophic shapes (used by `doctor`/strict presets)."""
68
+ cmd = command or ""
69
+ return any(re.search(p, cmd) for p in _DESTRUCTIVE)
krnl_agent/scaffold.py ADDED
@@ -0,0 +1,167 @@
1
+ """Auto-onboarding: scaffold a `.krnl/` wrapper the first time the agent works in
2
+ a project, so memory, a skill, and a project doc exist by default - no setup.
3
+
4
+ Creates (only what's missing):
5
+ .krnl/AGENTS.md - project memory / rules (auto-loaded)
6
+ .krnl/PROJECT.md - a project overview doc (the agent's own MD)
7
+ .krnl/skills/explore-codebase/SKILL.md - a starter skill for understanding the repo
8
+
9
+ Everything is detected from the repo (stack, build/test/lint commands), so the
10
+ files are useful immediately and key-free.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from pathlib import Path
16
+
17
+
18
+ def detect_project(workspace: str) -> dict:
19
+ root = Path(workspace)
20
+ info: dict = {"name": root.name, "stack": [], "build": [], "test": [],
21
+ "lint": [], "run": [], "git": (root / ".git").exists()}
22
+
23
+ def add(cat: str, cmd: str):
24
+ if cmd not in info[cat]:
25
+ info[cat].append(cmd)
26
+
27
+ if (root / "pyproject.toml").exists() or (root / "setup.py").exists() or (root / "requirements.txt").exists():
28
+ info["stack"].append("Python")
29
+ if (root / "requirements.txt").exists():
30
+ add("build", "pip install -r requirements.txt")
31
+ if (root / "pyproject.toml").exists():
32
+ add("build", "pip install -e .")
33
+ add("test", "pytest")
34
+ pkg = root / "package.json"
35
+ if pkg.exists():
36
+ info["stack"].append("Node.js")
37
+ try:
38
+ scripts = json.loads(pkg.read_text(encoding="utf-8")).get("scripts", {})
39
+ cat = {"build": "build", "test": "test", "lint": "lint", "dev": "run", "start": "run"}
40
+ for k, c in cat.items():
41
+ if k in scripts:
42
+ add(c, f"npm run {k}")
43
+ except Exception:
44
+ pass
45
+ if (root / "go.mod").exists():
46
+ info["stack"].append("Go"); add("build", "go build ./..."); add("test", "go test ./...")
47
+ if (root / "Cargo.toml").exists():
48
+ info["stack"].append("Rust"); add("build", "cargo build"); add("test", "cargo test")
49
+ if (root / "pom.xml").exists():
50
+ info["stack"].append("Java/Maven"); add("build", "mvn compile"); add("test", "mvn test")
51
+ if (root / "build.gradle").exists() or (root / "build.gradle.kts").exists():
52
+ info["stack"].append("Gradle"); add("build", "gradle build"); add("test", "gradle test")
53
+ if (root / "Gemfile").exists():
54
+ info["stack"].append("Ruby")
55
+ if (root / "composer.json").exists():
56
+ info["stack"].append("PHP")
57
+ # top-level layout (a few entries)
58
+ try:
59
+ entries = sorted(p.name for p in root.iterdir()
60
+ if not p.name.startswith(".") and p.name not in ("node_modules", "venv"))
61
+ info["layout"] = entries[:20]
62
+ except Exception:
63
+ info["layout"] = []
64
+ return info
65
+
66
+
67
+ def needs_scaffold(workspace: str) -> bool:
68
+ return not (Path(workspace) / ".krnl").exists()
69
+
70
+
71
+ def _list(items: list, fallback: str = "(detect / fill in)") -> str:
72
+ return "\n".join(f"- `{x}`" for x in items) if items else f"- {fallback}"
73
+
74
+
75
+ def _agents_md(info: dict) -> str:
76
+ stack = ", ".join(info["stack"]) or "(unknown)"
77
+ return f"""# Project memory - {info['name']}
78
+
79
+ Auto-loaded into the agent's context. Edit freely; this is the source of truth
80
+ for how to work in this repo.
81
+
82
+ ## Stack
83
+ {stack}
84
+
85
+ ## Commands
86
+ **Build/install**
87
+ {_list(info['build'])}
88
+ **Test**
89
+ {_list(info['test'])}
90
+ **Lint**
91
+ {_list(info['lint'])}
92
+ **Run/dev**
93
+ {_list(info['run'])}
94
+
95
+ ## Conventions
96
+ - (e.g. code style, branch naming, files to never touch)
97
+
98
+ ## Notes
99
+ - (anything the agent should always know about this project)
100
+ """
101
+
102
+
103
+ def _project_md(info: dict) -> str:
104
+ layout = "\n".join(f"- {x}" for x in info.get("layout", [])) or "- (run `list_files`)"
105
+ return f"""# {info['name']} - project overview
106
+
107
+ > Auto-generated by Krnl Agent onboarding. Enrich it by asking the agent to
108
+ > "study this codebase and update .krnl/PROJECT.md".
109
+
110
+ **Stack:** {', '.join(info['stack']) or 'unknown'}
111
+ **Git:** {'yes' if info['git'] else 'no'}
112
+
113
+ ## Top-level layout
114
+ {layout}
115
+
116
+ ## How to build & test
117
+ {_list(info['build'] + info['test'])}
118
+
119
+ ## Architecture notes
120
+ - (the agent fills these in as it learns the codebase)
121
+ """
122
+
123
+
124
+ def _skill_md(info: dict) -> str:
125
+ return f"""---
126
+ description: How to explore and safely work in the {info['name']} codebase.
127
+ ---
128
+
129
+ # Explore the {info['name']} codebase
130
+
131
+ When asked to understand, change, or debug this project:
132
+
133
+ 1. Read `.krnl/PROJECT.md` and `.krnl/AGENTS.md` first for stack and commands.
134
+ 2. Map the structure with `list_files` and `glob`; read entry points and configs.
135
+ 3. Use `search_text` to locate symbols before editing; prefer `edit_file`.
136
+ 4. After changes, run the test command from memory and fix failures.
137
+ 5. Keep changes minimal and match existing style.
138
+
139
+ Stack: {', '.join(info['stack']) or 'unknown'}
140
+ """
141
+
142
+
143
+ def scaffold(workspace: str) -> list[str]:
144
+ """Create the .krnl scaffold (only files that don't already exist)."""
145
+ root = Path(workspace)
146
+ info = detect_project(workspace)
147
+ krnl = root / ".krnl"
148
+ (krnl / "skills" / "explore-codebase").mkdir(parents=True, exist_ok=True)
149
+ created: list[str] = []
150
+
151
+ mem = krnl / "AGENTS.md"
152
+ has_memory = any((root / n).exists() for n in ("AGENTS.md", "KRNL.md", "CLAUDE.md"))
153
+ if not mem.exists() and not has_memory:
154
+ mem.write_text(_agents_md(info), encoding="utf-8")
155
+ created.append(".krnl/AGENTS.md")
156
+
157
+ proj = krnl / "PROJECT.md"
158
+ if not proj.exists():
159
+ proj.write_text(_project_md(info), encoding="utf-8")
160
+ created.append(".krnl/PROJECT.md")
161
+
162
+ skill = krnl / "skills" / "explore-codebase" / "SKILL.md"
163
+ if not skill.exists():
164
+ skill.write_text(_skill_md(info), encoding="utf-8")
165
+ created.append(".krnl/skills/explore-codebase/SKILL.md")
166
+
167
+ return created
@@ -0,0 +1,137 @@
1
+ """Scheduled agents — run tasks on cron schedules, independent of any terminal.
2
+
3
+ krnl-agent schedule create "PR summary" --cron "0 9 * * MON-FRI" \
4
+ --prompt "List all open PRs and their review status" --workspace /repo
5
+ krnl-agent schedule list
6
+ krnl-agent schedule run <id>
7
+ krnl-agent schedule daemon # foreground loop that fires due schedules
8
+
9
+ Schedules persist in ~/.krnl-agent/schedules.json across restarts. Includes a
10
+ small dependency-free cron matcher (5 fields, supports *, lists, ranges, steps,
11
+ and day/month names).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import uuid
17
+ from datetime import datetime
18
+
19
+ from .settings import SETTINGS_DIR
20
+
21
+ SCHEDULES_FILE = SETTINGS_DIR / "schedules.json"
22
+
23
+ _DOW = {"SUN": 0, "MON": 1, "TUE": 2, "WED": 3, "THU": 4, "FRI": 5, "SAT": 6}
24
+ _MON = {m: i + 1 for i, m in enumerate(
25
+ ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"])}
26
+
27
+
28
+ # --------------------------------------------------------------------------- #
29
+ # Cron matching
30
+ # --------------------------------------------------------------------------- #
31
+ def _match_field(expr: str, value: int, names: dict | None = None) -> bool:
32
+ expr = expr.upper()
33
+ if names:
34
+ for n, v in names.items():
35
+ expr = expr.replace(n, str(v))
36
+ for part in expr.split(","):
37
+ step = 1
38
+ if "/" in part:
39
+ part, step_s = part.split("/", 1)
40
+ step = int(step_s)
41
+ if part in ("*", ""):
42
+ lo, hi = None, None
43
+ elif "-" in part:
44
+ a, b = part.split("-", 1)
45
+ lo, hi = int(a), int(b)
46
+ else:
47
+ lo = hi = int(part)
48
+ if lo is None: # wildcard
49
+ if value % step == 0:
50
+ return True
51
+ elif lo <= value <= hi and (value - lo) % step == 0:
52
+ return True
53
+ return False
54
+
55
+
56
+ def cron_match(expr: str, dt: datetime) -> bool:
57
+ fields = expr.split()
58
+ if len(fields) != 5:
59
+ return False
60
+ minute, hour, dom, month, dow = fields
61
+ cron_dow = (dt.weekday() + 1) % 7 # Python Mon=0..Sun=6 -> cron Sun=0..Sat=6
62
+ dom_r = dom not in ("*", "?")
63
+ dow_r = dow not in ("*", "?")
64
+ base = (
65
+ _match_field(minute, dt.minute)
66
+ and _match_field(hour, dt.hour)
67
+ and _match_field(month, dt.month, _MON)
68
+ )
69
+ if not base:
70
+ return False
71
+ dom_ok = _match_field(dom, dt.day)
72
+ dow_ok = _match_field(dow, cron_dow, _DOW)
73
+ # standard cron: when both day-fields are restricted, it's OR
74
+ if dom_r and dow_r:
75
+ return dom_ok or dow_ok
76
+ return dom_ok and dow_ok
77
+
78
+
79
+ # --------------------------------------------------------------------------- #
80
+ # Storage
81
+ # --------------------------------------------------------------------------- #
82
+ def _load() -> list[dict]:
83
+ try:
84
+ return json.loads(SCHEDULES_FILE.read_text(encoding="utf-8")).get("schedules", [])
85
+ except Exception:
86
+ return []
87
+
88
+
89
+ def _save(rows: list[dict]) -> None:
90
+ SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
91
+ SCHEDULES_FILE.write_text(json.dumps({"schedules": rows}, indent=2), encoding="utf-8")
92
+
93
+
94
+ def create(name: str, cron: str, prompt: str, workspace: str,
95
+ provider: str | None = None, model: str | None = None) -> dict:
96
+ rows = _load()
97
+ sched = {
98
+ "id": uuid.uuid4().hex[:8],
99
+ "name": name,
100
+ "cron": cron,
101
+ "prompt": prompt,
102
+ "workspace": workspace,
103
+ "provider": provider,
104
+ "model": model,
105
+ "last_run": None,
106
+ }
107
+ rows.append(sched)
108
+ _save(rows)
109
+ return sched
110
+
111
+
112
+ def list_schedules() -> list[dict]:
113
+ return _load()
114
+
115
+
116
+ def remove(schedule_id: str) -> bool:
117
+ rows = _load()
118
+ new = [r for r in rows if r["id"] != schedule_id]
119
+ _save(new)
120
+ return len(new) != len(rows)
121
+
122
+
123
+ def get(schedule_id: str) -> dict | None:
124
+ return next((r for r in _load() if r["id"] == schedule_id), None)
125
+
126
+
127
+ def mark_run(schedule_id: str, stamp: str) -> None:
128
+ rows = _load()
129
+ for r in rows:
130
+ if r["id"] == schedule_id:
131
+ r["last_run"] = stamp
132
+ _save(rows)
133
+
134
+
135
+ def due(now: datetime) -> list[dict]:
136
+ stamp = now.strftime("%Y-%m-%d %H:%M")
137
+ return [r for r in _load() if r.get("last_run") != stamp and cron_match(r["cron"], now)]
krnl_agent/secrets.py ADDED
@@ -0,0 +1,100 @@
1
+ """Secret scanner — find hard-coded credentials before they ship.
2
+
3
+ A regex sweep over the workspace for the highest-signal secret shapes (API keys,
4
+ private keys, tokens, connection strings). It is intentionally conservative to
5
+ keep false positives low, reports `path:line` with the match masked, and never
6
+ prints the full secret back into the model's context.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ from pathlib import Path
13
+
14
+ # (label, compiled pattern). Patterns target distinctive secret shapes.
15
+ _RULES: list[tuple[str, re.Pattern]] = [
16
+ ("AWS access key id", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
17
+ ("AWS secret access key", re.compile(r"(?i)aws.{0,20}secret.{0,20}['\"][0-9a-zA-Z/+]{40}['\"]")),
18
+ ("GitHub token", re.compile(r"\bgh[pousr]_[0-9A-Za-z]{36,}\b")),
19
+ ("GitHub fine-grained token", re.compile(r"\bgithub_pat_[0-9A-Za-z_]{60,}\b")),
20
+ ("Slack token", re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{10,}\b")),
21
+ ("Google API key", re.compile(r"\bAIza[0-9A-Za-z\-_]{35}\b")),
22
+ ("OpenAI / sk- key", re.compile(r"\bsk-[A-Za-z0-9]{20,}\b")),
23
+ ("Anthropic key", re.compile(r"\bsk-ant-[A-Za-z0-9\-_]{20,}\b")),
24
+ ("PyPI token", re.compile(r"\bpypi-[A-Za-z0-9\-_]{40,}\b")),
25
+ ("Stripe key", re.compile(r"\b[sr]k_live_[0-9A-Za-z]{20,}\b")),
26
+ ("JWT", re.compile(r"\beyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}\b")),
27
+ ("Private key block", re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----")),
28
+ ("Generic secret assignment", re.compile(
29
+ r"(?i)(?:password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)"
30
+ r"\s*[:=]\s*['\"][^'\"\s]{8,}['\"]")),
31
+ ("Connection string w/ password", re.compile(
32
+ r"(?i)(?:postgres|mysql|mongodb|redis|amqp)(?:\+\w+)?://[^:\s]+:[^@\s]+@")),
33
+ ]
34
+
35
+ # Don't flag obvious placeholders.
36
+ _PLACEHOLDER = re.compile(
37
+ r"(?i)(your[_-]?|example|placeholder|changeme|xxx+|\.\.\.|<[^>]+>|dummy|sample|test[_-]?key)")
38
+
39
+ _MAX_HITS = 200
40
+
41
+
42
+ def _mask(s: str) -> str:
43
+ s = s.strip().strip("'\"")
44
+ if len(s) <= 12:
45
+ return s[:2] + "…"
46
+ return f"{s[:4]}…{s[-4:]} (len {len(s)})"
47
+
48
+
49
+ def scan(ctx, path: str = ".") -> tuple[int, str]:
50
+ """Scan the workspace for likely secrets. Returns (hit_count, report)."""
51
+ base = ctx.resolve(path)
52
+ hits: list[str] = []
53
+ scanned = 0
54
+ if base.is_file():
55
+ files = [base]
56
+ else:
57
+ files = []
58
+ for root, dirs, names in os.walk(base):
59
+ rroot = ctx.rel(Path(root))
60
+ dirs[:] = [d for d in sorted(dirs)
61
+ if not ctx.is_ignored(f"{rroot}/{d}".lstrip("./"))]
62
+ for n in sorted(names):
63
+ fp = Path(root) / n
64
+ if not ctx.is_ignored(ctx.rel(fp)):
65
+ files.append(fp)
66
+
67
+ for fp in files:
68
+ try:
69
+ if fp.stat().st_size > 400_000:
70
+ continue
71
+ with open(fp, "rb") as fb:
72
+ if b"\x00" in fb.read(2048):
73
+ continue
74
+ except Exception:
75
+ continue
76
+ try:
77
+ with open(fp, encoding="utf-8", errors="ignore") as fh:
78
+ for i, line in enumerate(fh, 1):
79
+ if len(line) > 1000:
80
+ continue
81
+ for label, rx in _RULES:
82
+ m = rx.search(line)
83
+ if m and not _PLACEHOLDER.search(m.group(0)):
84
+ hits.append(f"{ctx.rel(fp)}:{i}: [{label}] {_mask(m.group(0))}")
85
+ break
86
+ if len(hits) >= _MAX_HITS:
87
+ break
88
+ except Exception:
89
+ continue
90
+ scanned += 1
91
+ if len(hits) >= _MAX_HITS:
92
+ hits.append("… (more; truncated)")
93
+ break
94
+
95
+ if not hits:
96
+ return 0, f"No likely secrets found ({scanned} files scanned)."
97
+ report = (f"Found {len(hits)} potential secret(s) across {scanned} files. "
98
+ "Review each; rotate anything real and move it to env/secret storage:\n\n"
99
+ + "\n".join(hits))
100
+ return len(hits), report
krnl_agent/selfheal.py ADDED
@@ -0,0 +1,87 @@
1
+ """Self-healing — keep a deployment alive.
2
+
3
+ Two cooperating loops (policy: rollback is autonomous, code fixes are PRs):
4
+
5
+ * FAST loop (deterministic, autonomous): after a deploy, poll the app's health
6
+ endpoint; if it's unhealthy, automatically roll back to the last known-good
7
+ release. This only ever reverts to a previous good state, so it is always safe
8
+ to run without asking.
9
+ * SLOW loop (generative, gated): pull current production errors (see monitor.py),
10
+ have the model write a patch + regression test, and open a PR for a human to
11
+ merge. That part is driven by the `heal` command's prompt, not this module — here
12
+ we provide the safe, deterministic primitives it builds on.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ import urllib.error
18
+ import urllib.request
19
+
20
+ from . import deploy
21
+
22
+
23
+ def http_health(url: str, timeout: float = 10.0) -> tuple[bool, str]:
24
+ """Single GET. Healthy = HTTP 2xx/3xx. Never raises."""
25
+ try:
26
+ req = urllib.request.Request(url, headers={"User-Agent": "krnl-agent-health"})
27
+ with urllib.request.urlopen(req, timeout=timeout) as r: # noqa: S310
28
+ code = getattr(r, "status", 200)
29
+ return (200 <= code < 400), f"HTTP {code}"
30
+ except urllib.error.HTTPError as e:
31
+ return (200 <= e.code < 400), f"HTTP {e.code}"
32
+ except Exception as e: # noqa: BLE001
33
+ return False, f"unreachable: {type(e).__name__}: {e}"
34
+
35
+
36
+ def check_health(url: str, path: str = "/health", attempts: int = 1,
37
+ delay: float = 0.0, _sleep=time.sleep) -> tuple[bool, str]:
38
+ """Poll `url`+`path` up to `attempts` times. Returns (healthy, detail)."""
39
+ full = url.rstrip("/") + (path if path.startswith("/") else "/" + path)
40
+ last = ""
41
+ for i in range(max(1, attempts)):
42
+ ok, detail = http_health(full)
43
+ last = detail
44
+ if ok:
45
+ return True, f"healthy ({detail}) at {full}"
46
+ if i < attempts - 1 and delay:
47
+ _sleep(delay)
48
+ return False, f"unhealthy ({last}) at {full}"
49
+
50
+
51
+ def rollback(ctx, target_name: str, params: dict | None = None) -> tuple[bool, str]:
52
+ """Build and RUN the rollback command for a target (autonomous, safe).
53
+
54
+ Returns (ok, output). Rolling back only restores a prior good release.
55
+ """
56
+ ok, cmd = deploy.rollback_command(target_name, params)
57
+ if not ok:
58
+ return False, cmd
59
+ from .tools import run_command # lazy to avoid import cycle
60
+
61
+ outcome = run_command(ctx, cmd)
62
+ return outcome.ok, outcome.output
63
+
64
+
65
+ def heal_once(ctx, url: str, target_name: str, params: dict | None = None,
66
+ path: str = "/health", attempts: int = 3, delay: float = 5.0) -> dict:
67
+ """Fast-loop self-heal: check health; if unhealthy, auto-rollback. Returns a
68
+ report dict (no exceptions). The generative error→PR step is handled by the
69
+ `heal` command prompt, not here."""
70
+ healthy, detail = check_health(url, path, attempts=attempts, delay=delay)
71
+ report = {"url": url, "target": target_name, "healthy": healthy, "detail": detail,
72
+ "action": "none", "rollback_ok": None, "rollback_output": ""}
73
+ if healthy:
74
+ return report
75
+ t = deploy.target(target_name)
76
+ if not t or not t.rollback_cmd:
77
+ report["action"] = "no-rollback-available"
78
+ return report
79
+ report["action"] = "rollback"
80
+ rb_ok, rb_out = rollback(ctx, target_name, params)
81
+ report["rollback_ok"] = rb_ok
82
+ report["rollback_output"] = rb_out[:500]
83
+ # Re-check after rollback.
84
+ healthy2, detail2 = check_health(url, path, attempts=attempts, delay=delay)
85
+ report["healthy_after_rollback"] = healthy2
86
+ report["detail_after_rollback"] = detail2
87
+ return report