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/handler.py CHANGED
@@ -5,31 +5,69 @@ Controller Service — HTTP REST API 핸들러
5
5
  1. Host 헤더 검증 — DNS Rebinding 방지
6
6
  2. Origin 검증 — CORS를 허용된 출처로 제한
7
7
  3. 토큰 인증 — API 요청마다 Authorization 헤더 필수
8
+
9
+ 핸들러 구현은 Mixin 클래스로 분리:
10
+ - handler_jobs.py → JobHandlerMixin (작업 CRUD, 전송, 스트림)
11
+ - handler_sessions.py → SessionHandlerMixin (세션 목록)
12
+ - handler_fs.py → FsHandlerMixin (설정, 디렉토리, 최근 경로)
8
13
  """
9
14
 
10
- import base64
11
15
  import http.server
16
+ import importlib
12
17
  import json
13
18
  import os
14
19
  import re
20
+ import shutil
15
21
  import sys
16
22
  import time
23
+ from datetime import datetime, timezone
17
24
  from urllib.parse import urlparse, parse_qs
18
25
 
19
26
  from config import (
20
- STATIC_DIR, LOGS_DIR, UPLOADS_DIR, DATA_DIR,
21
- RECENT_DIRS_FILE, SETTINGS_FILE, SESSIONS_DIR,
22
- CLAUDE_PROJECTS_DIR, FIFO_PATH,
27
+ STATIC_DIR, UPLOADS_DIR,
23
28
  ALLOWED_ORIGINS, ALLOWED_HOSTS,
24
29
  AUTH_REQUIRED, AUTH_EXEMPT_PREFIXES, AUTH_EXEMPT_PATHS,
25
30
  )
26
- from utils import parse_meta_file, is_service_running, cwd_to_project_dir, scan_claude_sessions
27
- from jobs import get_all_jobs, get_job_result, send_to_fifo, start_controller_service, stop_controller_service
28
- from checkpoint import get_job_checkpoints, rewind_job
29
- from auth import verify_token, get_token
31
+ from auth import verify_token
32
+ import jobs
33
+ import checkpoint
34
+ import projects as _projects_mod
35
+ import pipeline as _pipeline_mod
36
+ import personas as _personas_mod
37
+ import webhook as _webhook_mod
38
+ import audit as _audit_mod
39
+
40
+ from handler_jobs import JobHandlerMixin
41
+ from handler_sessions import SessionHandlerMixin
42
+ from handler_fs import FsHandlerMixin
43
+ from handler_goals import GoalHandlerMixin
44
+ from handler_memory import MemoryHandlerMixin
45
+
46
+ # HTTP 상태 → 기본 에러 코드 매핑
47
+ _STATUS_TO_CODE = {
48
+ 400: "BAD_REQUEST",
49
+ 401: "AUTH_REQUIRED",
50
+ 403: "FORBIDDEN",
51
+ 404: "NOT_FOUND",
52
+ 409: "CONFLICT",
53
+ 500: "INTERNAL_ERROR",
54
+ 502: "BAD_GATEWAY",
55
+ 503: "SERVICE_UNAVAILABLE",
56
+ }
57
+
58
+ # 핫 리로드 대상 모듈
59
+ _HOT_MODULES = [jobs, checkpoint, _projects_mod, _pipeline_mod, _personas_mod, _webhook_mod, _audit_mod]
60
+
30
61
 
62
+ def _hot_reload():
63
+ for mod in _HOT_MODULES:
64
+ try:
65
+ importlib.reload(mod)
66
+ except Exception:
67
+ pass
31
68
 
32
- # MIME 타입 맵 (업로드/정적 파일 공용)
69
+
70
+ # MIME 타입 맵
33
71
  MIME_TYPES = {
34
72
  ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
35
73
  ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
@@ -57,51 +95,62 @@ MIME_TYPES = {
57
95
  ".svg": "image/svg+xml", ".ico": "image/x-icon",
58
96
  }
59
97
 
60
- # 업로드 허용 확장자
61
- IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}
62
- ALLOWED_UPLOAD_EXTS = IMAGE_EXTS | {
63
- ".txt", ".md", ".csv", ".json", ".xml", ".yaml", ".yml", ".toml",
64
- ".py", ".js", ".ts", ".jsx", ".tsx", ".html", ".css", ".scss",
65
- ".sh", ".bash", ".zsh", ".fish",
66
- ".c", ".cpp", ".h", ".hpp", ".java", ".kt", ".go", ".rs", ".rb",
67
- ".swift", ".m", ".r", ".sql", ".graphql",
68
- ".log", ".env", ".conf", ".ini", ".cfg",
69
- ".pdf", ".doc", ".docx", ".xls", ".xlsx", ".pptx",
70
- ".zip", ".tar", ".gz",
71
- }
72
-
73
98
 
74
- class ControllerHandler(http.server.BaseHTTPRequestHandler):
99
+ class ControllerHandler(
100
+ JobHandlerMixin,
101
+ SessionHandlerMixin,
102
+ FsHandlerMixin,
103
+ GoalHandlerMixin,
104
+ MemoryHandlerMixin,
105
+ http.server.BaseHTTPRequestHandler,
106
+ ):
75
107
  """Controller REST API + 정적 파일 서빙 핸들러"""
76
108
 
77
109
  def log_message(self, format, *args):
78
110
  sys.stderr.write(f" [{self.log_date_time_string()}] {format % args}\n")
79
111
 
112
+ def send_response(self, code, message=None):
113
+ self._response_code = code
114
+ super().send_response(code, message)
115
+
116
+ def _audit_log(self):
117
+ """감사 로그 기록 — do_GET/POST/DELETE의 finally에서 호출."""
118
+ if not hasattr(self, '_req_start'):
119
+ return
120
+ duration_ms = (time.time() - self._req_start) * 1000
121
+ path = urlparse(self.path).path.rstrip("/") or "/"
122
+ client_ip = self.client_address[0] if self.client_address else "unknown"
123
+ status = getattr(self, '_response_code', 0)
124
+ _audit_mod.log_api_call(self.command, path, client_ip, status, duration_ms)
125
+
126
+ # ── Mixin 모듈 접근자 (리로드 후에도 최신 참조) ──
127
+
128
+ def _jobs_mod(self): return jobs
129
+ def _ckpt_mod(self): return checkpoint
130
+ def _projects(self): return _projects_mod
131
+ def _pipeline(self): return _pipeline_mod
132
+ def _personas(self): return _personas_mod
133
+
80
134
  # ════════════════════════════════════════════════
81
135
  # 보안 미들웨어
82
136
  # ════════════════════════════════════════════════
83
137
 
84
138
  def _get_origin(self):
85
- """요청의 Origin 헤더를 반환한다."""
86
139
  return self.headers.get("Origin", "")
87
140
 
88
141
  def _set_cors_headers(self):
89
- """허용된 Origin만 CORS 응답에 포함한다."""
90
142
  origin = self._get_origin()
91
143
  if origin in ALLOWED_ORIGINS:
92
144
  self.send_header("Access-Control-Allow-Origin", origin)
93
145
  self.send_header("Vary", "Origin")
94
146
  elif not origin:
95
- # same-origin 요청 (Origin 헤더 없음) — 로컬 접근 허용
96
147
  self.send_header("Access-Control-Allow-Origin", "null")
97
148
  self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
98
149
  self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
99
150
  self.send_header("Access-Control-Allow-Credentials", "true")
100
151
 
101
152
  def _check_host(self) -> bool:
102
- """Host 헤더를 검증하여 DNS Rebinding 공격을 차단한다."""
103
153
  host = self.headers.get("Host", "")
104
- # 포트 번호 제거 후 호스트명만 비교
105
154
  hostname = host.split(":")[0] if ":" in host else host
106
155
  if hostname not in ALLOWED_HOSTS:
107
156
  self._send_forbidden("잘못된 Host 헤더")
@@ -109,29 +158,28 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
109
158
  return True
110
159
 
111
160
  def _check_auth(self, path: str) -> bool:
112
- """토큰 인증을 검증한다. AUTH_REQUIRED=false면 항상 통과."""
113
161
  if not AUTH_REQUIRED:
114
162
  return True
115
-
116
- # 정적 파일 및 면제 경로
117
163
  if path in AUTH_EXEMPT_PATHS:
118
164
  return True
119
165
  for prefix in AUTH_EXEMPT_PREFIXES:
120
166
  if path.startswith(prefix):
121
167
  return True
122
-
123
- # Authorization: Bearer <token>
124
168
  auth_header = self.headers.get("Authorization", "")
125
169
  if auth_header.startswith("Bearer "):
126
170
  token = auth_header[7:]
127
171
  if verify_token(token):
128
172
  return True
129
-
173
+ # EventSource는 커스텀 헤더를 보낼 수 없으므로 query param도 확인
174
+ qs = parse_qs(urlparse(self.path).query)
175
+ token_param = qs.get("token", [None])[0]
176
+ if token_param and verify_token(token_param):
177
+ return True
130
178
  self._send_unauthorized()
131
179
  return False
132
180
 
133
181
  def _send_forbidden(self, message="Forbidden"):
134
- body = json.dumps({"error": message}, ensure_ascii=False).encode("utf-8")
182
+ body = json.dumps({"error": {"code": "FORBIDDEN", "message": message}}, ensure_ascii=False).encode("utf-8")
135
183
  self.send_response(403)
136
184
  self.send_header("Content-Type", "application/json; charset=utf-8")
137
185
  self.send_header("Content-Length", str(len(body)))
@@ -139,7 +187,7 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
139
187
  self.wfile.write(body)
140
188
 
141
189
  def _send_unauthorized(self):
142
- body = json.dumps({"error": "인증이 필요합니다. Authorization 헤더에 토큰을 포함하세요."}, ensure_ascii=False).encode("utf-8")
190
+ body = json.dumps({"error": {"code": "AUTH_REQUIRED", "message": "인증이 필요합니다. Authorization 헤더에 토큰을 포함하세요."}}, ensure_ascii=False).encode("utf-8")
143
191
  self.send_response(401)
144
192
  self.send_header("Content-Type", "application/json; charset=utf-8")
145
193
  self.send_header("WWW-Authenticate", "Bearer")
@@ -161,30 +209,40 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
161
209
  self.end_headers()
162
210
  self.wfile.write(body)
163
211
 
164
- def _error_response(self, message, status=400):
165
- self._json_response({"error": message}, status)
212
+ def _error_response(self, message, status=400, code=None):
213
+ if code is None:
214
+ code = _STATUS_TO_CODE.get(status, "UNKNOWN_ERROR")
215
+ self._json_response({"error": {"code": code, "message": message}}, status)
216
+
217
+ _MAX_BODY_SIZE = 10 * 1024 * 1024 # 10 MB
166
218
 
167
219
  def _read_body(self):
168
- length = int(self.headers.get("Content-Length", 0))
169
- if length == 0:
170
- return {}
171
- raw = self.rfile.read(length)
172
220
  try:
173
- return json.loads(raw)
174
- except json.JSONDecodeError:
221
+ length = int(self.headers.get("Content-Length", 0))
222
+ except (ValueError, TypeError):
223
+ return {}
224
+ if length <= 0:
175
225
  return {}
226
+ if length > self._MAX_BODY_SIZE:
227
+ raise ValueError(
228
+ f"요청 본문이 최대 크기를 초과합니다 (최대 {self._MAX_BODY_SIZE // (1024 * 1024)}MB)")
229
+ raw = self.rfile.read(length)
230
+ data = json.loads(raw)
231
+ if not isinstance(data, dict):
232
+ raise ValueError("요청 본문은 JSON 객체여야 합니다")
233
+ return data
176
234
 
177
235
  def _serve_file(self, file_path, base_dir):
178
- """파일을 읽어 HTTP 응답으로 전송한다. 경로 탈출 방지 포함."""
179
236
  try:
180
237
  resolved = file_path.resolve()
181
- if not str(resolved).startswith(str(base_dir.resolve())):
182
- return self._error_response("접근 거부", 403)
238
+ base_resolved = str(base_dir.resolve()) + os.sep
239
+ if not str(resolved).startswith(base_resolved):
240
+ return self._error_response("접근 거부", 403, code="ACCESS_DENIED")
183
241
  except (ValueError, OSError):
184
- return self._error_response("잘못된 경로", 400)
242
+ return self._error_response("잘못된 경로", 400, code="INVALID_PATH")
185
243
 
186
244
  if not resolved.exists() or not resolved.is_file():
187
- return self._error_response("파일을 찾을 수 없습니다", 404)
245
+ return self._error_response("파일을 찾을 수 없습니다", 404, code="FILE_NOT_FOUND")
188
246
 
189
247
  ext = resolved.suffix.lower()
190
248
  mime = MIME_TYPES.get(ext, "application/octet-stream")
@@ -196,50 +254,110 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
196
254
  self.send_response(200)
197
255
  self.send_header("Content-Type", mime)
198
256
  self._set_cors_headers()
257
+ self.send_header("Cache-Control", "no-cache, must-revalidate")
199
258
  self.send_header("Content-Length", str(len(data)))
200
259
  self.end_headers()
201
260
  self.wfile.write(data)
202
261
  except OSError:
203
- self._error_response("파일 읽기 실패", 500)
262
+ self._error_response("파일 읽기 실패", 500, code="FILE_READ_ERROR")
263
+
264
+ # ════════════════════════════════════════════════
265
+ # HTTP 라우팅
266
+ # ════════════════════════════════════════════════
204
267
 
205
- # ── OPTIONS (CORS preflight) ──
206
268
  def do_OPTIONS(self):
207
- # preflight는 Host/Auth 검사 면제 (브라우저 자동 요청)
208
269
  self.send_response(204)
209
270
  self._set_cors_headers()
210
271
  self.end_headers()
211
272
 
212
- # ── GET ──
213
273
  def do_GET(self):
274
+ self._req_start = time.time()
275
+ try:
276
+ self._do_get_inner()
277
+ finally:
278
+ self._audit_log()
279
+
280
+ def _do_get_inner(self):
281
+ _hot_reload()
214
282
  parsed = urlparse(self.path)
215
283
  path = parsed.path.rstrip("/") or "/"
216
284
 
217
- # 보안 게이트
218
285
  if not self._check_host():
219
286
  return
220
287
  if not self._check_auth(path):
221
288
  return
222
289
 
290
+ try:
291
+ self._dispatch_get(path, parsed)
292
+ except (ValueError, TypeError) as e:
293
+ msg = str(e) or "잘못된 요청 파라미터입니다"
294
+ self._error_response(msg, 400, code="INVALID_PARAM")
295
+ except Exception as e:
296
+ sys.stderr.write(f" [ERROR] GET {path}: {e}\n")
297
+ self._error_response("서버 내부 오류가 발생했습니다", 500, code="INTERNAL_ERROR")
298
+
299
+ def _dispatch_get(self, path, parsed):
300
+ if path == "/api/health":
301
+ return self._handle_health()
302
+ if path == "/api/audit":
303
+ return self._handle_audit(parsed)
223
304
  if path == "/api/auth/verify":
224
305
  return self._handle_auth_verify()
225
-
226
306
  if path == "/api/status":
227
307
  return self._handle_status()
308
+ if path == "/api/stats":
309
+ return self._handle_stats(parsed)
228
310
  if path == "/api/jobs":
229
- return self._handle_jobs()
311
+ qs = parse_qs(parsed.query)
312
+ return self._handle_jobs(
313
+ cwd_filter=qs.get("cwd", [None])[0],
314
+ page=self._safe_int(qs.get("page", [1])[0], 1),
315
+ limit=self._safe_int(qs.get("limit", [10])[0], 10),
316
+ )
230
317
  if path == "/api/sessions":
231
318
  qs = parse_qs(parsed.query)
232
- filter_cwd = qs.get("cwd", [None])[0]
233
- return self._handle_sessions(filter_cwd=filter_cwd)
319
+ return self._handle_sessions(filter_cwd=qs.get("cwd", [None])[0])
234
320
  if path == "/api/config":
235
321
  return self._handle_get_config()
236
322
  if path == "/api/recent-dirs":
237
323
  return self._handle_get_recent_dirs()
238
324
  if path == "/api/dirs":
239
325
  qs = parse_qs(parsed.query)
240
- dir_path = qs.get("path", [os.path.expanduser("~")])[0]
241
- return self._handle_dirs(dir_path)
242
-
326
+ return self._handle_dirs(qs.get("path", [os.path.expanduser("~")])[0])
327
+ if path == "/api/projects":
328
+ return self._handle_list_projects()
329
+ if path == "/api/personas":
330
+ return self._json_response(self._personas().list_personas())
331
+ if path == "/api/pipelines":
332
+ return self._handle_list_pipelines()
333
+ if path == "/api/pipelines/evolution":
334
+ return self._json_response(self._pipeline().get_evolution_summary())
335
+ if path == "/api/goals":
336
+ return self._handle_list_goals(parsed)
337
+ if path == "/api/memory":
338
+ return self._handle_list_memory(parsed)
339
+
340
+ match = re.match(r"^/api/goals/([^/]+)$", path)
341
+ if match:
342
+ return self._handle_get_goal(match.group(1))
343
+ match = re.match(r"^/api/memory/([^/]+)$", path)
344
+ if match:
345
+ return self._handle_get_memory(match.group(1))
346
+ match = re.match(r"^/api/personas/([^/]+)$", path)
347
+ if match:
348
+ return self._handle_get_persona(match.group(1))
349
+ match = re.match(r"^/api/pipelines/([^/]+)/status$", path)
350
+ if match:
351
+ return self._handle_pipeline_status(match.group(1))
352
+ match = re.match(r"^/api/pipelines/([^/]+)/history$", path)
353
+ if match:
354
+ return self._handle_pipeline_history(match.group(1))
355
+ match = re.match(r"^/api/projects/([^/]+)/jobs$", path)
356
+ if match:
357
+ return self._handle_project_jobs(match.group(1))
358
+ match = re.match(r"^/api/projects/([^/]+)$", path)
359
+ if match:
360
+ return self._handle_get_project(match.group(1))
243
361
  match = re.match(r"^/api/jobs/(\w+)/result$", path)
244
362
  if match:
245
363
  return self._handle_job_result(match.group(1))
@@ -249,6 +367,9 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
249
367
  match = re.match(r"^/api/jobs/(\w+)/checkpoints$", path)
250
368
  if match:
251
369
  return self._handle_job_checkpoints(match.group(1))
370
+ match = re.match(r"^/api/jobs/(\w+)/diff$", path)
371
+ if match:
372
+ return self._handle_job_diff(match.group(1))
252
373
  match = re.match(r"^/api/session/([a-f0-9-]+)/job$", path)
253
374
  if match:
254
375
  return self._handle_job_by_session(match.group(1))
@@ -256,23 +377,50 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
256
377
  if match:
257
378
  return self._serve_file(UPLOADS_DIR / match.group(1), UPLOADS_DIR)
258
379
 
259
- # 정적 파일
260
380
  self._serve_static(parsed.path)
261
381
 
262
- # ── POST ──
382
+ @staticmethod
383
+ def _safe_int(value, default=0):
384
+ """query string 값을 안전하게 int로 변환한다. 실패 시 default 반환."""
385
+ if value is None:
386
+ return default
387
+ try:
388
+ return int(value)
389
+ except (ValueError, TypeError):
390
+ return default
391
+
263
392
  def do_POST(self):
393
+ self._req_start = time.time()
394
+ try:
395
+ self._do_post_inner()
396
+ finally:
397
+ self._audit_log()
398
+
399
+ def _do_post_inner(self):
400
+ _hot_reload()
264
401
  parsed = urlparse(self.path)
265
402
  path = parsed.path.rstrip("/") or "/"
266
403
 
267
- # 보안 게이트
268
404
  if not self._check_host():
269
405
  return
270
406
  if not self._check_auth(path):
271
407
  return
272
408
 
409
+ try:
410
+ self._dispatch_post(path, parsed)
411
+ except json.JSONDecodeError:
412
+ self._error_response("잘못된 JSON 요청 본문입니다", 400, code="INVALID_JSON")
413
+ except ValueError as e:
414
+ msg = str(e) or "잘못된 요청 본문입니다"
415
+ status = 413 if "최대 크기" in msg else 400
416
+ self._error_response(msg, status, code="INVALID_BODY")
417
+ except Exception as e:
418
+ sys.stderr.write(f" [ERROR] POST {path}: {e}\n")
419
+ self._error_response("서버 내부 오류가 발생했습니다", 500, code="INTERNAL_ERROR")
420
+
421
+ def _dispatch_post(self, path, parsed):
273
422
  if path == "/api/auth/verify":
274
423
  return self._handle_auth_verify()
275
-
276
424
  if path == "/api/send":
277
425
  return self._handle_send()
278
426
  if path == "/api/upload":
@@ -285,39 +433,115 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
285
433
  return self._handle_save_config()
286
434
  if path == "/api/recent-dirs":
287
435
  return self._handle_save_recent_dirs()
288
-
436
+ if path == "/api/mkdir":
437
+ return self._handle_mkdir()
438
+ if path == "/api/projects":
439
+ return self._handle_add_project()
440
+ if path == "/api/projects/create":
441
+ return self._handle_create_project()
442
+ if path == "/api/personas":
443
+ return self._handle_create_persona()
444
+ if path == "/api/pipelines":
445
+ return self._handle_create_pipeline()
446
+ if path == "/api/pipelines/tick-all":
447
+ return self._json_response(self._pipeline().tick_all())
448
+ if path == "/api/webhooks/test":
449
+ return self._handle_webhook_test()
450
+ if path == "/api/logs/cleanup":
451
+ return self._handle_logs_cleanup()
452
+ if path == "/api/goals":
453
+ return self._handle_create_goal()
454
+ if path == "/api/memory":
455
+ return self._handle_create_memory()
456
+
457
+ match = re.match(r"^/api/goals/([^/]+)/update$", path)
458
+ if match:
459
+ return self._handle_update_goal(match.group(1))
460
+ match = re.match(r"^/api/goals/([^/]+)/approve$", path)
461
+ if match:
462
+ return self._handle_approve_goal(match.group(1))
463
+ match = re.match(r"^/api/memory/([^/]+)/update$", path)
464
+ if match:
465
+ return self._handle_update_memory(match.group(1))
466
+ match = re.match(r"^/api/personas/([^/]+)/update$", path)
467
+ if match:
468
+ return self._handle_update_persona(match.group(1))
469
+ match = re.match(r"^/api/pipelines/([^/]+)/run$", path)
470
+ if match:
471
+ return self._handle_pipeline_run(match.group(1))
472
+ match = re.match(r"^/api/pipelines/([^/]+)/stop$", path)
473
+ if match:
474
+ return self._handle_pipeline_stop(match.group(1))
475
+ match = re.match(r"^/api/pipelines/([^/]+)/update$", path)
476
+ if match:
477
+ return self._handle_update_pipeline(match.group(1))
478
+ match = re.match(r"^/api/pipelines/([^/]+)/reset$", path)
479
+ if match:
480
+ return self._handle_pipeline_reset(match.group(1))
481
+ match = re.match(r"^/api/projects/([^/]+)$", path)
482
+ if match:
483
+ return self._handle_update_project(match.group(1))
289
484
  match = re.match(r"^/api/jobs/(\w+)/rewind$", path)
290
485
  if match:
291
486
  return self._handle_job_rewind(match.group(1))
292
487
 
293
- self._error_response("알 수 없는 엔드포인트", 404)
488
+ self._error_response("알 수 없는 엔드포인트", 404, code="ENDPOINT_NOT_FOUND")
294
489
 
295
- # ── DELETE ──
296
490
  def do_DELETE(self):
491
+ self._req_start = time.time()
492
+ try:
493
+ self._do_delete_inner()
494
+ finally:
495
+ self._audit_log()
496
+
497
+ def _do_delete_inner(self):
498
+ _hot_reload()
297
499
  parsed = urlparse(self.path)
298
500
  path = parsed.path.rstrip("/") or "/"
299
501
 
300
- # 보안 게이트
301
502
  if not self._check_host():
302
503
  return
303
504
  if not self._check_auth(path):
304
505
  return
305
506
 
507
+ try:
508
+ self._dispatch_delete(path)
509
+ except (json.JSONDecodeError, ValueError, TypeError) as e:
510
+ msg = str(e) or "잘못된 요청입니다"
511
+ self._error_response(msg, 400, code="INVALID_REQUEST")
512
+ except Exception as e:
513
+ sys.stderr.write(f" [ERROR] DELETE {path}: {e}\n")
514
+ self._error_response("서버 내부 오류가 발생했습니다", 500, code="INTERNAL_ERROR")
515
+
516
+ def _dispatch_delete(self, path):
517
+ match = re.match(r"^/api/goals/([^/]+)$", path)
518
+ if match:
519
+ return self._handle_cancel_goal(match.group(1))
520
+ match = re.match(r"^/api/memory/([^/]+)$", path)
521
+ if match:
522
+ return self._handle_delete_memory(match.group(1))
523
+ match = re.match(r"^/api/personas/([^/]+)$", path)
524
+ if match:
525
+ return self._handle_delete_persona(match.group(1))
526
+ match = re.match(r"^/api/pipelines/([^/]+)$", path)
527
+ if match:
528
+ return self._handle_delete_pipeline(match.group(1))
529
+ match = re.match(r"^/api/projects/([^/]+)$", path)
530
+ if match:
531
+ return self._handle_remove_project(match.group(1))
306
532
  match = re.match(r"^/api/jobs/(\w+)$", path)
307
533
  if match:
308
534
  return self._handle_delete_job(match.group(1))
309
535
  if path == "/api/jobs":
310
536
  return self._handle_delete_completed_jobs()
311
537
 
312
- self._error_response("알 수 없는 엔드포인트", 404)
538
+ self._error_response("알 수 없는 엔드포인트", 404, code="ENDPOINT_NOT_FOUND")
313
539
 
314
540
  # ════════════════════════════════════════════════
315
- # API 핸들러
541
+ # 얇은 핸들러 (라우팅과 함께 유지)
316
542
  # ════════════════════════════════════════════════
317
543
 
318
544
  def _handle_auth_verify(self):
319
- """POST /api/auth/verify — 토큰 검증 엔드포인트.
320
- 프론트엔드에서 토큰 유효성을 확인할 때 사용한다."""
321
545
  auth_header = self.headers.get("Authorization", "")
322
546
  if auth_header.startswith("Bearer "):
323
547
  token = auth_header[7:]
@@ -325,454 +549,391 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
325
549
  return self._json_response({"valid": True})
326
550
  self._json_response({"valid": False}, 401)
327
551
 
328
- def _handle_status(self):
329
- running, _ = is_service_running()
330
- self._json_response({"running": running, "fifo": str(FIFO_PATH)})
552
+ def _handle_stats(self, parsed):
553
+ qs = parse_qs(parsed.query)
331
554
 
332
- def _handle_jobs(self):
333
- self._json_response(get_all_jobs())
555
+ # period 파라미터: day, week, month, all (기본값)
556
+ period = qs.get("period", ["all"])[0]
557
+ now = time.time()
334
558
 
335
- def _handle_job_result(self, job_id):
336
- result, err = get_job_result(job_id)
337
- if err:
338
- self._error_response(err, 404)
559
+ period_map = {"day": 86400, "week": 604800, "month": 2592000}
560
+ if period in period_map:
561
+ from_ts = now - period_map[period]
562
+ elif period == "all":
563
+ from_ts = None
339
564
  else:
340
- self._json_response(result)
565
+ # from/to 커스텀 범위 지원 (ISO 날짜 또는 Unix timestamp)
566
+ from_ts = self._parse_ts(qs.get("from", [None])[0])
341
567
 
342
- def _handle_upload(self):
343
- body = self._read_body()
344
- data_b64 = body.get("data", "")
345
- filename = body.get("filename", "file")
568
+ to_ts = self._parse_ts(qs.get("to", [None])[0]) or now
346
569
 
347
- if not data_b64:
348
- return self._error_response("data 필드가 필요합니다")
349
- if "," in data_b64:
350
- data_b64 = data_b64.split(",", 1)[1]
570
+ self._json_response(self._jobs_mod().get_stats(from_ts=from_ts, to_ts=to_ts))
351
571
 
572
+ @staticmethod
573
+ def _parse_ts(value):
574
+ """문자열을 Unix timestamp로 변환. ISO 날짜 또는 숫자 문자열 지원."""
575
+ if not value:
576
+ return None
352
577
  try:
353
- raw = base64.b64decode(data_b64)
354
- except Exception:
355
- return self._error_response("잘못된 base64 데이터")
356
-
357
- ext = os.path.splitext(filename)[1].lower()
358
- if ext not in ALLOWED_UPLOAD_EXTS:
359
- ext = ext if ext else ".bin"
360
- prefix = "img" if ext in IMAGE_EXTS else "file"
361
- safe_name = f"{prefix}_{int(time.time())}_{os.getpid()}_{id(raw) % 10000}{ext}"
362
-
363
- UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
364
- filepath = UPLOADS_DIR / safe_name
365
- filepath.write_bytes(raw)
366
-
367
- is_image = ext in IMAGE_EXTS
368
- self._json_response({
369
- "path": str(filepath),
370
- "filename": safe_name,
371
- "originalName": filename,
372
- "size": len(raw),
373
- "isImage": is_image,
374
- }, 201)
375
-
376
- def _handle_send(self):
377
- body = self._read_body()
378
- prompt = body.get("prompt", "").strip()
379
- if not prompt:
380
- return self._error_response("prompt 필드가 필요합니다")
381
-
382
- result, err = send_to_fifo(
383
- prompt,
384
- cwd=body.get("cwd") or None,
385
- job_id=body.get("id") or None,
386
- images=body.get("images") or None,
387
- session=body.get("session") or None,
388
- )
389
- if err:
390
- self._error_response(err, 502)
391
- else:
392
- self._json_response(result, 201)
393
-
394
- def _handle_service_start(self):
395
- ok, _ = start_controller_service()
396
- if ok:
397
- self._json_response({"started": True})
398
- else:
399
- self._error_response("서비스 시작 실패", 500)
578
+ return float(value)
579
+ except ValueError:
580
+ pass
581
+ for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"):
582
+ try:
583
+ return time.mktime(time.strptime(value, fmt))
584
+ except ValueError:
585
+ continue
586
+ return None
400
587
 
401
- def _handle_service_stop(self):
402
- ok, err = stop_controller_service()
403
- if ok:
404
- self._json_response({"stopped": True})
405
- else:
406
- self._error_response(err or "서비스 종료 실패", 500)
588
+ def _handle_status(self):
589
+ from utils import is_service_running
590
+ from config import FIFO_PATH
591
+ running, _ = is_service_running()
592
+ self._json_response({"running": running, "fifo": str(FIFO_PATH)})
407
593
 
408
- def _handle_delete_job(self, job_id):
409
- meta_file = LOGS_DIR / f"job_{job_id}.meta"
410
- out_file = LOGS_DIR / f"job_{job_id}.out"
594
+ def _handle_health(self):
595
+ from utils import is_service_running
596
+ from config import FIFO_PATH, LOGS_DIR, PID_FILE, SESSIONS_DIR
411
597
 
412
- if not meta_file.exists():
413
- return self._error_response("작업을 찾을 수 없습니다", 404)
598
+ # ── 서비스 상태 ──
599
+ running, pid = is_service_running()
600
+ uptime_seconds = None
601
+ if running and PID_FILE.exists():
602
+ try:
603
+ uptime_seconds = int(time.time() - PID_FILE.stat().st_mtime)
604
+ except OSError:
605
+ pass
414
606
 
415
- meta = parse_meta_file(meta_file)
416
- if meta and meta.get("STATUS") == "running":
417
- pid = meta.get("PID")
418
- if pid:
607
+ # ── FIFO 상태 ──
608
+ fifo_exists = FIFO_PATH.exists()
609
+ fifo_writable = False
610
+ if fifo_exists:
611
+ fifo_writable = os.access(str(FIFO_PATH), os.W_OK)
612
+
613
+ # ── 작업 통계 (meta 파일 직접 스캔 — get_all_jobs()보다 가볍다) ──
614
+ active = 0
615
+ succeeded = 0
616
+ failed = 0
617
+ total = 0
618
+ if LOGS_DIR.exists():
619
+ for mf in LOGS_DIR.glob("job_*.meta"):
620
+ total += 1
419
621
  try:
420
- os.kill(int(pid), 0)
421
- return self._error_response("실행 중인 작업은 삭제할 수 없습니다", 409)
422
- except (ProcessLookupError, ValueError, OSError):
622
+ content = mf.read_text()
623
+ for line in content.splitlines():
624
+ if line.startswith("STATUS="):
625
+ val = line[7:].strip().strip("'\"")
626
+ if val == "running":
627
+ active += 1
628
+ elif val == "done":
629
+ succeeded += 1
630
+ elif val == "failed":
631
+ failed += 1
632
+ break
633
+ except OSError:
423
634
  pass
424
635
 
425
- try:
426
- if meta_file.exists():
427
- meta_file.unlink()
428
- if out_file.exists():
429
- out_file.unlink()
430
- self._json_response({"deleted": True, "job_id": job_id})
431
- except OSError as e:
432
- self._error_response(f"삭제 실패: {e}", 500)
433
-
434
- def _handle_delete_completed_jobs(self):
435
- deleted = []
436
- for mf in list(LOGS_DIR.glob("job_*.meta")):
437
- meta = parse_meta_file(mf)
438
- if not meta:
439
- continue
440
- status = meta.get("STATUS", "")
441
- if status in ("done", "failed"):
442
- pid = meta.get("PID")
443
- if pid and status == "running":
444
- try:
445
- os.kill(int(pid), 0)
446
- continue
447
- except (ProcessLookupError, ValueError, OSError):
448
- pass
449
- job_id = meta.get("JOB_ID", "")
450
- out_file = LOGS_DIR / f"job_{job_id}.out"
636
+ # ── 디스크 사용량 ──
637
+ logs_size_bytes = 0
638
+ if LOGS_DIR.exists():
639
+ for f in LOGS_DIR.iterdir():
451
640
  try:
452
- mf.unlink()
453
- if out_file.exists():
454
- out_file.unlink()
455
- deleted.append(job_id)
641
+ logs_size_bytes += f.stat().st_size
456
642
  except OSError:
457
643
  pass
458
- self._json_response({"deleted": deleted, "count": len(deleted)})
459
644
 
460
- def _handle_job_stream(self, job_id):
461
- out_file = LOGS_DIR / f"job_{job_id}.out"
462
- meta_file = LOGS_DIR / f"job_{job_id}.meta"
645
+ disk_total, disk_used, disk_free = shutil.disk_usage("/")
463
646
 
464
- if not meta_file.exists():
465
- return self._error_response("작업을 찾을 없습니다", 404)
647
+ # ── 워치독 상태 ──
648
+ from config import CONTROLLER_DIR as _cdir
649
+ wd_pid_file = _cdir / "service" / "watchdog.pid"
650
+ wd_state_file = _cdir / "data" / "watchdog_state.json"
651
+ wd_running = False
652
+ wd_info = {}
653
+ if wd_pid_file.exists():
654
+ try:
655
+ wd_pid = int(wd_pid_file.read_text().strip())
656
+ os.kill(wd_pid, 0)
657
+ wd_running = True
658
+ except (ValueError, OSError):
659
+ pass
660
+ if wd_state_file.exists():
661
+ try:
662
+ wd_info = json.loads(wd_state_file.read_text())
663
+ except (json.JSONDecodeError, OSError):
664
+ pass
466
665
 
467
- parsed = urlparse(self.path)
666
+ # ── 전체 상태 판정 ──
667
+ if running and fifo_exists and fifo_writable:
668
+ status = "healthy"
669
+ elif running:
670
+ status = "degraded"
671
+ else:
672
+ status = "unhealthy"
673
+
674
+ http_status = 503 if status == "unhealthy" else 200
675
+
676
+ self._json_response({
677
+ "status": status,
678
+ "timestamp": datetime.now(timezone.utc).isoformat(),
679
+ "service": {
680
+ "running": running,
681
+ "pid": pid,
682
+ "uptime_seconds": uptime_seconds,
683
+ },
684
+ "fifo": {
685
+ "exists": fifo_exists,
686
+ "writable": fifo_writable,
687
+ },
688
+ "jobs": {
689
+ "active": active,
690
+ "total": total,
691
+ "succeeded": succeeded,
692
+ "failed": failed,
693
+ },
694
+ "disk": {
695
+ "logs_size_mb": round(logs_size_bytes / (1024 * 1024), 2),
696
+ "disk_free_gb": round(disk_free / (1024 ** 3), 2),
697
+ },
698
+ "watchdog": {
699
+ "running": wd_running,
700
+ "restart_count": wd_info.get("restart_count", 0),
701
+ "last_restart": wd_info.get("last_restart", ""),
702
+ "status": wd_info.get("status", "unknown"),
703
+ },
704
+ }, http_status)
705
+
706
+ # ── 감사 로그 ──
707
+
708
+ def _handle_audit(self, parsed):
709
+ """감사 로그 검색 — GET /api/audit?from=...&to=...&method=...&path=...&ip=...&status=...&limit=...&offset=..."""
468
710
  qs = parse_qs(parsed.query)
469
- offset = int(qs.get("offset", [0])[0])
711
+ result = _audit_mod.search_audit(
712
+ from_ts=self._parse_ts(qs.get("from", [None])[0]),
713
+ to_ts=self._parse_ts(qs.get("to", [None])[0]),
714
+ method=qs.get("method", [None])[0],
715
+ path_contains=qs.get("path", [None])[0],
716
+ ip=qs.get("ip", [None])[0],
717
+ status=qs.get("status", [None])[0],
718
+ limit=min(self._safe_int(qs.get("limit", [100])[0], 100), 1000),
719
+ offset=self._safe_int(qs.get("offset", [0])[0], 0),
720
+ )
721
+ self._json_response(result)
722
+
723
+ # ── 웹훅 ──
470
724
 
471
- if not out_file.exists():
472
- return self._json_response({"events": [], "offset": 0, "done": False})
725
+ def _handle_webhook_test(self):
726
+ """설정된 webhook_url로 테스트 페이로드를 전송한다."""
727
+ result = _webhook_mod.deliver_webhook("test-0000", "done")
728
+ if result is None:
729
+ return self._error_response(
730
+ "webhook_url이 설정되지 않았습니다. 설정에서 URL을 지정하세요.",
731
+ 400, code="WEBHOOK_NOT_CONFIGURED")
732
+ # 테스트 전송의 중복방지 마커 제거
733
+ marker = _webhook_mod._WEBHOOK_SENT_DIR / "test-0000_done"
734
+ if marker.exists():
735
+ try:
736
+ marker.unlink()
737
+ except OSError:
738
+ pass
739
+ self._json_response(result)
473
740
 
741
+ def _handle_logs_cleanup(self):
742
+ """보존 기간이 지난 완료/실패 작업 파일을 삭제한다."""
743
+ body = self._read_body() or {}
744
+ retention_days = body.get("retention_days", 30)
474
745
  try:
475
- with open(out_file, "r") as f:
476
- f.seek(offset)
477
- new_data = f.read()
478
- new_offset = f.tell()
479
-
480
- events = []
481
- for line in new_data.strip().split("\n"):
482
- if not line.strip():
483
- continue
484
- try:
485
- evt = json.loads(line)
486
- evt_type = evt.get("type", "")
487
- if evt_type == "assistant":
488
- msg = evt.get("message", {})
489
- content = msg.get("content", [])
490
- text_parts = [c.get("text", "") for c in content if c.get("type") == "text"]
491
- if text_parts:
492
- events.append({"type": "text", "text": "".join(text_parts)})
493
- tool_parts = [c for c in content if c.get("type") == "tool_use"]
494
- for tp in tool_parts:
495
- events.append({
496
- "type": "tool_use",
497
- "tool": tp.get("name", ""),
498
- "input": str(tp.get("input", ""))[:200]
499
- })
500
- elif evt_type == "result":
501
- events.append({
502
- "type": "result",
503
- "result": evt.get("result", ""),
504
- "cost_usd": evt.get("total_cost_usd"),
505
- "duration_ms": evt.get("duration_ms"),
506
- "is_error": evt.get("is_error", False)
507
- })
508
- except json.JSONDecodeError:
509
- continue
510
-
511
- meta = parse_meta_file(meta_file)
512
- done = meta.get("STATUS", "") in ("done", "failed")
513
-
514
- self._json_response({"events": events, "offset": new_offset, "done": done})
515
- except OSError as e:
516
- self._error_response(f"스트림 읽기 실패: {e}", 500)
517
-
518
- def _handle_job_checkpoints(self, job_id):
519
- checkpoints, err = get_job_checkpoints(job_id)
746
+ retention_days = int(retention_days)
747
+ if retention_days < 1:
748
+ retention_days = 1
749
+ except (ValueError, TypeError):
750
+ retention_days = 30
751
+ result = self._jobs_mod().cleanup_old_jobs(retention_days=retention_days)
752
+ self._json_response(result)
753
+
754
+ # ── 프로젝트 (얇은 위임) ──
755
+
756
+ def _handle_list_projects(self):
757
+ self._json_response(self._projects().list_projects())
758
+
759
+ def _handle_get_project(self, project_id):
760
+ project, err = self._projects().get_project(project_id)
520
761
  if err:
521
762
  self._error_response(err, 404)
522
763
  else:
523
- self._json_response(checkpoints)
764
+ self._json_response(project)
524
765
 
525
- def _handle_job_by_session(self, session_id):
526
- """Session ID로 가장 최근 job을 찾아 반환한다."""
527
- jobs = get_all_jobs()
528
- matched = [j for j in jobs if j.get("session_id") == session_id]
529
- if not matched:
530
- return self._error_response(
531
- f"Session ID '{session_id[:8]}...'에 해당하는 작업을 찾을 수 없습니다", 404)
532
- self._json_response(matched[0])
766
+ def _handle_project_jobs(self, project_id):
767
+ project, err = self._projects().get_project(project_id)
768
+ if err:
769
+ return self._error_response(err, 404, code="PROJECT_NOT_FOUND")
770
+ jobs = self._jobs_mod().get_all_jobs(cwd_filter=project["path"])
771
+ self._json_response({"project": project, "jobs": jobs})
533
772
 
534
- def _handle_job_rewind(self, job_id):
773
+ def _handle_add_project(self):
535
774
  body = self._read_body()
536
- checkpoint_hash = body.get("checkpoint", "").strip()
537
- new_prompt = body.get("prompt", "").strip()
775
+ path = body.get("path", "").strip()
776
+ if not path:
777
+ return self._error_response("path 필드가 필요합니다", code="MISSING_FIELD")
778
+ project, err = self._projects().add_project(
779
+ path, name=body.get("name", "").strip(), description=body.get("description", "").strip())
780
+ if err:
781
+ self._error_response(err, 409, code="ALREADY_EXISTS")
782
+ else:
783
+ self._json_response(project, 201)
784
+
785
+ def _handle_create_project(self):
786
+ body = self._read_body()
787
+ path = body.get("path", "").strip()
788
+ if not path:
789
+ return self._error_response("path 필드가 필요합니다", code="MISSING_FIELD")
790
+ project, err = self._projects().create_project(
791
+ path, name=body.get("name", "").strip(),
792
+ description=body.get("description", "").strip(),
793
+ init_git=body.get("init_git", True))
794
+ if err:
795
+ self._error_response(err, 400)
796
+ else:
797
+ self._json_response(project, 201)
798
+
799
+ def _handle_update_project(self, project_id):
800
+ body = self._read_body()
801
+ project, err = self._projects().update_project(
802
+ project_id, name=body.get("name"), description=body.get("description"))
803
+ if err:
804
+ self._error_response(err, 404)
805
+ else:
806
+ self._json_response(project)
807
+
808
+ def _handle_remove_project(self, project_id):
809
+ project, err = self._projects().remove_project(project_id)
810
+ if err:
811
+ self._error_response(err, 404)
812
+ else:
813
+ self._json_response({"removed": True, "project": project})
814
+
815
+ # ── 파이프라인 (얇은 위임) ──
816
+
817
+ def _handle_list_pipelines(self):
818
+ self._json_response(self._pipeline().list_pipelines())
819
+
820
+ def _handle_pipeline_status(self, pipe_id):
821
+ result, err = self._pipeline().get_pipeline_status(pipe_id)
822
+ if err:
823
+ self._error_response(err, 404)
824
+ else:
825
+ self._json_response(result)
538
826
 
539
- if not checkpoint_hash:
540
- return self._error_response("checkpoint 필드가 필요합니다")
541
- if not new_prompt:
542
- return self._error_response("prompt 필드가 필요합니다")
827
+ def _handle_pipeline_history(self, pipe_id):
828
+ result, err = self._pipeline().get_pipeline_history(pipe_id)
829
+ if err:
830
+ self._error_response(err, 404)
831
+ else:
832
+ self._json_response(result)
543
833
 
544
- result, err = rewind_job(job_id, checkpoint_hash, new_prompt)
834
+ def _handle_create_pipeline(self):
835
+ body = self._read_body()
836
+ path = body.get("project_path", "").strip()
837
+ command = body.get("command", "").strip()
838
+ if not path or not command:
839
+ return self._error_response("project_path와 command 필드가 필요합니다", code="MISSING_FIELD")
840
+ result, err = self._pipeline().create_pipeline(
841
+ path, command=command,
842
+ interval=body.get("interval", "").strip(),
843
+ name=body.get("name", "").strip(),
844
+ on_complete=body.get("on_complete", "").strip())
545
845
  if err:
546
- self._error_response(err, 400 if "찾을 수 없습니다" in err else 500)
846
+ self._error_response(err, 400)
547
847
  else:
548
848
  self._json_response(result, 201)
549
849
 
550
- def _handle_sessions(self, filter_cwd=None):
551
- """Claude Code 네이티브 세션 + history.log + job meta 파일을 합쳐 세션 목록을 반환한다."""
552
- seen = {}
850
+ def _handle_pipeline_run(self, pipe_id):
851
+ body = self._read_body()
852
+ if body.get("force"):
853
+ result, err = self._pipeline().force_run(pipe_id)
854
+ else:
855
+ result, err = self._pipeline().run_next(pipe_id)
856
+ if err:
857
+ self._error_response(err, 400)
858
+ else:
859
+ self._json_response(result)
553
860
 
554
- # 0) Claude Code 네이티브 세션 스캔
555
- if filter_cwd:
556
- proj_name = cwd_to_project_dir(filter_cwd)
557
- project_dirs = [CLAUDE_PROJECTS_DIR / proj_name]
861
+ def _handle_pipeline_stop(self, pipe_id):
862
+ result, err = self._pipeline().stop_pipeline(pipe_id)
863
+ if err:
864
+ self._error_response(err, 400)
558
865
  else:
559
- if CLAUDE_PROJECTS_DIR.exists():
560
- all_dirs = sorted(
561
- (d for d in CLAUDE_PROJECTS_DIR.iterdir() if d.is_dir()),
562
- key=lambda d: d.stat().st_mtime,
563
- reverse=True,
564
- )
565
- project_dirs = all_dirs[:15]
566
- else:
567
- project_dirs = []
568
-
569
- for pd in project_dirs:
570
- native = scan_claude_sessions(pd, limit=60)
571
- for sid, info in native.items():
572
- if sid not in seen:
573
- seen[sid] = info
574
-
575
- # 1) Job meta 파일에서 보강
576
- if LOGS_DIR.exists():
577
- meta_files = sorted(
578
- LOGS_DIR.glob("job_*.meta"),
579
- key=lambda f: int(f.stem.split("_")[1]),
580
- reverse=True,
581
- )
582
- for mf in meta_files:
583
- meta = parse_meta_file(mf)
584
- if not meta:
585
- continue
586
- sid = meta.get("SESSION_ID", "").strip()
587
- if not sid:
588
- continue
589
-
590
- status = meta.get("STATUS", "unknown")
591
- if status == "running" and meta.get("PID"):
592
- try:
593
- os.kill(int(meta["PID"]), 0)
594
- except (ProcessLookupError, ValueError, OSError):
595
- status = "done"
596
-
597
- job_id = meta.get("JOB_ID", "")
598
- cost_usd = None
599
- if status in ("done", "failed"):
600
- out_file = LOGS_DIR / f"job_{job_id}.out"
601
- if out_file.exists():
602
- try:
603
- for line in open(out_file, "r"):
604
- try:
605
- obj = json.loads(line.strip())
606
- if obj.get("type") == "result":
607
- cost_usd = obj.get("total_cost_usd")
608
- except json.JSONDecodeError:
609
- continue
610
- except OSError:
611
- pass
612
-
613
- entry = {
614
- "session_id": sid,
615
- "job_id": job_id,
616
- "prompt": meta.get("PROMPT", ""),
617
- "timestamp": meta.get("CREATED_AT", ""),
618
- "status": status,
619
- "cwd": meta.get("CWD", ""),
620
- "cost_usd": cost_usd,
621
- "slug": "",
622
- }
623
-
624
- if sid not in seen:
625
- seen[sid] = entry
626
- else:
627
- existing = seen[sid]
628
- if existing.get("job_id") is None:
629
- existing.update({"job_id": job_id, "status": status, "cost_usd": cost_usd})
630
- else:
631
- try:
632
- if int(job_id) > int(existing.get("job_id", 0)):
633
- seen[sid] = entry
634
- except (ValueError, TypeError):
635
- pass
636
-
637
- # 2) history.log 보충
638
- history_file = SESSIONS_DIR / "history.log"
639
- if history_file.exists():
640
- try:
641
- for line in history_file.read_text("utf-8").strip().split("\n"):
642
- parts = line.split("|", 2)
643
- if len(parts) >= 2:
644
- ts, sid = parts[0].strip(), parts[1].strip()
645
- if not sid:
646
- continue
647
- prompt = parts[2].strip() if len(parts) > 2 else ""
648
- if sid not in seen:
649
- seen[sid] = {
650
- "session_id": sid, "job_id": None,
651
- "prompt": prompt, "timestamp": ts,
652
- "status": "done", "cwd": None,
653
- "cost_usd": None, "slug": "",
654
- }
655
- except OSError:
656
- pass
866
+ self._json_response(result)
657
867
 
658
- # cwd 필터 적용
659
- if filter_cwd:
660
- norm = os.path.normpath(filter_cwd)
661
- seen = {
662
- sid: s for sid, s in seen.items()
663
- if s.get("cwd") and os.path.normpath(s["cwd"]) == norm
664
- }
665
-
666
- sessions = sorted(seen.values(), key=lambda s: s.get("timestamp") or "", reverse=True)
667
- self._json_response(sessions[:50])
668
-
669
- def _handle_get_config(self):
670
- defaults = {
671
- "skip_permissions": True,
672
- "allowed_tools": "Bash,Read,Write,Edit,Glob,Grep,Agent,NotebookEdit,WebFetch,WebSearch",
673
- "model": "",
674
- "max_jobs": 10,
675
- "append_system_prompt": "",
676
- "target_repo": "",
677
- "base_branch": "main",
678
- "checkpoint_interval": 5,
679
- "locale": "ko",
680
- }
681
- try:
682
- if SETTINGS_FILE.exists():
683
- saved = json.loads(SETTINGS_FILE.read_text("utf-8"))
684
- defaults.update(saved)
685
- except (json.JSONDecodeError, OSError):
686
- pass
687
- self._json_response(defaults)
868
+ def _handle_update_pipeline(self, pipe_id):
869
+ body = self._read_body()
870
+ result, err = self._pipeline().update_pipeline(
871
+ pipe_id,
872
+ command=body.get("command"),
873
+ interval=body.get("interval"),
874
+ name=body.get("name"),
875
+ on_complete=body.get("on_complete"),
876
+ )
877
+ if err:
878
+ self._error_response(err, 400)
879
+ else:
880
+ self._json_response(result)
688
881
 
689
- def _handle_save_config(self):
882
+ def _handle_pipeline_reset(self, pipe_id):
690
883
  body = self._read_body()
691
- if not body or not isinstance(body, dict):
692
- return self._error_response("설정 데이터가 필요합니다")
884
+ result, err = self._pipeline().reset_phase(pipe_id, phase=body.get("phase"))
885
+ if err:
886
+ self._error_response(err, 400)
887
+ else:
888
+ self._json_response(result)
693
889
 
694
- current = {}
695
- try:
696
- if SETTINGS_FILE.exists():
697
- current = json.loads(SETTINGS_FILE.read_text("utf-8"))
698
- except (json.JSONDecodeError, OSError):
699
- pass
890
+ # ── Persona 핸들러 ──
700
891
 
701
- allowed_keys = {
702
- "skip_permissions", "allowed_tools", "model", "max_jobs",
703
- "append_system_prompt", "target_repo", "base_branch",
704
- "checkpoint_interval", "locale",
705
- }
706
- for k, v in body.items():
707
- if k in allowed_keys:
708
- current[k] = v
892
+ def _handle_get_persona(self, persona_id):
893
+ result, err = self._personas().get_persona(persona_id)
894
+ if err:
895
+ self._error_response(err, 404, code="PERSONA_NOT_FOUND")
896
+ else:
897
+ self._json_response(result)
709
898
 
710
- try:
711
- DATA_DIR.mkdir(parents=True, exist_ok=True)
712
- SETTINGS_FILE.write_text(
713
- json.dumps(current, ensure_ascii=False, indent=2), "utf-8"
714
- )
715
- self._json_response({"ok": True, "config": current})
716
- except OSError as e:
717
- self._error_response(f"설정 저장 실패: {e}", 500)
899
+ def _handle_create_persona(self):
900
+ body = self._read_body()
901
+ name = body.get("name", "").strip()
902
+ if not name:
903
+ return self._error_response("name 필드가 필요합니다", code="MISSING_FIELD")
904
+ result, err = self._personas().create_persona(
905
+ name=name,
906
+ role=body.get("role", "custom"),
907
+ description=body.get("description", ""),
908
+ system_prompt=body.get("system_prompt", ""),
909
+ icon=body.get("icon", "user"),
910
+ color=body.get("color", "#6366f1"),
911
+ )
912
+ self._json_response(result, 201)
718
913
 
719
- def _handle_get_recent_dirs(self):
720
- try:
721
- if RECENT_DIRS_FILE.exists():
722
- data = json.loads(RECENT_DIRS_FILE.read_text("utf-8"))
723
- else:
724
- data = []
725
- self._json_response(data)
726
- except (json.JSONDecodeError, OSError):
727
- self._json_response([])
728
-
729
- def _handle_save_recent_dirs(self):
914
+ def _handle_update_persona(self, persona_id):
730
915
  body = self._read_body()
731
- dirs = body.get("dirs")
732
- if not isinstance(dirs, list):
733
- return self._error_response("dirs 배열이 필요합니다")
734
- dirs = [d for d in dirs if isinstance(d, str)][:8]
735
- try:
736
- DATA_DIR.mkdir(parents=True, exist_ok=True)
737
- RECENT_DIRS_FILE.write_text(json.dumps(dirs, ensure_ascii=False), "utf-8")
738
- self._json_response({"ok": True})
739
- except OSError as e:
740
- self._error_response(f"저장 실패: {e}", 500)
916
+ result, err = self._personas().update_persona(persona_id, body)
917
+ if err:
918
+ status = 403 if "내장" in err else 404
919
+ self._error_response(err, status)
920
+ else:
921
+ self._json_response(result)
741
922
 
742
- def _handle_dirs(self, dir_path):
743
- try:
744
- dir_path = os.path.abspath(os.path.expanduser(dir_path))
745
- if not os.path.isdir(dir_path):
746
- return self._error_response("디렉토리가 아닙니다", 400)
923
+ def _handle_delete_persona(self, persona_id):
924
+ result, err = self._personas().delete_persona(persona_id)
925
+ if err:
926
+ status = 403 if "내장" in err else 404
927
+ self._error_response(err, status)
928
+ else:
929
+ self._json_response({"deleted": True, "persona": result})
747
930
 
748
- entries = []
749
- try:
750
- items = sorted(os.listdir(dir_path))
751
- except PermissionError:
752
- return self._error_response("접근 권한 없음", 403)
753
-
754
- parent = os.path.dirname(dir_path)
755
- if parent != dir_path:
756
- entries.append({"name": "..", "path": parent, "type": "dir"})
757
-
758
- for item in items:
759
- if item.startswith("."):
760
- continue
761
- full = os.path.join(dir_path, item)
762
- entry = {"name": item, "path": full}
763
- if os.path.isdir(full):
764
- entry["type"] = "dir"
765
- else:
766
- entry["type"] = "file"
767
- try:
768
- entry["size"] = os.path.getsize(full)
769
- except OSError:
770
- entry["size"] = 0
771
- entries.append(entry)
772
-
773
- self._json_response({"current": dir_path, "entries": entries})
774
- except Exception as e:
775
- self._error_response(f"디렉토리 읽기 실패: {e}", 500)
931
+ def _handle_delete_pipeline(self, pipe_id):
932
+ result, err = self._pipeline().delete_pipeline(pipe_id)
933
+ if err:
934
+ self._error_response(err, 404)
935
+ else:
936
+ self._json_response({"deleted": True, "pipeline": result})
776
937
 
777
938
  def _serve_static(self, url_path):
778
939
  if url_path in ("/", ""):