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.
Files changed (71) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +1189 -0
  4. package/bin/native-app.py +6 -3
  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 +11 -5
  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 +634 -473
  38. package/web/handler_fs.py +153 -0
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +372 -0
  41. package/web/handler_memory.py +203 -0
  42. package/web/handler_sessions.py +132 -0
  43. package/web/jobs.py +585 -13
  44. package/web/personas.py +419 -0
  45. package/web/pipeline.py +981 -0
  46. package/web/presets.py +506 -0
  47. package/web/projects.py +246 -0
  48. package/web/static/api.js +141 -0
  49. package/web/static/app.js +25 -1937
  50. package/web/static/attachments.js +144 -0
  51. package/web/static/base.css +497 -0
  52. package/web/static/context.js +204 -0
  53. package/web/static/dirs.js +246 -0
  54. package/web/static/form.css +763 -0
  55. package/web/static/goals.css +363 -0
  56. package/web/static/goals.js +300 -0
  57. package/web/static/i18n.js +625 -0
  58. package/web/static/index.html +215 -13
  59. package/web/static/{styles.css → jobs.css} +746 -1141
  60. package/web/static/jobs.js +1270 -0
  61. package/web/static/memoryview.js +117 -0
  62. package/web/static/personas.js +228 -0
  63. package/web/static/pipeline.css +338 -0
  64. package/web/static/pipelines.js +487 -0
  65. package/web/static/presets.js +244 -0
  66. package/web/static/send.js +135 -0
  67. package/web/static/settings-style.css +291 -0
  68. package/web/static/settings.js +81 -0
  69. package/web/static/stream.js +534 -0
  70. package/web/static/utils.js +131 -0
  71. 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://claude.won-space.com")
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"))