@misterhuydo/sentinel 1.0.0 → 1.0.1
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.
- package/lib/generate.js +9 -3
- package/lib/init.js +30 -2
- package/package.json +1 -1
- package/python/sentinel/config_loader.py +176 -174
- package/python/sentinel/fix_engine.py +127 -123
package/lib/generate.js
CHANGED
|
@@ -5,15 +5,21 @@ const path = require('path');
|
|
|
5
5
|
|
|
6
6
|
// ── Per-project files ─────────────────────────────────────────────────────────
|
|
7
7
|
|
|
8
|
-
function writeExampleProject(projectDir, codeDir, pythonBin) {
|
|
8
|
+
function writeExampleProject(projectDir, codeDir, pythonBin, anthropicKey = '') {
|
|
9
9
|
const configDir = path.join(projectDir, 'config', 'log-configs');
|
|
10
10
|
const repoDir = path.join(projectDir, 'config', 'repo-configs');
|
|
11
11
|
fs.ensureDirSync(configDir);
|
|
12
12
|
fs.ensureDirSync(repoDir);
|
|
13
13
|
|
|
14
|
-
// Copy example config templates from bundled python/
|
|
15
14
|
const tplDir = path.join(__dirname, '..', 'templates');
|
|
16
|
-
|
|
15
|
+
// Inject API key into sentinel.properties if provided
|
|
16
|
+
let sentinelProps = fs.readFileSync(path.join(tplDir, 'sentinel.properties'), 'utf8');
|
|
17
|
+
if (anthropicKey) {
|
|
18
|
+
sentinelProps += `\n# Anthropic API key for Claude Code (headless server auth)\nANTHROPIC_API_KEY=${anthropicKey}\n`;
|
|
19
|
+
} else {
|
|
20
|
+
sentinelProps += `\n# Anthropic API key — set this if using API key auth, or leave blank for OAuth\n# ANTHROPIC_API_KEY=sk-ant-...\n`;
|
|
21
|
+
}
|
|
22
|
+
fs.writeFileSync(path.join(projectDir, 'config', 'sentinel.properties'), sentinelProps);
|
|
17
23
|
fs.copySync(path.join(tplDir, 'log-configs', '_example.properties'), path.join(configDir, '_example.properties'));
|
|
18
24
|
fs.copySync(path.join(tplDir, 'repo-configs', '_example.properties'), path.join(repoDir, '_example.properties'));
|
|
19
25
|
|
package/lib/init.js
CHANGED
|
@@ -23,6 +23,22 @@ module.exports = async function init() {
|
|
|
23
23
|
initial: path.join(os.homedir(), 'sentinel'),
|
|
24
24
|
format: v => v.replace(/^~/, os.homedir()),
|
|
25
25
|
},
|
|
26
|
+
{
|
|
27
|
+
type: 'select',
|
|
28
|
+
name: 'authMode',
|
|
29
|
+
message: 'How will Claude Code authenticate?',
|
|
30
|
+
choices: [
|
|
31
|
+
{ title: 'API key (Anthropic API account — recommended for servers)', value: 'apikey' },
|
|
32
|
+
{ title: 'Claude Pro / OAuth (will give you a URL to open in any browser)', value: 'oauth' },
|
|
33
|
+
{ title: 'Skip (I will configure this later)', value: 'skip' },
|
|
34
|
+
],
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
type: prev => prev === 'apikey' ? 'password' : null,
|
|
38
|
+
name: 'anthropicKey',
|
|
39
|
+
message: 'Anthropic API key (sk-ant-...)',
|
|
40
|
+
validate: v => v.startsWith('sk-ant-') ? true : 'Key should start with sk-ant-',
|
|
41
|
+
},
|
|
26
42
|
{
|
|
27
43
|
type: 'confirm',
|
|
28
44
|
name: 'example',
|
|
@@ -37,7 +53,7 @@ module.exports = async function init() {
|
|
|
37
53
|
},
|
|
38
54
|
], { onCancel: () => process.exit(0) });
|
|
39
55
|
|
|
40
|
-
const { workspace, example, systemd } = answers;
|
|
56
|
+
const { workspace, authMode, anthropicKey, example, systemd } = answers;
|
|
41
57
|
const codeDir = path.join(workspace, 'code');
|
|
42
58
|
|
|
43
59
|
// ── Python ──────────────────────────────────────────────────────────────────
|
|
@@ -80,6 +96,18 @@ module.exports = async function init() {
|
|
|
80
96
|
installNpmGlobal('@misterhuydo/cairn-mcp', 'cairn');
|
|
81
97
|
installNpmGlobal('@anthropic-ai/claude-code', 'claude');
|
|
82
98
|
|
|
99
|
+
// ── Claude Code auth ─────────────────────────────────────────────────────────
|
|
100
|
+
step('Claude Code authentication…');
|
|
101
|
+
if (authMode === 'apikey' && anthropicKey) {
|
|
102
|
+
ok('API key will be written to each project\'s sentinel.properties');
|
|
103
|
+
} else if (authMode === 'oauth') {
|
|
104
|
+
console.log(chalk.yellow('\n Claude Code will print a URL — open it in any browser to complete login.\n'));
|
|
105
|
+
spawnSync('claude', ['--print', 'hi'], { stdio: 'inherit', env: process.env });
|
|
106
|
+
ok('Claude Code authenticated (OAuth token stored in ~/.claude/)');
|
|
107
|
+
} else {
|
|
108
|
+
warn('Skipping auth — run "claude" manually on this server to authenticate when ready');
|
|
109
|
+
}
|
|
110
|
+
|
|
83
111
|
// ── Workspace structure ─────────────────────────────────────────────────────
|
|
84
112
|
step('Creating workspace…');
|
|
85
113
|
fs.ensureDirSync(workspace);
|
|
@@ -89,7 +117,7 @@ module.exports = async function init() {
|
|
|
89
117
|
if (example) {
|
|
90
118
|
step('Creating example project…');
|
|
91
119
|
const exampleDir = path.join(workspace, 'my-project');
|
|
92
|
-
writeExampleProject(exampleDir, codeDir, pythonBin);
|
|
120
|
+
writeExampleProject(exampleDir, codeDir, pythonBin, anthropicKey || '');
|
|
93
121
|
ok(`Example project: ${exampleDir}`);
|
|
94
122
|
}
|
|
95
123
|
|
package/package.json
CHANGED
|
@@ -1,174 +1,176 @@
|
|
|
1
|
-
"""
|
|
2
|
-
config_loader.py — Load and hot-reload all .properties config files.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
import signal
|
|
7
|
-
from dataclasses import dataclass, field
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import Optional
|
|
10
|
-
|
|
11
|
-
logger = logging.getLogger(__name__)
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def _parse_properties(path: str) -> dict[str, str]:
|
|
15
|
-
result = {}
|
|
16
|
-
with open(path, encoding="utf-8") as f:
|
|
17
|
-
for raw in f:
|
|
18
|
-
line = raw.strip()
|
|
19
|
-
if not line or line.startswith("#"):
|
|
20
|
-
continue
|
|
21
|
-
if "=" not in line:
|
|
22
|
-
continue
|
|
23
|
-
key, _, val = line.partition("=")
|
|
24
|
-
key = key.strip()
|
|
25
|
-
val = val.partition("#")[0].strip()
|
|
26
|
-
if key:
|
|
27
|
-
result[key] = val
|
|
28
|
-
return result
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _csv(val: str) -> list[str]:
|
|
32
|
-
return [v.strip() for v in val.split(",") if v.strip()]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
# ── Typed config objects ──────────────────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
@dataclass
|
|
38
|
-
class SentinelConfig:
|
|
39
|
-
poll_interval_seconds: int = 120
|
|
40
|
-
smtp_host: str = ""
|
|
41
|
-
smtp_port: int = 587
|
|
42
|
-
smtp_user: str = ""
|
|
43
|
-
smtp_password: str = ""
|
|
44
|
-
report_recipients: list[str] = field(default_factory=list)
|
|
45
|
-
report_interval_hours: int = 6
|
|
46
|
-
state_db: str = "./sentinel.db"
|
|
47
|
-
workspace_dir: str = "./workspace"
|
|
48
|
-
claude_code_bin: str = "claude"
|
|
49
|
-
github_token: str = ""
|
|
50
|
-
fix_confidence_threshold: float = 0.7
|
|
51
|
-
log_retention_hours: int = 48
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
self.
|
|
90
|
-
self.
|
|
91
|
-
self.
|
|
92
|
-
self.
|
|
93
|
-
self.
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
self.
|
|
98
|
-
self.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
c
|
|
112
|
-
c.
|
|
113
|
-
c.
|
|
114
|
-
c.
|
|
115
|
-
c.
|
|
116
|
-
c.
|
|
117
|
-
c.
|
|
118
|
-
c.
|
|
119
|
-
c.
|
|
120
|
-
c.
|
|
121
|
-
c.
|
|
122
|
-
c.
|
|
123
|
-
c.
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
s
|
|
136
|
-
s.
|
|
137
|
-
s.
|
|
138
|
-
s.
|
|
139
|
-
s.
|
|
140
|
-
s.
|
|
141
|
-
s.
|
|
142
|
-
s.
|
|
143
|
-
s.
|
|
144
|
-
s.
|
|
145
|
-
s.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
r
|
|
158
|
-
r.
|
|
159
|
-
r.
|
|
160
|
-
r.
|
|
161
|
-
r.
|
|
162
|
-
r.
|
|
163
|
-
r.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
1
|
+
"""
|
|
2
|
+
config_loader.py — Load and hot-reload all .properties config files.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import signal
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_properties(path: str) -> dict[str, str]:
|
|
15
|
+
result = {}
|
|
16
|
+
with open(path, encoding="utf-8") as f:
|
|
17
|
+
for raw in f:
|
|
18
|
+
line = raw.strip()
|
|
19
|
+
if not line or line.startswith("#"):
|
|
20
|
+
continue
|
|
21
|
+
if "=" not in line:
|
|
22
|
+
continue
|
|
23
|
+
key, _, val = line.partition("=")
|
|
24
|
+
key = key.strip()
|
|
25
|
+
val = val.partition("#")[0].strip()
|
|
26
|
+
if key:
|
|
27
|
+
result[key] = val
|
|
28
|
+
return result
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _csv(val: str) -> list[str]:
|
|
32
|
+
return [v.strip() for v in val.split(",") if v.strip()]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ── Typed config objects ──────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class SentinelConfig:
|
|
39
|
+
poll_interval_seconds: int = 120
|
|
40
|
+
smtp_host: str = ""
|
|
41
|
+
smtp_port: int = 587
|
|
42
|
+
smtp_user: str = ""
|
|
43
|
+
smtp_password: str = ""
|
|
44
|
+
report_recipients: list[str] = field(default_factory=list)
|
|
45
|
+
report_interval_hours: int = 6
|
|
46
|
+
state_db: str = "./sentinel.db"
|
|
47
|
+
workspace_dir: str = "./workspace"
|
|
48
|
+
claude_code_bin: str = "claude"
|
|
49
|
+
github_token: str = ""
|
|
50
|
+
fix_confidence_threshold: float = 0.7
|
|
51
|
+
log_retention_hours: int = 48
|
|
52
|
+
anthropic_api_key: str = ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class LogSourceConfig:
|
|
57
|
+
name: str = "" # derived from filename stem
|
|
58
|
+
source_type: str = "ssh"
|
|
59
|
+
# SSH
|
|
60
|
+
key: str = ""
|
|
61
|
+
hosts: list[str] = field(default_factory=list)
|
|
62
|
+
logs: list[str] = field(default_factory=list)
|
|
63
|
+
remote_service_user: str = ""
|
|
64
|
+
grep_filter: str = ""
|
|
65
|
+
grep_exclude: str = ""
|
|
66
|
+
tail: Optional[int] = None
|
|
67
|
+
head: Optional[int] = None
|
|
68
|
+
# Cloudflare
|
|
69
|
+
cf_url: str = ""
|
|
70
|
+
cf_token: str = ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class RepoConfig:
|
|
75
|
+
repo_name: str = "" # derived from filename stem
|
|
76
|
+
repo_url: str = ""
|
|
77
|
+
local_path: str = ""
|
|
78
|
+
branch: str = "main"
|
|
79
|
+
auto_publish: bool = False
|
|
80
|
+
cicd_type: str = ""
|
|
81
|
+
cicd_job_url: str = ""
|
|
82
|
+
cicd_token: str = ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ── Loader ────────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
class ConfigLoader:
|
|
88
|
+
def __init__(self, config_dir: str = "./config"):
|
|
89
|
+
self.config_dir = Path(config_dir)
|
|
90
|
+
self.sentinel: SentinelConfig = SentinelConfig()
|
|
91
|
+
self.log_sources: dict[str, LogSourceConfig] = {}
|
|
92
|
+
self.repos: dict[str, RepoConfig] = {}
|
|
93
|
+
self.load()
|
|
94
|
+
self._register_sighup()
|
|
95
|
+
|
|
96
|
+
def load(self):
|
|
97
|
+
self._load_sentinel()
|
|
98
|
+
self._load_log_sources()
|
|
99
|
+
self._load_repos()
|
|
100
|
+
logger.info(
|
|
101
|
+
"Config loaded: %d log-config(s), %d repo-config(s)",
|
|
102
|
+
len(self.log_sources), len(self.repos),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _load_sentinel(self):
|
|
106
|
+
path = self.config_dir / "sentinel.properties"
|
|
107
|
+
if not path.exists():
|
|
108
|
+
logger.warning("sentinel.properties not found at %s", path)
|
|
109
|
+
return
|
|
110
|
+
d = _parse_properties(str(path))
|
|
111
|
+
c = SentinelConfig()
|
|
112
|
+
c.poll_interval_seconds = int(d.get("POLL_INTERVAL_SECONDS", 120))
|
|
113
|
+
c.smtp_host = d.get("SMTP_HOST", "")
|
|
114
|
+
c.smtp_port = int(d.get("SMTP_PORT", 587))
|
|
115
|
+
c.smtp_user = d.get("SMTP_USER", "")
|
|
116
|
+
c.smtp_password = d.get("SMTP_PASSWORD", "")
|
|
117
|
+
c.report_recipients = _csv(d.get("REPORT_RECIPIENTS", ""))
|
|
118
|
+
c.report_interval_hours = int(d.get("REPORT_INTERVAL_HOURS", 6))
|
|
119
|
+
c.state_db = d.get("STATE_DB", "./sentinel.db")
|
|
120
|
+
c.workspace_dir = d.get("WORKSPACE_DIR", "./workspace")
|
|
121
|
+
c.claude_code_bin = d.get("CLAUDE_CODE_BIN", "claude")
|
|
122
|
+
c.github_token = d.get("GITHUB_TOKEN", "")
|
|
123
|
+
c.fix_confidence_threshold = float(d.get("FIX_CONFIDENCE_THRESHOLD", 0.7))
|
|
124
|
+
c.log_retention_hours = int(d.get("LOG_RETENTION_HOURS", 48))
|
|
125
|
+
c.anthropic_api_key = d.get("ANTHROPIC_API_KEY", "")
|
|
126
|
+
self.sentinel = c
|
|
127
|
+
|
|
128
|
+
def _load_log_sources(self):
|
|
129
|
+
sources_dir = self.config_dir / "log-configs"
|
|
130
|
+
if not sources_dir.exists():
|
|
131
|
+
return
|
|
132
|
+
self.log_sources = {}
|
|
133
|
+
for path in sorted(sources_dir.glob("*.properties")):
|
|
134
|
+
d = _parse_properties(str(path))
|
|
135
|
+
s = LogSourceConfig()
|
|
136
|
+
s.name = path.stem
|
|
137
|
+
s.source_type = d.get("SOURCE_TYPE", "ssh").lower()
|
|
138
|
+
s.key = d.get("KEY", "")
|
|
139
|
+
s.hosts = _csv(d.get("HOSTS", ""))
|
|
140
|
+
s.logs = _csv(d.get("LOGS", ""))
|
|
141
|
+
s.remote_service_user = d.get("REMOTE_SERVICE_USER", path.stem)
|
|
142
|
+
s.grep_filter = d.get("GREP_FILTER", "")
|
|
143
|
+
s.grep_exclude = d.get("GREP_EXCLUDE", "")
|
|
144
|
+
s.tail = int(d["TAIL"]) if "TAIL" in d else None
|
|
145
|
+
s.head = int(d["HEAD"]) if "HEAD" in d else None
|
|
146
|
+
s.cf_url = d.get("CF_URL", "")
|
|
147
|
+
s.cf_token = d.get("CF_TOKEN", "")
|
|
148
|
+
self.log_sources[s.name] = s
|
|
149
|
+
|
|
150
|
+
def _load_repos(self):
|
|
151
|
+
repos_dir = self.config_dir / "repo-configs"
|
|
152
|
+
if not repos_dir.exists():
|
|
153
|
+
return
|
|
154
|
+
self.repos = {}
|
|
155
|
+
for path in sorted(repos_dir.glob("*.properties")):
|
|
156
|
+
d = _parse_properties(str(path))
|
|
157
|
+
r = RepoConfig()
|
|
158
|
+
r.repo_name = path.stem
|
|
159
|
+
r.repo_url = d.get("REPO_URL", "")
|
|
160
|
+
r.local_path = d.get("LOCAL_PATH", "")
|
|
161
|
+
r.branch = d.get("BRANCH", "main")
|
|
162
|
+
r.auto_publish = d.get("AUTO_PUBLISH", "false").lower() == "true"
|
|
163
|
+
r.cicd_type = d.get("CICD_TYPE", "")
|
|
164
|
+
r.cicd_job_url = d.get("CICD_JOB_URL", "")
|
|
165
|
+
r.cicd_token = d.get("CICD_TOKEN", "")
|
|
166
|
+
self.repos[r.repo_name] = r
|
|
167
|
+
|
|
168
|
+
def _register_sighup(self):
|
|
169
|
+
try:
|
|
170
|
+
signal.signal(signal.SIGHUP, self._on_sighup)
|
|
171
|
+
except (OSError, AttributeError):
|
|
172
|
+
pass
|
|
173
|
+
|
|
174
|
+
def _on_sighup(self, *_):
|
|
175
|
+
logger.info("SIGHUP received — reloading config")
|
|
176
|
+
self.load()
|
|
@@ -1,123 +1,127 @@
|
|
|
1
|
-
"""
|
|
2
|
-
fix_engine.py — Generate code fixes via Claude Code (headless).
|
|
3
|
-
|
|
4
|
-
Invokes: claude --print "<prompt>" 2>&1
|
|
5
|
-
|
|
6
|
-
Cairn MCP context is fetched automatically by Claude Code via its MCP tool
|
|
7
|
-
connection — Sentinel does not need to query or inject it explicitly.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
import logging
|
|
11
|
-
import re
|
|
12
|
-
import subprocess
|
|
13
|
-
import textwrap
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
|
|
16
|
-
from .config_loader import RepoConfig, SentinelConfig
|
|
17
|
-
from .log_parser import ErrorEvent
|
|
18
|
-
|
|
19
|
-
logger = logging.getLogger(__name__)
|
|
20
|
-
|
|
21
|
-
SUBPROCESS_TIMEOUT = 120
|
|
22
|
-
MAX_FILES_IN_PATCH = 5
|
|
23
|
-
MAX_LINES_IN_PATCH = 200
|
|
24
|
-
|
|
25
|
-
_DIFF_BLOCK = re.compile(r"```(?:diff|patch)?\n(.*?)```", re.DOTALL)
|
|
26
|
-
_DIFF_HEADER = re.compile(r"^diff --git|^---\s+\S+|^\+\+\+\s+\S+", re.MULTILINE)
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
def _build_prompt(event: ErrorEvent, repo: RepoConfig, log_file: Path) -> str:
|
|
30
|
-
return textwrap.dedent(f"""\
|
|
31
|
-
You are fixing a production bug in the repository at {repo.local_path}.
|
|
32
|
-
Repository: {repo.repo_name}
|
|
33
|
-
|
|
34
|
-
LOG FILE: {log_file}
|
|
35
|
-
Read this file first. It contains the last 48h of logs from {event.source} —
|
|
36
|
-
use it to understand the frequency, surrounding context, and any warnings
|
|
37
|
-
that preceded this error.
|
|
38
|
-
|
|
39
|
-
ERROR fingerprint to fix (from {event.source}):
|
|
40
|
-
{event.full_text()}
|
|
41
|
-
|
|
42
|
-
Task:
|
|
43
|
-
1. Read the log file above to understand what led up to this error.
|
|
44
|
-
2. Use your available tools to explore the codebase and identify the root cause.
|
|
45
|
-
3. Output ONLY a unified diff patch (git diff format) fixing the issue.
|
|
46
|
-
4. Do not explain. Output only the patch.
|
|
47
|
-
5. If you cannot determine a safe fix, output: SKIP: <reason>
|
|
48
|
-
""")
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def _extract_patch(output: str) -> str | None:
|
|
52
|
-
m = _DIFF_BLOCK.search(output)
|
|
53
|
-
if m:
|
|
54
|
-
return m.group(1).strip()
|
|
55
|
-
if _DIFF_HEADER.search(output):
|
|
56
|
-
return output.strip()
|
|
57
|
-
return None
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def _validate_patch(patch: str) -> tuple[bool, str]:
|
|
61
|
-
files_changed = len(re.findall(r"^diff --git", patch, re.MULTILINE))
|
|
62
|
-
lines_changed = len([
|
|
63
|
-
l for l in patch.splitlines()
|
|
64
|
-
if l.startswith(("+", "-")) and not l.startswith(("+++", "---"))
|
|
65
|
-
])
|
|
66
|
-
if files_changed > MAX_FILES_IN_PATCH:
|
|
67
|
-
return False, f"Patch touches {files_changed} files (limit {MAX_FILES_IN_PATCH})"
|
|
68
|
-
if lines_changed > MAX_LINES_IN_PATCH:
|
|
69
|
-
return False, f"Patch changes {lines_changed} lines (limit {MAX_LINES_IN_PATCH})"
|
|
70
|
-
return True, ""
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def generate_fix(
|
|
74
|
-
event: ErrorEvent,
|
|
75
|
-
repo: RepoConfig,
|
|
76
|
-
cfg: SentinelConfig,
|
|
77
|
-
patches_dir: Path,
|
|
78
|
-
) -> tuple[str, Path | None]:
|
|
79
|
-
"""
|
|
80
|
-
Generate a fix for the given error event.
|
|
81
|
-
|
|
82
|
-
Returns:
|
|
83
|
-
(status, patch_path)
|
|
84
|
-
status: "patch" | "skip" | "error"
|
|
85
|
-
"""
|
|
86
|
-
log_file = Path(cfg.workspace_dir) / "fetched" / f"{event.source}.log"
|
|
87
|
-
prompt = _build_prompt(event, repo, log_file)
|
|
88
|
-
|
|
89
|
-
logger.info("Invoking Claude Code for %s (fp=%s)", event.source, event.fingerprint)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
1
|
+
"""
|
|
2
|
+
fix_engine.py — Generate code fixes via Claude Code (headless).
|
|
3
|
+
|
|
4
|
+
Invokes: claude --print "<prompt>" 2>&1
|
|
5
|
+
|
|
6
|
+
Cairn MCP context is fetched automatically by Claude Code via its MCP tool
|
|
7
|
+
connection — Sentinel does not need to query or inject it explicitly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import textwrap
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .config_loader import RepoConfig, SentinelConfig
|
|
17
|
+
from .log_parser import ErrorEvent
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
SUBPROCESS_TIMEOUT = 120
|
|
22
|
+
MAX_FILES_IN_PATCH = 5
|
|
23
|
+
MAX_LINES_IN_PATCH = 200
|
|
24
|
+
|
|
25
|
+
_DIFF_BLOCK = re.compile(r"```(?:diff|patch)?\n(.*?)```", re.DOTALL)
|
|
26
|
+
_DIFF_HEADER = re.compile(r"^diff --git|^---\s+\S+|^\+\+\+\s+\S+", re.MULTILINE)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _build_prompt(event: ErrorEvent, repo: RepoConfig, log_file: Path) -> str:
|
|
30
|
+
return textwrap.dedent(f"""\
|
|
31
|
+
You are fixing a production bug in the repository at {repo.local_path}.
|
|
32
|
+
Repository: {repo.repo_name}
|
|
33
|
+
|
|
34
|
+
LOG FILE: {log_file}
|
|
35
|
+
Read this file first. It contains the last 48h of logs from {event.source} —
|
|
36
|
+
use it to understand the frequency, surrounding context, and any warnings
|
|
37
|
+
that preceded this error.
|
|
38
|
+
|
|
39
|
+
ERROR fingerprint to fix (from {event.source}):
|
|
40
|
+
{event.full_text()}
|
|
41
|
+
|
|
42
|
+
Task:
|
|
43
|
+
1. Read the log file above to understand what led up to this error.
|
|
44
|
+
2. Use your available tools to explore the codebase and identify the root cause.
|
|
45
|
+
3. Output ONLY a unified diff patch (git diff format) fixing the issue.
|
|
46
|
+
4. Do not explain. Output only the patch.
|
|
47
|
+
5. If you cannot determine a safe fix, output: SKIP: <reason>
|
|
48
|
+
""")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _extract_patch(output: str) -> str | None:
|
|
52
|
+
m = _DIFF_BLOCK.search(output)
|
|
53
|
+
if m:
|
|
54
|
+
return m.group(1).strip()
|
|
55
|
+
if _DIFF_HEADER.search(output):
|
|
56
|
+
return output.strip()
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _validate_patch(patch: str) -> tuple[bool, str]:
|
|
61
|
+
files_changed = len(re.findall(r"^diff --git", patch, re.MULTILINE))
|
|
62
|
+
lines_changed = len([
|
|
63
|
+
l for l in patch.splitlines()
|
|
64
|
+
if l.startswith(("+", "-")) and not l.startswith(("+++", "---"))
|
|
65
|
+
])
|
|
66
|
+
if files_changed > MAX_FILES_IN_PATCH:
|
|
67
|
+
return False, f"Patch touches {files_changed} files (limit {MAX_FILES_IN_PATCH})"
|
|
68
|
+
if lines_changed > MAX_LINES_IN_PATCH:
|
|
69
|
+
return False, f"Patch changes {lines_changed} lines (limit {MAX_LINES_IN_PATCH})"
|
|
70
|
+
return True, ""
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def generate_fix(
|
|
74
|
+
event: ErrorEvent,
|
|
75
|
+
repo: RepoConfig,
|
|
76
|
+
cfg: SentinelConfig,
|
|
77
|
+
patches_dir: Path,
|
|
78
|
+
) -> tuple[str, Path | None]:
|
|
79
|
+
"""
|
|
80
|
+
Generate a fix for the given error event.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
(status, patch_path)
|
|
84
|
+
status: "patch" | "skip" | "error"
|
|
85
|
+
"""
|
|
86
|
+
log_file = Path(cfg.workspace_dir) / "fetched" / f"{event.source}.log"
|
|
87
|
+
prompt = _build_prompt(event, repo, log_file)
|
|
88
|
+
|
|
89
|
+
logger.info("Invoking Claude Code for %s (fp=%s)", event.source, event.fingerprint)
|
|
90
|
+
import os as _os
|
|
91
|
+
env = _os.environ.copy()
|
|
92
|
+
if cfg.anthropic_api_key:
|
|
93
|
+
env["ANTHROPIC_API_KEY"] = cfg.anthropic_api_key
|
|
94
|
+
try:
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
[cfg.claude_code_bin, "--print", prompt],
|
|
97
|
+
capture_output=True, text=True, timeout=SUBPROCESS_TIMEOUT, env=env,
|
|
98
|
+
)
|
|
99
|
+
except subprocess.TimeoutExpired:
|
|
100
|
+
logger.error("Claude Code timed out for %s", event.fingerprint)
|
|
101
|
+
return "error", None
|
|
102
|
+
except FileNotFoundError:
|
|
103
|
+
logger.error("Claude Code binary not found at '%s'", cfg.claude_code_bin)
|
|
104
|
+
return "error", None
|
|
105
|
+
|
|
106
|
+
output = (result.stdout or "") + (result.stderr or "")
|
|
107
|
+
|
|
108
|
+
if output.strip().upper().startswith("SKIP:"):
|
|
109
|
+
reason = output.strip()[5:].strip()
|
|
110
|
+
logger.info("Claude skipped fix for %s: %s", event.fingerprint, reason)
|
|
111
|
+
return "skip", None
|
|
112
|
+
|
|
113
|
+
patch = _extract_patch(output)
|
|
114
|
+
if not patch:
|
|
115
|
+
logger.warning("No patch found in Claude output for %s", event.fingerprint)
|
|
116
|
+
return "error", None
|
|
117
|
+
|
|
118
|
+
ok, reason = _validate_patch(patch)
|
|
119
|
+
if not ok:
|
|
120
|
+
logger.warning("Patch rejected for %s: %s", event.fingerprint, reason)
|
|
121
|
+
return "skip", None
|
|
122
|
+
|
|
123
|
+
patches_dir.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
patch_path = patches_dir / f"{event.fingerprint}.diff"
|
|
125
|
+
patch_path.write_text(patch, encoding="utf-8")
|
|
126
|
+
logger.info("Patch written to %s", patch_path)
|
|
127
|
+
return "patch", patch_path
|