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
package/web/audit.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""
|
|
2
|
+
감사 로그 — 모든 API 호출을 타임스탬프·IP와 함께 기록하고 조회 API를 제공한다.
|
|
3
|
+
|
|
4
|
+
저장 형식: JSONL (한 줄에 하나의 JSON 객체)
|
|
5
|
+
저장 위치: data/audit.log
|
|
6
|
+
필드: ts, time, method, path, ip, status, duration_ms
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from config import DATA_DIR
|
|
12
|
+
|
|
13
|
+
AUDIT_LOG_FILE = DATA_DIR / "audit.log"
|
|
14
|
+
_ROTATED_FILE = DATA_DIR / "audit.log.1"
|
|
15
|
+
|
|
16
|
+
# 최대 로그 파일 크기 (10 MB) — 초과 시 .1로 로테이션
|
|
17
|
+
MAX_AUDIT_SIZE = 10 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
# 기록 제외 경로 (정적 파일, 페이지 요청)
|
|
20
|
+
_EXCLUDE_PREFIXES = ("/static/", "/uploads/")
|
|
21
|
+
_EXCLUDE_PATHS = {"/", "/index.html", "/favicon.ico"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def log_api_call(method, path, client_ip, status, duration_ms):
|
|
25
|
+
"""API 호출 한 건을 감사 로그에 기록한다.
|
|
26
|
+
|
|
27
|
+
정적 파일 요청과 페이지 로드는 제외되며, /api/ 경로만 기록된다.
|
|
28
|
+
POSIX O_APPEND 모드의 원자적 쓰기를 활용하여 ThreadingHTTPServer에서 안전하다.
|
|
29
|
+
"""
|
|
30
|
+
if path in _EXCLUDE_PATHS:
|
|
31
|
+
return
|
|
32
|
+
for prefix in _EXCLUDE_PREFIXES:
|
|
33
|
+
if path.startswith(prefix):
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
entry = {
|
|
37
|
+
"ts": time.time(),
|
|
38
|
+
"time": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
39
|
+
"method": method,
|
|
40
|
+
"path": path,
|
|
41
|
+
"ip": client_ip,
|
|
42
|
+
"status": status,
|
|
43
|
+
"duration_ms": round(duration_ms, 1),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
# 크기 기반 로테이션: 최대 1세대만 유지
|
|
49
|
+
if AUDIT_LOG_FILE.exists():
|
|
50
|
+
try:
|
|
51
|
+
if AUDIT_LOG_FILE.stat().st_size > MAX_AUDIT_SIZE:
|
|
52
|
+
AUDIT_LOG_FILE.rename(_ROTATED_FILE)
|
|
53
|
+
except OSError:
|
|
54
|
+
pass
|
|
55
|
+
with open(AUDIT_LOG_FILE, "a", encoding="utf-8") as f:
|
|
56
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
57
|
+
except OSError:
|
|
58
|
+
pass
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def search_audit(from_ts=None, to_ts=None, method=None, path_contains=None,
|
|
62
|
+
ip=None, status=None, limit=100, offset=0):
|
|
63
|
+
"""감사 로그를 조건에 맞게 검색한다.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
from_ts: 시작 시간 (Unix timestamp)
|
|
67
|
+
to_ts: 끝 시간 (Unix timestamp)
|
|
68
|
+
method: HTTP 메서드 필터 (GET, POST, DELETE)
|
|
69
|
+
path_contains: 경로 부분 문자열 필터
|
|
70
|
+
ip: IP 주소 필터
|
|
71
|
+
status: HTTP 상태 코드 필터
|
|
72
|
+
limit: 반환 최대 건수 (기본 100)
|
|
73
|
+
offset: 건너뛸 건수
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
dict: {"entries": [...], "total": int, "limit": int, "offset": int}
|
|
77
|
+
"""
|
|
78
|
+
if not AUDIT_LOG_FILE.exists():
|
|
79
|
+
return {"entries": [], "total": 0, "limit": limit, "offset": offset}
|
|
80
|
+
|
|
81
|
+
results = []
|
|
82
|
+
with open(AUDIT_LOG_FILE, "r", encoding="utf-8") as f:
|
|
83
|
+
for line in f:
|
|
84
|
+
line = line.strip()
|
|
85
|
+
if not line:
|
|
86
|
+
continue
|
|
87
|
+
try:
|
|
88
|
+
entry = json.loads(line)
|
|
89
|
+
except json.JSONDecodeError:
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
ts = entry.get("ts", 0)
|
|
93
|
+
if from_ts is not None and ts < from_ts:
|
|
94
|
+
continue
|
|
95
|
+
if to_ts is not None and ts > to_ts:
|
|
96
|
+
continue
|
|
97
|
+
if method and entry.get("method") != method.upper():
|
|
98
|
+
continue
|
|
99
|
+
if path_contains and path_contains not in entry.get("path", ""):
|
|
100
|
+
continue
|
|
101
|
+
if ip and entry.get("ip") != ip:
|
|
102
|
+
continue
|
|
103
|
+
if status is not None:
|
|
104
|
+
try:
|
|
105
|
+
if entry.get("status") != int(status):
|
|
106
|
+
continue
|
|
107
|
+
except (ValueError, TypeError):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
results.append(entry)
|
|
111
|
+
|
|
112
|
+
# 최신 순으로 정렬
|
|
113
|
+
results.sort(key=lambda e: e.get("ts", 0), reverse=True)
|
|
114
|
+
total = len(results)
|
|
115
|
+
entries = results[offset:offset + limit]
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
"entries": entries,
|
|
119
|
+
"total": total,
|
|
120
|
+
"limit": limit,
|
|
121
|
+
"offset": offset,
|
|
122
|
+
}
|
package/web/checkpoint.py
CHANGED
|
@@ -13,6 +13,14 @@ from config import LOGS_DIR
|
|
|
13
13
|
from utils import parse_meta_file
|
|
14
14
|
from jobs import send_to_fifo
|
|
15
15
|
|
|
16
|
+
_GIT_HASH_RE = re.compile(r'^[0-9a-fA-F]{4,40}$')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _validate_git_hash(value):
|
|
20
|
+
"""git commit hash 형식을 검증한다. 유효하지 않으면 ValueError를 발생시킨다."""
|
|
21
|
+
if not value or not _GIT_HASH_RE.match(value):
|
|
22
|
+
raise ValueError(f"유효하지 않은 git hash입니다: {value!r}")
|
|
23
|
+
|
|
16
24
|
|
|
17
25
|
def get_job_checkpoints(job_id):
|
|
18
26
|
"""worktree의 git log에서 해당 job의 checkpoint 커밋 목록을 반환한다."""
|
|
@@ -108,8 +116,80 @@ def extract_conversation_context(out_file, max_chars=4000):
|
|
|
108
116
|
return full[:max_chars]
|
|
109
117
|
|
|
110
118
|
|
|
119
|
+
def diff_checkpoints(job_id, from_hash, to_hash=None):
|
|
120
|
+
"""두 체크포인트 간 diff를 반환한다. to_hash 생략 시 from_hash의 부모와 비교."""
|
|
121
|
+
try:
|
|
122
|
+
_validate_git_hash(from_hash)
|
|
123
|
+
if to_hash:
|
|
124
|
+
_validate_git_hash(to_hash)
|
|
125
|
+
except ValueError as e:
|
|
126
|
+
return None, str(e)
|
|
127
|
+
|
|
128
|
+
meta_file = LOGS_DIR / f"job_{job_id}.meta"
|
|
129
|
+
if not meta_file.exists():
|
|
130
|
+
return None, "작업을 찾을 수 없습니다"
|
|
131
|
+
|
|
132
|
+
meta = parse_meta_file(meta_file)
|
|
133
|
+
wt_path = meta.get("WORKTREE", "")
|
|
134
|
+
if not wt_path or not os.path.isdir(wt_path):
|
|
135
|
+
return None, "워크트리를 찾을 수 없습니다"
|
|
136
|
+
|
|
137
|
+
# to_hash가 없으면 from_hash 단독 커밋의 변경사항 (부모 대비)
|
|
138
|
+
if not to_hash:
|
|
139
|
+
diff_spec = [f"{from_hash}^", from_hash]
|
|
140
|
+
else:
|
|
141
|
+
diff_spec = [from_hash, to_hash]
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
result = subprocess.run(
|
|
145
|
+
["git", "diff", "--no-color", "-U3"] + diff_spec,
|
|
146
|
+
cwd=wt_path, capture_output=True, text=True, timeout=15
|
|
147
|
+
)
|
|
148
|
+
raw_diff = result.stdout
|
|
149
|
+
|
|
150
|
+
# 파일별로 파싱
|
|
151
|
+
files = []
|
|
152
|
+
current = None
|
|
153
|
+
for line in raw_diff.split("\n"):
|
|
154
|
+
if line.startswith("diff --git"):
|
|
155
|
+
if current:
|
|
156
|
+
files.append(current)
|
|
157
|
+
# "diff --git a/path b/path" → path 추출
|
|
158
|
+
parts = line.split(" b/", 1)
|
|
159
|
+
fname = parts[1] if len(parts) > 1 else "unknown"
|
|
160
|
+
current = {"file": fname, "chunks": [], "additions": 0, "deletions": 0}
|
|
161
|
+
elif current is not None:
|
|
162
|
+
current["chunks"].append(line)
|
|
163
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
164
|
+
current["additions"] += 1
|
|
165
|
+
elif line.startswith("-") and not line.startswith("---"):
|
|
166
|
+
current["deletions"] += 1
|
|
167
|
+
|
|
168
|
+
if current:
|
|
169
|
+
files.append(current)
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
"from": diff_spec[0],
|
|
173
|
+
"to": diff_spec[1],
|
|
174
|
+
"files": files,
|
|
175
|
+
"total_files": len(files),
|
|
176
|
+
"total_additions": sum(f["additions"] for f in files),
|
|
177
|
+
"total_deletions": sum(f["deletions"] for f in files),
|
|
178
|
+
}, None
|
|
179
|
+
|
|
180
|
+
except subprocess.TimeoutExpired:
|
|
181
|
+
return None, "diff 생성 시간 초과"
|
|
182
|
+
except OSError as e:
|
|
183
|
+
return None, f"diff 실패: {e}"
|
|
184
|
+
|
|
185
|
+
|
|
111
186
|
def rewind_job(job_id, checkpoint_hash, new_prompt):
|
|
112
187
|
"""job을 특정 checkpoint로 되돌리고 새 job을 디스패치한다."""
|
|
188
|
+
try:
|
|
189
|
+
_validate_git_hash(checkpoint_hash)
|
|
190
|
+
except ValueError as e:
|
|
191
|
+
return None, str(e)
|
|
192
|
+
|
|
113
193
|
meta_file = LOGS_DIR / f"job_{job_id}.meta"
|
|
114
194
|
out_file = LOGS_DIR / f"job_{job_id}.out"
|
|
115
195
|
|
package/web/config.py
CHANGED
|
@@ -21,7 +21,6 @@ RECENT_DIRS_FILE = DATA_DIR / "recent_dirs.json"
|
|
|
21
21
|
SETTINGS_FILE = DATA_DIR / "settings.json"
|
|
22
22
|
SERVICE_SCRIPT = CONTROLLER_DIR / "service" / "controller.sh"
|
|
23
23
|
SESSIONS_DIR = CONTROLLER_DIR / "sessions"
|
|
24
|
-
QUEUE_DIR = CONTROLLER_DIR / "queue"
|
|
25
24
|
CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
|
|
26
25
|
|
|
27
26
|
PORT = int(os.environ.get("PORT", 8420))
|
|
@@ -33,8 +32,6 @@ PORT = int(os.environ.get("PORT", 8420))
|
|
|
33
32
|
# 허용된 Origin 목록 (CORS)
|
|
34
33
|
# 환경변수 ALLOWED_ORIGINS로 오버라이드 가능 (쉼표 구분)
|
|
35
34
|
_DEFAULT_ORIGINS = [
|
|
36
|
-
"http://claude.won-space.com",
|
|
37
|
-
"https://claude.won-space.com",
|
|
38
35
|
"http://localhost:8420",
|
|
39
36
|
"https://localhost:8420",
|
|
40
37
|
]
|
|
@@ -54,11 +51,11 @@ AUTH_REQUIRED = os.environ.get("AUTH_REQUIRED", "false").lower() == "true"
|
|
|
54
51
|
|
|
55
52
|
# 인증 면제 경로 (AUTH_REQUIRED=true일 때만 적용)
|
|
56
53
|
AUTH_EXEMPT_PREFIXES = ("/static/", "/uploads/", "/api/auth/")
|
|
57
|
-
AUTH_EXEMPT_PATHS = {"/", "/index.html", "/styles.css", "/app.js"}
|
|
54
|
+
AUTH_EXEMPT_PATHS = {"/", "/index.html", "/styles.css", "/app.js", "/api/health"}
|
|
58
55
|
|
|
59
56
|
# 앱 실행 시 브라우저에서 열 공개 URL
|
|
60
57
|
# 환경변수 PUBLIC_URL로 오버라이드 가능
|
|
61
|
-
PUBLIC_URL = os.environ.get("PUBLIC_URL", "https://
|
|
58
|
+
PUBLIC_URL = os.environ.get("PUBLIC_URL", "https://localhost:8420")
|
|
62
59
|
|
|
63
60
|
# SSL 인증서 경로 (mkcert 생성 파일)
|
|
64
61
|
SSL_CERT = os.environ.get("SSL_CERT", str(CONTROLLER_DIR / "certs" / "localhost+1.pem"))
|