@misterhuydo/sentinel 1.0.5 → 1.0.6

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 CHANGED
@@ -73,7 +73,7 @@ if echo "$AUTH_OUT" | grep -Eqi "not logged in|/login"; then
73
73
  exit 1
74
74
  fi
75
75
 
76
- mkdir -p "$DIR/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches"
76
+ mkdir -p "$DIR/logs" "$DIR/workspace/fetched" "$DIR/workspace/patches" "$DIR/issues"
77
77
  cd "$DIR"
78
78
  PYTHONPATH="${codeDir}" "${pythonBin}" -m sentinel.main --config ./config \\
79
79
  >> "$DIR/logs/sentinel.log" 2>&1 &
@@ -109,17 +109,36 @@ rm -f "$PID_FILE"
109
109
  function generateWorkspaceScripts(workspace) {
110
110
  // startAll.sh
111
111
  fs.writeFileSync(path.join(workspace, 'startAll.sh'), `#!/usr/bin/env bash
112
- # Start all Sentinel project instances
112
+ # Start all valid Sentinel project instances.
113
+ # A valid project must have config/repo-configs/*.properties with a GitHub REPO_URL.
113
114
  WORKSPACE="$(cd "$(dirname "$0")" && pwd)"
114
115
  started=0
116
+ skipped=0
115
117
  for project_dir in "$WORKSPACE"/*/; do
116
118
  name=$(basename "$project_dir")
117
119
  [[ "$name" == "code" ]] && continue
118
120
  [[ -f "$project_dir/start.sh" ]] || continue
121
+
122
+ # Must have at least one repo-config with a valid GitHub REPO_URL
123
+ valid_repo=false
124
+ for props in "$project_dir/config/repo-configs/"*.properties 2>/dev/null; do
125
+ [[ -f "$props" ]] || continue
126
+ if grep -qE "^REPO_URL[[:space:]]*=[[:space:]]*(git@github\.com:|https://github\.com/)" "$props"; then
127
+ valid_repo=true
128
+ break
129
+ fi
130
+ done
131
+
132
+ if [[ "$valid_repo" == "false" ]]; then
133
+ echo "[sentinel] Skipping $name — no valid REPO_URL found in config/repo-configs/"
134
+ skipped=$((skipped + 1))
135
+ continue
136
+ fi
137
+
119
138
  bash "$project_dir/start.sh"
120
139
  started=$((started + 1))
121
140
  done
122
- echo "[sentinel] $started project(s) started"
141
+ echo "[sentinel] $started project(s) started, $skipped skipped"
123
142
  `, { mode: 0o755 });
124
143
 
125
144
  // stopAll.sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@misterhuydo/sentinel",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Sentinel — Autonomous DevOps Agent installer and manager",
5
5
  "bin": {
6
6
  "sentinel": "./bin/sentinel.js"
@@ -26,36 +26,39 @@ _DIFF_BLOCK = re.compile(r"```(?:diff|patch)?\n(.*?)```", re.DOTALL)
26
26
  _DIFF_HEADER = re.compile(r"^diff --git|^---\s+\S+|^\+\+\+\s+\S+", re.MULTILINE)
27
27
 
28
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
-
29
+ def _build_prompt(event, repo: RepoConfig, log_file) -> str:
30
+ if log_file and log_file.exists():
31
+ ctx = (
32
+ "LOG FILE: " + str(log_file) + "\n"
33
+ "Read this file first -- it contains the last 48h of logs from "
34
+ + event.source + ".\n"
35
+ "Use it to understand frequency, context, and preceding warnings."
36
+ )
37
+ step1 = "Read the log file above to understand what led up to this error."
38
+ else:
39
+ ctx = (
40
+ "SOURCE: " + event.source + "\n"
41
+ "No rolling log file available. The full issue description is below."
42
+ )
43
+ step1 = "Use the issue description above as your primary context."
44
+
45
+ lines_out = [
46
+ f"You are fixing a production bug in the repository at {repo.local_path}.",
47
+ f"Repository: {repo.repo_name}",
48
+ "",
49
+ ctx,
50
+ "",
51
+ f"ISSUE TO FIX (from {event.source}):",
52
+ event.full_text(),
53
+ "",
54
+ "Task:",
55
+ f"1. {step1}",
56
+ "2. Use your available tools to explore the codebase and identify the root cause.",
57
+ "3. Output ONLY a unified diff patch (git diff format) fixing the issue.",
58
+ "4. Do not explain. Output only the patch.",
59
+ "5. If you cannot determine a safe fix, output: SKIP: <reason>",
60
+ ]
61
+ return "\n".join(lines_out)
59
62
 
60
63
  def _validate_patch(patch: str) -> tuple[bool, str]:
61
64
  files_changed = len(re.findall(r"^diff --git", patch, re.MULTILINE))
@@ -83,7 +86,10 @@ def generate_fix(
83
86
  (status, patch_path)
84
87
  status: "patch" | "skip" | "error"
85
88
  """
89
+ # Issues have source like "issues/filename" — no rolling log file exists
86
90
  log_file = Path(cfg.workspace_dir) / "fetched" / f"{event.source}.log"
91
+ if not log_file.exists():
92
+ log_file = None
87
93
  prompt = _build_prompt(event, repo, log_file)
88
94
 
89
95
  logger.info("Invoking Claude Code for %s (fp=%s)", event.source, event.fingerprint)
@@ -0,0 +1,131 @@
1
+ """
2
+ issue_watcher.py — Scan the issues/ directory for manually-submitted bug reports.
3
+
4
+ Admins drop plain-text or markdown files into <project>/issues/.
5
+ Each file is treated as a fix request. Processed files are archived to issues/.done/.
6
+
7
+ File format (TARGET_REPO header is optional):
8
+
9
+ TARGET_REPO: my-repo-name
10
+
11
+ Short summary of the problem (becomes the email subject line)
12
+
13
+ Any details: customer feedback, stack traces, screenshots text, etc.
14
+ If TARGET_REPO is omitted and only one repo is configured, it is used automatically.
15
+ """
16
+
17
+ import hashlib
18
+ import logging
19
+ import time
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime, timezone
22
+ from pathlib import Path
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+ _TARGET_REPO_PREFIX = "TARGET_REPO:"
27
+
28
+
29
+ @dataclass
30
+ class IssueEvent:
31
+ """
32
+ A fix request sourced from the issues/ directory.
33
+ Implements the same interface as ErrorEvent so it can flow through
34
+ the same fix pipeline (_handle_error / generate_fix / git_manager).
35
+ """
36
+ source: str # "issues/<filename>" — shown in emails and logs
37
+ issue_file: Path # full path, used for archiving after processing
38
+ message: str # first non-blank body line — used as subject summary
39
+ body: str # full file content (the issue description)
40
+ target_repo: str # explicit TARGET_REPO value, or "" for auto-select
41
+ fingerprint: str = ""
42
+ severity: str = "ERROR"
43
+ timestamp: str = ""
44
+
45
+ # Compatibility fields matching ErrorEvent interface
46
+ level: str = "ERROR"
47
+ thread: str = ""
48
+ logger_name: str = ""
49
+ stack_trace: list[str] = field(default_factory=list)
50
+ log_file: str = ""
51
+
52
+ def __post_init__(self):
53
+ if not self.fingerprint:
54
+ raw = f"issue:{self.source}:{self.message[:200]}"
55
+ self.fingerprint = hashlib.sha1(raw.encode()).hexdigest()[:16]
56
+ if not self.timestamp:
57
+ self.timestamp = datetime.now(timezone.utc).isoformat()
58
+ if not self.stack_trace:
59
+ self.stack_trace = self.body.splitlines()
60
+
61
+ @property
62
+ def is_infra_issue(self) -> bool:
63
+ return False
64
+
65
+ def short_summary(self) -> str:
66
+ return self.message[:120]
67
+
68
+ def full_text(self) -> str:
69
+ return self.body
70
+
71
+
72
+ def scan_issues(project_dir: Path) -> list[IssueEvent]:
73
+ """
74
+ Return all pending issue files from <project_dir>/issues/.
75
+ Files starting with '.' and files inside .done/ are skipped.
76
+ """
77
+ issues_dir = project_dir / "issues"
78
+ if not issues_dir.exists():
79
+ return []
80
+
81
+ events = []
82
+ for f in sorted(issues_dir.iterdir()):
83
+ if not f.is_file() or f.name.startswith("."):
84
+ continue
85
+
86
+ try:
87
+ content = f.read_text(encoding="utf-8", errors="replace").strip()
88
+ except OSError as e:
89
+ logger.error("Cannot read issue file %s: %s", f, e)
90
+ continue
91
+
92
+ if not content:
93
+ continue
94
+
95
+ lines = content.splitlines()
96
+ target_repo = ""
97
+ body_start = 0
98
+
99
+ # Parse optional TARGET_REPO: header (must be the first non-blank line)
100
+ for i, line in enumerate(lines):
101
+ stripped = line.strip()
102
+ if stripped.upper().startswith(_TARGET_REPO_PREFIX):
103
+ target_repo = stripped[len(_TARGET_REPO_PREFIX):].strip()
104
+ body_start = i + 1
105
+ elif stripped:
106
+ break
107
+
108
+ body = "\n".join(lines[body_start:]).strip() or content
109
+ message = next((l.strip() for l in lines[body_start:] if l.strip()), f.name)
110
+
111
+ events.append(IssueEvent(
112
+ source=f"issues/{f.name}",
113
+ issue_file=f,
114
+ message=message,
115
+ body=body,
116
+ target_repo=target_repo,
117
+ ))
118
+ logger.info("Found issue: %s (target_repo=%r)", f.name, target_repo or "auto")
119
+
120
+ return events
121
+
122
+
123
+ def mark_done(issue_file: Path) -> None:
124
+ """Archive a processed issue to issues/.done/ regardless of outcome."""
125
+ done_dir = issue_file.parent / ".done"
126
+ done_dir.mkdir(exist_ok=True)
127
+ dest = done_dir / issue_file.name
128
+ if dest.exists():
129
+ dest = done_dir / f"{issue_file.stem}-{int(time.time())}{issue_file.suffix}"
130
+ issue_file.rename(dest)
131
+ logger.info("Issue archived: %s -> .done/%s", issue_file.name, dest.name)
@@ -22,8 +22,9 @@ from .git_manager import apply_and_commit, publish
22
22
  from .cicd_trigger import trigger as cicd_trigger
23
23
  from .log_fetcher import fetch_all
24
24
  from .log_parser import parse_all, ErrorEvent
25
+ from .issue_watcher import scan_issues, mark_done, IssueEvent
25
26
  from .repo_router import route
26
- from .reporter import build_and_send, send_fix_notification
27
+ from .reporter import build_and_send, send_fix_notification, send_failure_notification
27
28
  from .state_store import StateStore
28
29
 
29
30
  logging.basicConfig(
@@ -83,12 +84,27 @@ async def _handle_error(event: ErrorEvent, cfg_loader: ConfigLoader, store: Stat
83
84
  status, patch_path = generate_fix(event, repo, sentinel, patches_dir)
84
85
 
85
86
  if status != "patch" or patch_path is None:
86
- store.record_fix(event.fingerprint, "skipped" if status == "skip" else "failed", repo_name=repo.repo_name)
87
+ outcome = "skipped" if status == "skip" else "failed"
88
+ store.record_fix(event.fingerprint, outcome, repo_name=repo.repo_name)
89
+ send_failure_notification(sentinel, {
90
+ "source": event.source,
91
+ "message": event.message,
92
+ "repo_name": repo.repo_name,
93
+ "reason": f"Claude Code returned {status.upper()}",
94
+ "body": event.full_text()[:500],
95
+ })
87
96
  return
88
97
 
89
98
  commit_status, commit_hash = apply_and_commit(event, patch_path, repo, sentinel)
90
99
  if commit_status != "committed":
91
100
  store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
101
+ send_failure_notification(sentinel, {
102
+ "source": event.source,
103
+ "message": event.message,
104
+ "repo_name": repo.repo_name,
105
+ "reason": "patch generated but commit/tests failed",
106
+ "body": event.full_text()[:500],
107
+ })
92
108
  return
93
109
 
94
110
  branch, pr_url = publish(event, repo, sentinel, commit_hash)
@@ -123,28 +139,129 @@ async def _handle_error(event: ErrorEvent, cfg_loader: ConfigLoader, store: Stat
123
139
 
124
140
  # ── Poll cycle ────────────────────────────────────────────────────────────────
125
141
 
126
- async def poll_cycle(cfg_loader: ConfigLoader, store: StateStore):
127
- global _report_requested
128
142
 
129
- sources = list(cfg_loader.log_sources.values())
130
- if not sources:
131
- logger.warning("No log-configs found")
143
+ # ── Issue pipeline ────────────────────────────────────────────────────────────
144
+
145
+ async def _handle_issue(event: IssueEvent, cfg_loader: ConfigLoader, store: StateStore):
146
+ """Process a single issue file from the issues/ directory."""
147
+ sentinel = cfg_loader.sentinel
148
+
149
+ if Path("SENTINEL_PAUSE").exists():
150
+ logger.info("SENTINEL_PAUSE present -- fix activity halted")
132
151
  return
133
152
 
134
- logger.info("Fetching logs from %d source(s)...", len(sources))
135
- fetched = await fetch_all(sources, cfg_loader.sentinel)
153
+ if store.fix_attempted_recently(event.fingerprint, hours=24):
154
+ logger.debug("Issue already processed recently: %s", event.source)
155
+ mark_done(event.issue_file)
156
+ return
157
+
158
+ # Route: explicit TARGET_REPO in file > single-repo shortcut > warn and leave
159
+ if event.target_repo:
160
+ repo = cfg_loader.repos.get(event.target_repo)
161
+ if not repo:
162
+ logger.warning("TARGET_REPO %r not found in config -- leaving %s for admin",
163
+ event.target_repo, event.source)
164
+ return
165
+ elif len(cfg_loader.repos) == 1:
166
+ repo = next(iter(cfg_loader.repos.values()))
167
+ else:
168
+ logger.warning(
169
+ "Cannot auto-route %s -- add 'TARGET_REPO: <repo>' as first line in the file",
170
+ event.source,
171
+ )
172
+ return # Leave the file so admin can add the header
173
+
174
+ patches_dir = Path(sentinel.workspace_dir) / "patches"
175
+ status, patch_path = generate_fix(event, repo, sentinel, patches_dir)
176
+
177
+ if status != "patch" or patch_path is None:
178
+ store.record_fix(event.fingerprint, "skipped" if status == "skip" else "failed",
179
+ repo_name=repo.repo_name)
180
+ send_failure_notification(sentinel, {
181
+ "source": event.source,
182
+ "message": event.message,
183
+ "repo_name": repo.repo_name,
184
+ "reason": f"Claude Code returned {status.upper()}",
185
+ "body": event.body[:500],
186
+ })
187
+ mark_done(event.issue_file)
188
+ return
136
189
 
137
- events = parse_all(fetched, cfg_loader.log_sources)
138
- logger.info("Parsed %d error/warn events", len(events))
190
+ commit_status, commit_hash = apply_and_commit(event, patch_path, repo, sentinel)
191
+ if commit_status != "committed":
192
+ store.record_fix(event.fingerprint, "failed", repo_name=repo.repo_name)
193
+ send_failure_notification(sentinel, {
194
+ "source": event.source,
195
+ "message": event.message,
196
+ "repo_name": repo.repo_name,
197
+ "reason": "patch generated but commit/tests failed",
198
+ "body": event.body[:500],
199
+ })
200
+ mark_done(event.issue_file)
201
+ return
139
202
 
140
- new_events = []
141
- for event in events:
142
- store.record_error(event.fingerprint, event.source, event.message)
143
- if not store.fix_attempted_recently(event.fingerprint):
144
- new_events.append(event)
203
+ branch, pr_url = publish(event, repo, sentinel, commit_hash)
204
+ store.record_fix(
205
+ event.fingerprint,
206
+ "applied" if repo.auto_publish else "pending",
207
+ patch_path=str(patch_path),
208
+ commit_hash=commit_hash,
209
+ branch=branch,
210
+ pr_url=pr_url,
211
+ repo_name=repo.repo_name,
212
+ )
213
+ send_fix_notification(sentinel, {
214
+ "source": event.source,
215
+ "severity": "ERROR",
216
+ "fingerprint": event.fingerprint,
217
+ "first_seen": event.timestamp,
218
+ "message": event.message,
219
+ "stack_trace": event.body,
220
+ "repo_name": repo.repo_name,
221
+ "commit_hash": commit_hash,
222
+ "branch": branch,
223
+ "pr_url": pr_url,
224
+ "auto_publish": repo.auto_publish,
225
+ "files_changed": [],
226
+ })
227
+ mark_done(event.issue_file)
228
+
229
+ if repo.auto_publish:
230
+ cicd_trigger(repo, store, event.fingerprint)
231
+
232
+
233
+ async def poll_cycle(cfg_loader: ConfigLoader, store: StateStore):
234
+ global _report_requested
235
+
236
+ # ── Log sources (optional) ────────────────────────────────────────────────
237
+ sources = list(cfg_loader.log_sources.values())
238
+ if sources:
239
+ logger.info("Fetching logs from %d source(s)...", len(sources))
240
+ fetched = await fetch_all(sources, cfg_loader.sentinel)
241
+ events = parse_all(fetched, cfg_loader.log_sources)
242
+ logger.info("Parsed %d error/warn events", len(events))
243
+
244
+ new_events = []
245
+ for event in events:
246
+ store.record_error(event.fingerprint, event.source, event.message)
247
+ if not store.fix_attempted_recently(event.fingerprint):
248
+ new_events.append(event)
249
+
250
+ if new_events:
251
+ logger.info("%d new log event(s) to process", len(new_events))
252
+ await asyncio.gather(
253
+ *[_handle_error(e, cfg_loader, store) for e in new_events],
254
+ return_exceptions=True,
255
+ )
145
256
 
146
- logger.info("%d new event(s) to process", len(new_events))
147
- await asyncio.gather(*[_handle_error(e, cfg_loader, store) for e in new_events], return_exceptions=True)
257
+ # ── Issues directory (always checked) ────────────────────────────────────
258
+ issues = scan_issues(Path("."))
259
+ if issues:
260
+ logger.info("%d issue file(s) found in issues/", len(issues))
261
+ await asyncio.gather(
262
+ *[_handle_issue(e, cfg_loader, store) for e in issues],
263
+ return_exceptions=True,
264
+ )
148
265
 
149
266
  if cfg_loader.sentinel.send_health and (_report_requested or _report_due(cfg_loader, store)):
150
267
  _report_requested = False
@@ -216,6 +333,7 @@ def main():
216
333
  Path("logs").mkdir(exist_ok=True)
217
334
  Path("workspace/fetched").mkdir(parents=True, exist_ok=True)
218
335
  Path("workspace/patches").mkdir(parents=True, exist_ok=True)
336
+ Path("issues").mkdir(exist_ok=True)
219
337
 
220
338
  parser = argparse.ArgumentParser(description="Sentinel — Autonomous DevOps Agent")
221
339
  parser.add_argument("--init", action="store_true", help="First-time setup")
@@ -136,3 +136,53 @@ def _age(ts_str: str) -> str:
136
136
  return f"{int(delta.total_seconds() // 60)}m" if hours < 1 else f"{hours}h"
137
137
  except Exception:
138
138
  return "?"
139
+
140
+ def send_failure_notification(cfg: SentinelConfig, details: dict):
141
+ """
142
+ Notify admins when Claude Code cannot fix a problem (from logs or issues/).
143
+
144
+ details dict keys: source, message, repo_name, reason, body
145
+ """
146
+ if not cfg.mails:
147
+ return
148
+
149
+ source = details.get('source', 'unknown')
150
+ repo_name = details.get('repo_name', 'unknown')
151
+ reason = details.get('reason', 'unknown')
152
+ message = details.get('message', '')
153
+ body = details.get('body', '')[:1000]
154
+ ts = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')
155
+
156
+ subject = f'[Sentinel] UNRESOLVED ({source}): {message[:80]}'
157
+
158
+ ctx_html = f'<h3>Context</h3><pre>{body}</pre>' if body else ''
159
+ html = (
160
+ '<!DOCTYPE html><html><head><meta charset="utf-8">'
161
+ '<style>'
162
+ 'body{font-family:Arial,sans-serif;font-size:14px;color:#222}'
163
+ 'h2{color:#c62828}'
164
+ 'h3{color:#444;border-bottom:1px solid #ddd;padding-bottom:4px}'
165
+ 'table{border-collapse:collapse;width:100%;margin-bottom:16px}'
166
+ 'th{background:#f1f3f4;text-align:left;padding:6px 10px}'
167
+ 'td{padding:5px 10px;border-bottom:1px solid #eee;vertical-align:top}'
168
+ '.label{font-weight:bold;width:160px}'
169
+ '.mono{font-family:monospace;font-size:12px}'
170
+ 'pre{background:#f8f8f8;border:1px solid #ddd;padding:10px;font-size:12px;white-space:pre-wrap}'
171
+ '</style></head><body>'
172
+ '<h2>&#x26A0; Sentinel could not fix this issue</h2>'
173
+ f'<p><strong>{repo_name}</strong> &middot; {ts}</p>'
174
+ '<h3>Details</h3>'
175
+ '<table>'
176
+ f'<tr><td class="label">Source</td><td class="mono">{source}</td></tr>'
177
+ f'<tr><td class="label">Repository</td><td class="mono">{repo_name}</td></tr>'
178
+ f'<tr><td class="label">Message</td><td class="mono">{message}</td></tr>'
179
+ f'<tr><td class="label">Reason</td><td>{reason}</td></tr>'
180
+ '</table>'
181
+ + ctx_html +
182
+ '<hr><small>Sentinel &mdash; Autonomous DevOps Agent</small>'
183
+ '</body></html>'
184
+ )
185
+
186
+ _send_email(cfg, subject, html)
187
+ logger.info('Failure notification sent for %s', source)
188
+