claude-controller 0.1.2 → 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 +1189 -0
- package/bin/native-app.py +6 -3
- 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 +11 -5
- 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 +634 -473
- package/web/handler_fs.py +153 -0
- package/web/handler_goals.py +203 -0
- package/web/handler_jobs.py +372 -0
- package/web/handler_memory.py +203 -0
- package/web/handler_sessions.py +132 -0
- package/web/jobs.py +585 -13
- package/web/personas.py +419 -0
- package/web/pipeline.py +981 -0
- package/web/presets.py +506 -0
- package/web/projects.py +246 -0
- package/web/static/api.js +141 -0
- package/web/static/app.js +25 -1937
- package/web/static/attachments.js +144 -0
- package/web/static/base.css +497 -0
- package/web/static/context.js +204 -0
- package/web/static/dirs.js +246 -0
- package/web/static/form.css +763 -0
- package/web/static/goals.css +363 -0
- package/web/static/goals.js +300 -0
- package/web/static/i18n.js +625 -0
- package/web/static/index.html +215 -13
- package/web/static/{styles.css → jobs.css} +746 -1141
- package/web/static/jobs.js +1270 -0
- package/web/static/memoryview.js +117 -0
- package/web/static/personas.js +228 -0
- package/web/static/pipeline.css +338 -0
- package/web/static/pipelines.js +487 -0
- package/web/static/presets.js +244 -0
- package/web/static/send.js +135 -0
- package/web/static/settings-style.css +291 -0
- package/web/static/settings.js +81 -0
- package/web/static/stream.js +534 -0
- package/web/static/utils.js +131 -0
- package/web/webhook.py +210 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════
|
|
2
|
+
Utility Functions
|
|
3
|
+
═══════════════════════════════════════════════ */
|
|
4
|
+
|
|
5
|
+
function showToast(message, type = 'success') {
|
|
6
|
+
const container = document.getElementById('toastContainer');
|
|
7
|
+
const toast = document.createElement('div');
|
|
8
|
+
const duration = type === 'error' ? 6000 : 3000;
|
|
9
|
+
toast.className = `toast ${type}`;
|
|
10
|
+
toast.style.setProperty('--toast-duration', `${duration}ms`);
|
|
11
|
+
const icon = type === 'success'
|
|
12
|
+
? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"/></svg>'
|
|
13
|
+
: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/></svg>';
|
|
14
|
+
toast.innerHTML = `${icon} <span class="toast-msg">${escapeHtml(message)}</span><span class="toast-close">×</span>`;
|
|
15
|
+
toast.addEventListener('click', () => {
|
|
16
|
+
toast.style.animation = 'toastOut 0.2s ease forwards';
|
|
17
|
+
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 200);
|
|
18
|
+
});
|
|
19
|
+
container.appendChild(toast);
|
|
20
|
+
setTimeout(() => { if (toast.parentNode) toast.remove(); }, duration);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function escapeHtml(str) {
|
|
24
|
+
if (!str) return '';
|
|
25
|
+
const d = document.createElement('div');
|
|
26
|
+
d.textContent = str;
|
|
27
|
+
return d.innerHTML;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** JS 문자열 이스케이프 — onclick 핸들러 내 싱글쿼트 문자열에서 사용 */
|
|
31
|
+
function escapeJsStr(str) {
|
|
32
|
+
if (!str) return '';
|
|
33
|
+
return str.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function truncate(str, len = 60) {
|
|
37
|
+
if (!str) return '-';
|
|
38
|
+
return str.length > len ? str.slice(0, len) + '...' : str;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function renderPromptHtml(prompt) {
|
|
42
|
+
if (!prompt) return '-';
|
|
43
|
+
const text = truncate(prompt, 200);
|
|
44
|
+
const escaped = escapeHtml(text);
|
|
45
|
+
return escaped.replace(/@(\/[^\s,]+|image\d+)/g, (match, ref) => {
|
|
46
|
+
const isImage = ref.startsWith('image');
|
|
47
|
+
const label = isImage ? ref : ref.split('/').pop();
|
|
48
|
+
const icon = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"/><polyline points="13 2 13 9 20 9"/></svg>';
|
|
49
|
+
return `<span class="prompt-img-chip" title="${escapeHtml('@' + ref)}">${icon}${escapeHtml(label)}</span>`;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function formatTime(ts) {
|
|
54
|
+
if (!ts) return '-';
|
|
55
|
+
try {
|
|
56
|
+
const d = new Date(ts);
|
|
57
|
+
if (isNaN(d.getTime())) return ts;
|
|
58
|
+
const pad = n => String(n).padStart(2, '0');
|
|
59
|
+
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
|
60
|
+
} catch { return ts; }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function formatCwd(cwd) {
|
|
64
|
+
if (!cwd) return '-';
|
|
65
|
+
const parts = cwd.replace(/\/$/, '').split('/');
|
|
66
|
+
return parts[parts.length - 1] || cwd;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatElapsed(startTs) {
|
|
70
|
+
if (!startTs) return '--:--';
|
|
71
|
+
const start = new Date(startTs).getTime();
|
|
72
|
+
if (isNaN(start)) return '--:--';
|
|
73
|
+
const elapsed = Math.max(0, Math.floor((Date.now() - start) / 1000));
|
|
74
|
+
const h = Math.floor(elapsed / 3600);
|
|
75
|
+
const m = Math.floor((elapsed % 3600) / 60);
|
|
76
|
+
const s = elapsed % 60;
|
|
77
|
+
const pad = n => String(n).padStart(2, '0');
|
|
78
|
+
if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
|
|
79
|
+
return `${pad(m)}:${pad(s)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function formatFileSize(bytes) {
|
|
83
|
+
if (bytes < 1024) return bytes + ' B';
|
|
84
|
+
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
85
|
+
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function getFileExt(filename) {
|
|
89
|
+
const dot = filename.lastIndexOf('.');
|
|
90
|
+
return dot >= 0 ? filename.slice(dot + 1).toUpperCase() : '?';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* ── Desktop Notification ── */
|
|
94
|
+
function requestNotificationPermission() {
|
|
95
|
+
if ('Notification' in window && Notification.permission === 'default') {
|
|
96
|
+
Notification.requestPermission();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function notifyJobDone(jobId, status, prompt) {
|
|
101
|
+
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
|
102
|
+
if (document.hasFocus()) return;
|
|
103
|
+
const title = status === 'done' ? `Job #${jobId} 완료` : `Job #${jobId} 실패`;
|
|
104
|
+
const body = truncate(prompt || '', 80);
|
|
105
|
+
const icon = status === 'done'
|
|
106
|
+
? 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="80" font-size="80">%E2%9C%85</text></svg>'
|
|
107
|
+
: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text y="80" font-size="80">%E2%9D%8C</text></svg>';
|
|
108
|
+
try {
|
|
109
|
+
const n = new Notification(title, { body, icon, tag: `job-${jobId}` });
|
|
110
|
+
n.onclick = () => { window.focus(); toggleJobExpand(String(jobId)); n.close(); };
|
|
111
|
+
setTimeout(() => n.close(), 8000);
|
|
112
|
+
} catch { /* silent */ }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/* ── Duration formatting ── */
|
|
116
|
+
function formatDuration(durationMs) {
|
|
117
|
+
if (durationMs == null) return '';
|
|
118
|
+
const sec = durationMs / 1000;
|
|
119
|
+
return sec < 60 ? `${sec.toFixed(1)}s` : `${Math.floor(sec / 60)}m ${Math.round(sec % 60)}s`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ── Theme ── */
|
|
123
|
+
function applyTheme(theme) {
|
|
124
|
+
document.documentElement.setAttribute('data-theme', theme);
|
|
125
|
+
localStorage.setItem('theme', theme);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function toggleTheme() {
|
|
129
|
+
const current = document.documentElement.getAttribute('data-theme') || 'dark';
|
|
130
|
+
applyTheme(current === 'dark' ? 'light' : 'dark');
|
|
131
|
+
}
|
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()
|