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.
- package/README.md +2 -2
- package/bin/autoloop.sh +382 -0
- package/bin/ctl +327 -5
- package/bin/native-app.py +5 -2
- package/bin/watchdog.sh +357 -0
- package/cognitive/__init__.py +14 -0
- package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
- package/cognitive/dispatcher.py +192 -0
- package/cognitive/evaluator.py +289 -0
- package/cognitive/goal_engine.py +232 -0
- package/cognitive/learning.py +189 -0
- package/cognitive/orchestrator.py +303 -0
- package/cognitive/planner.py +207 -0
- package/cognitive/prompts/analyst.md +31 -0
- package/cognitive/prompts/coder.md +22 -0
- package/cognitive/prompts/reviewer.md +33 -0
- package/cognitive/prompts/tester.md +21 -0
- package/cognitive/prompts/writer.md +25 -0
- package/config.sh +6 -1
- package/dag/__init__.py +5 -0
- package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
- package/dag/__pycache__/graph.cpython-314.pyc +0 -0
- package/dag/graph.py +222 -0
- package/lib/jobs.sh +12 -1
- package/package.json +5 -1
- package/postinstall.sh +1 -1
- package/service/controller.sh +43 -11
- package/web/audit.py +122 -0
- package/web/checkpoint.py +80 -0
- package/web/config.py +2 -5
- package/web/handler.py +464 -26
- package/web/handler_fs.py +15 -14
- package/web/handler_goals.py +203 -0
- package/web/handler_jobs.py +165 -42
- package/web/handler_memory.py +203 -0
- package/web/jobs.py +576 -12
- package/web/personas.py +419 -0
- package/web/pipeline.py +682 -50
- package/web/presets.py +506 -0
- package/web/projects.py +58 -4
- package/web/static/api.js +90 -3
- package/web/static/app.js +8 -0
- package/web/static/base.css +51 -12
- package/web/static/context.js +14 -4
- package/web/static/form.css +3 -2
- package/web/static/goals.css +363 -0
- package/web/static/goals.js +300 -0
- package/web/static/i18n.js +288 -0
- package/web/static/index.html +142 -6
- package/web/static/jobs.css +951 -4
- package/web/static/jobs.js +890 -54
- package/web/static/memoryview.js +117 -0
- package/web/static/personas.js +228 -0
- package/web/static/pipeline.css +308 -1
- package/web/static/pipelines.js +249 -14
- package/web/static/presets.js +244 -0
- package/web/static/send.js +26 -4
- package/web/static/settings-style.css +34 -3
- package/web/static/settings.js +37 -1
- package/web/static/stream.js +242 -19
- package/web/static/utils.js +54 -2
- 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()
|