claude-controller 0.2.0 → 0.3.0

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 (68) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +327 -5
  4. package/bin/native-app.py +5 -2
  5. package/bin/watchdog.sh +357 -0
  6. package/cognitive/__init__.py +14 -0
  7. package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
  9. package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
  10. package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
  11. package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
  12. package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
  13. package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
  14. package/cognitive/dispatcher.py +192 -0
  15. package/cognitive/evaluator.py +289 -0
  16. package/cognitive/goal_engine.py +232 -0
  17. package/cognitive/learning.py +189 -0
  18. package/cognitive/orchestrator.py +303 -0
  19. package/cognitive/planner.py +207 -0
  20. package/cognitive/prompts/analyst.md +31 -0
  21. package/cognitive/prompts/coder.md +22 -0
  22. package/cognitive/prompts/reviewer.md +33 -0
  23. package/cognitive/prompts/tester.md +21 -0
  24. package/cognitive/prompts/writer.md +25 -0
  25. package/config.sh +6 -1
  26. package/dag/__init__.py +5 -0
  27. package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/dag/__pycache__/graph.cpython-314.pyc +0 -0
  29. package/dag/graph.py +222 -0
  30. package/lib/jobs.sh +12 -1
  31. package/package.json +5 -1
  32. package/postinstall.sh +1 -1
  33. package/service/controller.sh +43 -11
  34. package/web/audit.py +122 -0
  35. package/web/checkpoint.py +80 -0
  36. package/web/config.py +2 -5
  37. package/web/handler.py +464 -26
  38. package/web/handler_fs.py +15 -14
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +165 -42
  41. package/web/handler_memory.py +203 -0
  42. package/web/jobs.py +576 -12
  43. package/web/personas.py +419 -0
  44. package/web/pipeline.py +682 -50
  45. package/web/presets.py +506 -0
  46. package/web/projects.py +58 -4
  47. package/web/static/api.js +90 -3
  48. package/web/static/app.js +8 -0
  49. package/web/static/base.css +51 -12
  50. package/web/static/context.js +14 -4
  51. package/web/static/form.css +3 -2
  52. package/web/static/goals.css +363 -0
  53. package/web/static/goals.js +300 -0
  54. package/web/static/i18n.js +288 -0
  55. package/web/static/index.html +142 -6
  56. package/web/static/jobs.css +951 -4
  57. package/web/static/jobs.js +890 -54
  58. package/web/static/memoryview.js +117 -0
  59. package/web/static/personas.js +228 -0
  60. package/web/static/pipeline.css +308 -1
  61. package/web/static/pipelines.js +249 -14
  62. package/web/static/presets.js +244 -0
  63. package/web/static/send.js +26 -4
  64. package/web/static/settings-style.css +34 -3
  65. package/web/static/settings.js +37 -1
  66. package/web/static/stream.js +242 -19
  67. package/web/static/utils.js +54 -2
  68. package/web/webhook.py +210 -0
