@misterhuydo/sentinel 1.0.0 → 1.0.2
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 +20 -4
- package/lib/init.js +46 -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
|
|
|
@@ -25,7 +31,17 @@ function generateProjectScripts(projectDir, codeDir, pythonBin) {
|
|
|
25
31
|
|
|
26
32
|
// init.sh
|
|
27
33
|
fs.writeFileSync(path.join(projectDir, 'init.sh'), `#!/usr/bin/env bash
|
|
28
|
-
# First-time setup
|
|
34
|
+
# First-time setup for this Sentinel project instance.
|
|
35
|
+
#
|
|
36
|
+
# What this does:
|
|
37
|
+
# - Clones any repos defined in config/repo-configs/ that don't exist locally yet
|
|
38
|
+
# (skips repos that are already cloned — safe to run multiple times)
|
|
39
|
+
# - Indexes each repo with Cairn MCP for codebase context
|
|
40
|
+
# - Tests SSH connectivity to each configured log source
|
|
41
|
+
# - Sends a test email to verify SMTP settings
|
|
42
|
+
#
|
|
43
|
+
# Note: ongoing repo management (git pull, conflict resolution) is handled
|
|
44
|
+
# automatically by Sentinel on each fix cycle — you don't need to do it manually.
|
|
29
45
|
set -euo pipefail
|
|
30
46
|
cd "$(dirname "$0")"
|
|
31
47
|
PYTHONPATH="${codeDir}" "${pythonBin}" -m sentinel.main --config ./config --init
|
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,26 @@ 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(
|
|
105
|
+
'\n Claude Code OAuth requires an interactive step.\n' +
|
|
106
|
+
' After setup completes, run this command on the server:\n\n' +
|
|
107
|
+
chalk.bold(' claude\n\n') +
|
|
108
|
+
' It will print a URL like:\n' +
|
|
109
|
+
' https://claude.ai/oauth/authorize?...\n\n' +
|
|
110
|
+
' Open that URL in any browser, log in with your Claude Pro account,\n' +
|
|
111
|
+
' and the server will be authenticated. The token is stored in ~/.claude/\n' +
|
|
112
|
+
' and persists across restarts.\n'
|
|
113
|
+
));
|
|
114
|
+
warn('OAuth not completed yet — run "claude" after setup to authenticate');
|
|
115
|
+
} else {
|
|
116
|
+
warn('Skipping auth — set ANTHROPIC_API_KEY in sentinel.properties or run "claude" to authenticate');
|
|
117
|
+
}
|
|
118
|
+
|
|
83
119
|
// ── Workspace structure ─────────────────────────────────────────────────────
|
|
84
120
|
step('Creating workspace…');
|
|
85
121
|
fs.ensureDirSync(workspace);
|
|
@@ -89,7 +125,7 @@ module.exports = async function init() {
|
|
|
89
125
|
if (example) {
|
|
90
126
|
step('Creating example project…');
|
|
91
127
|
const exampleDir = path.join(workspace, 'my-project');
|
|
92
|
-
writeExampleProject(exampleDir, codeDir, pythonBin);
|
|
128
|
+
writeExampleProject(exampleDir, codeDir, pythonBin, anthropicKey || '');
|
|
93
129
|
ok(`Example project: ${exampleDir}`);
|
|
94
130
|
}
|
|
95
131
|
|
|
@@ -132,6 +168,14 @@ module.exports = async function init() {
|
|
|
132
168
|
${chalk.cyan('journalctl -u sentinel -f')}
|
|
133
169
|
`);
|
|
134
170
|
}
|
|
171
|
+
if (authMode === 'oauth') {
|
|
172
|
+
console.log(
|
|
173
|
+
chalk.bold.yellow(' ⚠ Complete Claude Code login now:\n') +
|
|
174
|
+
` ${chalk.bold.cyan('claude')}\n` +
|
|
175
|
+
' Open the URL it prints in any browser → log in with Claude Pro.\n'
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
135
179
|
console.log(` Add another project anytime:
|
|
136
180
|
${chalk.cyan('sentinel add <project-name>')}
|
|
137
181
|
`);
|
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
|