package/web/webhook.py ADDED
@@ -0,0 +1,210 @@
1
+ """
2
+ Controller Service — Webhook 전달 모듈
3
+
4
+ 작업 완료/실패 시 등록된 URL로 결과를 POST한다.
5
+ bash(lib/jobs.sh)에서 직접 호출 가능:
6
+ python3 /path/to/webhook.py <job_id> <status>
7
+ """
8
+
9
+ import hashlib
10
+ import hmac
11
+ import json
12
+ import os
13
+ import sys
14
+ import time
15
+ import urllib.request
16
+ import urllib.error
17
+ from pathlib import Path
18
+
19
+
20
+ # 경로 설정 — web/ 기준
21
+ _WEB_DIR = Path(__file__).resolve().parent
22
+ _CONTROLLER_DIR = _WEB_DIR.parent
23
+ _DATA_DIR = _CONTROLLER_DIR / "data"
24
+ _SETTINGS_FILE = _DATA_DIR / "settings.json"
25
+ _LOGS_DIR = _CONTROLLER_DIR / "logs"
26
+ _WEBHOOK_SENT_DIR = _DATA_DIR / "webhook_sent"
27
+
28
+
29
+ def _load_settings():
30
+ try:
31
+ if _SETTINGS_FILE.exists():
32
+ return json.loads(_SETTINGS_FILE.read_text("utf-8"))
33
+ except (json.JSONDecodeError, OSError):
34
+ pass
35
+ return {}
36
+
37
+
38
+ def _build_payload(job_id, status):
39
+ """작업의 메타 + 결과를 읽어서 웹훅 페이로드를 생성한다."""
40
+ meta_file = _LOGS_DIR / f"job_{job_id}.meta"
41
+ out_file = _LOGS_DIR / f"job_{job_id}.out"
42
+
43
+ meta = {}
44
+ if meta_file.exists():
45
+ try:
46
+ for line in meta_file.read_text().splitlines():
47
+ if "=" in line:
48
+ k, v = line.split("=", 1)
49
+ meta[k.strip()] = v.strip().strip("'\"")
50
+ except OSError:
51
+ pass
52
+
53
+ # 결과 추출
54
+ result_text = None
55
+ cost_usd = None
56
+ duration_ms = None
57
+ session_id = None
58
+ is_error = False
59
+
60
+ if out_file.exists():
61
+ try:
62
+ for line in out_file.read_text().splitlines():
63
+ if '"type":"result"' not in line:
64
+ continue
65
+ try:
66
+ obj = json.loads(line)
67
+ if obj.get("type") == "result":
68
+ result_text = obj.get("result")
69
+ cost_usd = obj.get("total_cost_usd")
70
+ duration_ms = obj.get("duration_ms")
71
+ session_id = obj.get("session_id")
72
+ is_error = obj.get("is_error", False)
73
+ except json.JSONDecodeError:
74
+ continue
75
+ except OSError:
76
+ pass
77
+
78
+ return {
79
+ "event": f"job.{status}",
80
+ "timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
81
+ "job": {
82
+ "job_id": job_id,
83
+ "status": status,
84
+ "prompt": meta.get("PROMPT", ""),
85
+ "cwd": meta.get("CWD") or None,
86
+ "created_at": meta.get("CREATED_AT", ""),
87
+ "session_id": session_id or meta.get("SESSION_ID") or None,
88
+ "result": result_text,
89
+ "cost_usd": cost_usd,
90
+ "duration_ms": duration_ms,
91
+ "is_error": is_error,
92
+ },
93
+ }
94
+
95
+
96
+ def _sign_payload(payload_bytes, secret):
97
+ """HMAC-SHA256 서명을 생성한다."""
98
+ return hmac.new(secret.encode("utf-8"), payload_bytes, hashlib.sha256).hexdigest()
99
+
100
+
101
+ def deliver_webhook(job_id, status):
102
+ """설정에 webhook_url이 있으면 결과를 POST한다.
103
+
104
+ Returns:
105
+ dict | None: 전송 결과 또는 None (미설정/중복)
106
+ """
107
+ settings = _load_settings()
108
+ webhook_url = (settings.get("webhook_url") or "").strip()
109
+ if not webhook_url:
110
+ return None
111
+
112
+ # 이벤트 필터: webhook_events가 설정되어 있으면 해당 이벤트만 전송
113
+ webhook_events = settings.get("webhook_events", "done,failed")
114
+ allowed_events = {e.strip() for e in webhook_events.split(",")}
115
+ if status not in allowed_events:
116
+ return None
117
+
118
+ # 중복 전송 방지
119
+ _WEBHOOK_SENT_DIR.mkdir(parents=True, exist_ok=True)
120
+ sent_marker = _WEBHOOK_SENT_DIR / f"{job_id}_{status}"
121
+ if sent_marker.exists():
122
+ return None
123
+
124
+ payload = _build_payload(job_id, status)
125
+ payload_bytes = json.dumps(payload, ensure_ascii=False).encode("utf-8")
126
+
127
+ headers = {
128
+ "Content-Type": "application/json; charset=utf-8",
129
+ "User-Agent": "Controller-Webhook/1.0",
130
+ }
131
+
132
+ # HMAC 서명
133
+ webhook_secret = (settings.get("webhook_secret") or "").strip()
134
+ if webhook_secret:
135
+ sig = _sign_payload(payload_bytes, webhook_secret)
136
+ headers["X-Webhook-Signature"] = f"sha256={sig}"
137
+
138
+ try:
139
+ req = urllib.request.Request(
140
+ webhook_url, data=payload_bytes, headers=headers, method="POST"
141
+ )
142
+ with urllib.request.urlopen(req, timeout=10) as resp:
143
+ resp_status = resp.status
144
+
145
+ # 성공 마커 기록
146
+ sent_marker.write_text(str(int(time.time())))
147
+
148
+ return {
149
+ "delivered": True,
150
+ "url": webhook_url,
151
+ "status_code": resp_status,
152
+ "event": payload["event"],
153
+ }
154
+ except (urllib.error.URLError, OSError, ValueError) as e:
155
+ return {
156
+ "delivered": False,
157
+ "url": webhook_url,
158
+ "error": str(e),
159
+ "event": payload["event"],
160
+ }
161
+
162
+
163
+ def cleanup_sent_markers(max_age_seconds=86400):
164
+ """오래된 전송 마커를 정리한다 (기본 24시간)."""
165
+ if not _WEBHOOK_SENT_DIR.exists():
166
+ return
167
+ now = time.time()
168
+ for f in _WEBHOOK_SENT_DIR.iterdir():
169
+ try:
170
+ if now - f.stat().st_mtime > max_age_seconds:
171
+ f.unlink()
172
+ except OSError:
173
+ pass
174
+
175
+
176
+ # ════════════════════════════════════════════════
177
+ # DAG 디스패치 — 작업 완료 시 후속 pending 작업 자동 실행
178
+ # ════════════════════════════════════════════════
179
+
180
+ def _dispatch_pending_after_completion():
181
+ """작업 완료 후 의존성이 충족된 pending 작업을 디스패치한다."""
182
+ try:
183
+ sys.path.insert(0, str(_WEB_DIR))
184
+ from jobs import dispatch_pending_jobs
185
+ dispatched = dispatch_pending_jobs()
186
+ if dispatched:
187
+ print(f"DAG dispatch: {dispatched}", file=sys.stderr)
188
+ except Exception as e:
189
+ print(f"DAG dispatch error: {e}", file=sys.stderr)
190
+
191
+
192
+ # CLI 진입점: python3 webhook.py <job_id> <status>
193
+ if __name__ == "__main__":
194
+ if len(sys.argv) < 3:
195
+ print(f"Usage: {sys.argv[0]} <job_id> <done|failed>", file=sys.stderr)
196
+ sys.exit(1)
197
+
198
+ _job_id = sys.argv[1]
199
+ _status = sys.argv[2]
200
+
201
+ if _status not in ("done", "failed"):
202
+ print(f"Invalid status: {_status}", file=sys.stderr)
203
+ sys.exit(1)
204
+
205
+ result = deliver_webhook(_job_id, _status)
206
+ if result:
207
+ print(json.dumps(result, ensure_ascii=False))
208
+
209
+ # 작업 완료 후 DAG 의존성 체인 처리
210
+ _dispatch_pending_after_completion()