claude-controller 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +327 -5
  4. package/bin/native-app.py +5 -2
  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 +5 -1
  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 +464 -26
  38. package/web/handler_fs.py +15 -14
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +165 -42
  41. package/web/handler_memory.py +203 -0
  42. package/web/jobs.py +576 -12
  43. package/web/personas.py +419 -0
  44. package/web/pipeline.py +682 -50
  45. package/web/presets.py +506 -0
  46. package/web/projects.py +58 -4
  47. package/web/static/api.js +90 -3
  48. package/web/static/app.js +8 -0
  49. package/web/static/base.css +51 -12
  50. package/web/static/context.js +14 -4
  51. package/web/static/form.css +3 -2
  52. package/web/static/goals.css +363 -0
  53. package/web/static/goals.js +300 -0
  54. package/web/static/i18n.js +288 -0
  55. package/web/static/index.html +142 -6
  56. package/web/static/jobs.css +951 -4
  57. package/web/static/jobs.js +890 -54
  58. package/web/static/memoryview.js +117 -0
  59. package/web/static/personas.js +228 -0
  60. package/web/static/pipeline.css +308 -1
  61. package/web/static/pipelines.js +249 -14
  62. package/web/static/presets.js +244 -0
  63. package/web/static/send.js +26 -4
  64. package/web/static/settings-style.css +34 -3
  65. package/web/static/settings.js +37 -1
  66. package/web/static/stream.js +242 -19
  67. package/web/static/utils.js +54 -2
  68. package/web/webhook.py +210 -0
package/web/handler.py CHANGED
@@ -17,7 +17,10 @@ import importlib
17
17
  import json
18
18
  import os
19
19
  import re
20
+ import shutil
20
21
  import sys
22
+ import time
23
+ from datetime import datetime, timezone
21
24
  from urllib.parse import urlparse, parse_qs
22
25
 
23
26
  from config import (
@@ -30,13 +33,30 @@ import jobs
30
33
  import checkpoint
31
34
  import projects as _projects_mod
32
35
  import pipeline as _pipeline_mod
36
+ import personas as _personas_mod
37
+ import webhook as _webhook_mod
38
+ import audit as _audit_mod
33
39
 
34
40
  from handler_jobs import JobHandlerMixin
35
41
  from handler_sessions import SessionHandlerMixin
36
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
+ }
37
57
 
38
58
  # 핫 리로드 대상 모듈
39
- _HOT_MODULES = [jobs, checkpoint, _projects_mod, _pipeline_mod]
59
+ _HOT_MODULES = [jobs, checkpoint, _projects_mod, _pipeline_mod, _personas_mod, _webhook_mod, _audit_mod]
40
60
 
41
61
 
42
62
  def _hot_reload():
@@ -80,6 +100,8 @@ class ControllerHandler(
80
100
  JobHandlerMixin,
81
101
  SessionHandlerMixin,
82
102
  FsHandlerMixin,
103
+ GoalHandlerMixin,
104
+ MemoryHandlerMixin,
83
105
  http.server.BaseHTTPRequestHandler,
84
106
  ):
85
107
  """Controller REST API + 정적 파일 서빙 핸들러"""
@@ -87,12 +109,27 @@ class ControllerHandler(
87
109
  def log_message(self, format, *args):
88
110
  sys.stderr.write(f" [{self.log_date_time_string()}] {format % args}\n")
89
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
+
90
126
  # ── Mixin 모듈 접근자 (리로드 후에도 최신 참조) ──
91
127
 
92
128
  def _jobs_mod(self): return jobs
93
129
  def _ckpt_mod(self): return checkpoint
94
130
  def _projects(self): return _projects_mod
95
131
  def _pipeline(self): return _pipeline_mod
132
+ def _personas(self): return _personas_mod
96
133
 
97
134
  # ════════════════════════════════════════════════
98
135
  # 보안 미들웨어
@@ -133,11 +170,16 @@ class ControllerHandler(
133
170
  token = auth_header[7:]
134
171
  if verify_token(token):
135
172
  return True
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
136
178
  self._send_unauthorized()
137
179
  return False
138
180
 
139
181
  def _send_forbidden(self, message="Forbidden"):
140
- 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")
141
183
  self.send_response(403)
142
184
  self.send_header("Content-Type", "application/json; charset=utf-8")
143
185
  self.send_header("Content-Length", str(len(body)))
@@ -145,7 +187,7 @@ class ControllerHandler(
145
187
  self.wfile.write(body)
146
188
 
147
189
  def _send_unauthorized(self):
148
- 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")
149
191
  self.send_response(401)
150
192
  self.send_header("Content-Type", "application/json; charset=utf-8")
151
193
  self.send_header("WWW-Authenticate", "Bearer")
@@ -167,29 +209,40 @@ class ControllerHandler(
167
209
  self.end_headers()
168
210
  self.wfile.write(body)
169
211
 
170
- def _error_response(self, message, status=400):
171
- 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
172
218
 
173
219
  def _read_body(self):
174
- length = int(self.headers.get("Content-Length", 0))
175
- if length == 0:
176
- return {}
177
- raw = self.rfile.read(length)
178
220
  try:
179
- return json.loads(raw)
180
- except json.JSONDecodeError:
221
+ length = int(self.headers.get("Content-Length", 0))
222
+ except (ValueError, TypeError):
223
+ return {}
224
+ if length <= 0:
181
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
182
234
 
183
235
  def _serve_file(self, file_path, base_dir):
184
236
  try:
185
237
  resolved = file_path.resolve()
186
- if not str(resolved).startswith(str(base_dir.resolve())):
187
- 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")
188
241
  except (ValueError, OSError):
189
- return self._error_response("잘못된 경로", 400)
242
+ return self._error_response("잘못된 경로", 400, code="INVALID_PATH")
190
243
 
191
244
  if not resolved.exists() or not resolved.is_file():
192
- return self._error_response("파일을 찾을 수 없습니다", 404)
245
+ return self._error_response("파일을 찾을 수 없습니다", 404, code="FILE_NOT_FOUND")
193
246
 
194
247
  ext = resolved.suffix.lower()
195
248
  mime = MIME_TYPES.get(ext, "application/octet-stream")
@@ -206,7 +259,7 @@ class ControllerHandler(
206
259
  self.end_headers()
207
260
  self.wfile.write(data)
208
261
  except OSError:
209
- self._error_response("파일 읽기 실패", 500)
262
+ self._error_response("파일 읽기 실패", 500, code="FILE_READ_ERROR")
210
263
 
211
264
  # ════════════════════════════════════════════════
212
265
  # HTTP 라우팅
@@ -218,6 +271,13 @@ class ControllerHandler(
218
271
  self.end_headers()
219
272
 
220
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):
221
281
  _hot_reload()
222
282
  parsed = urlparse(self.path)
223
283
  path = parsed.path.rstrip("/") or "/"
@@ -227,12 +287,33 @@ class ControllerHandler(
227
287
  if not self._check_auth(path):
228
288
  return
229
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)
230
304
  if path == "/api/auth/verify":
231
305
  return self._handle_auth_verify()
232
306
  if path == "/api/status":
233
307
  return self._handle_status()
308
+ if path == "/api/stats":
309
+ return self._handle_stats(parsed)
234
310
  if path == "/api/jobs":
235
- 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
+ )
236
317
  if path == "/api/sessions":
237
318
  qs = parse_qs(parsed.query)
238
319
  return self._handle_sessions(filter_cwd=qs.get("cwd", [None])[0])
@@ -245,12 +326,35 @@ class ControllerHandler(
245
326
  return self._handle_dirs(qs.get("path", [os.path.expanduser("~")])[0])
246
327
  if path == "/api/projects":
247
328
  return self._handle_list_projects()
329
+ if path == "/api/personas":
330
+ return self._json_response(self._personas().list_personas())
248
331
  if path == "/api/pipelines":
249
332
  return self._handle_list_pipelines()
250
-
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))
251
349
  match = re.match(r"^/api/pipelines/([^/]+)/status$", path)
252
350
  if match:
253
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))
254
358
  match = re.match(r"^/api/projects/([^/]+)$", path)
255
359
  if match:
256
360
  return self._handle_get_project(match.group(1))
@@ -263,6 +367,9 @@ class ControllerHandler(
263
367
  match = re.match(r"^/api/jobs/(\w+)/checkpoints$", path)
264
368
  if match:
265
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))
266
373
  match = re.match(r"^/api/session/([a-f0-9-]+)/job$", path)
267
374
  if match:
268
375
  return self._handle_job_by_session(match.group(1))
@@ -272,7 +379,24 @@ class ControllerHandler(
272
379
 
273
380
  self._serve_static(parsed.path)
274
381
 
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
+
275
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):
276
400
  _hot_reload()
277
401
  parsed = urlparse(self.path)
278
402
  path = parsed.path.rstrip("/") or "/"
@@ -282,6 +406,19 @@ class ControllerHandler(
282
406
  if not self._check_auth(path):
283
407
  return
284
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):
285
422
  if path == "/api/auth/verify":
286
423
  return self._handle_auth_verify()
287
424
  if path == "/api/send":
@@ -302,11 +439,33 @@ class ControllerHandler(
302
439
  return self._handle_add_project()
303
440
  if path == "/api/projects/create":
304
441
  return self._handle_create_project()
442
+ if path == "/api/personas":
443
+ return self._handle_create_persona()
305
444
  if path == "/api/pipelines":
306
445
  return self._handle_create_pipeline()
307
446
  if path == "/api/pipelines/tick-all":
308
447
  return self._json_response(self._pipeline().tick_all())
309
-
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))
310
469
  match = re.match(r"^/api/pipelines/([^/]+)/run$", path)
311
470
  if match:
312
471
  return self._handle_pipeline_run(match.group(1))
@@ -326,9 +485,16 @@ class ControllerHandler(
326
485
  if match:
327
486
  return self._handle_job_rewind(match.group(1))
328
487
 
329
- self._error_response("알 수 없는 엔드포인트", 404)
488
+ self._error_response("알 수 없는 엔드포인트", 404, code="ENDPOINT_NOT_FOUND")
330
489
 
331
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):
332
498
  _hot_reload()
333
499
  parsed = urlparse(self.path)
334
500
  path = parsed.path.rstrip("/") or "/"
@@ -338,6 +504,25 @@ class ControllerHandler(
338
504
  if not self._check_auth(path):
339
505
  return
340
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))
341
526
  match = re.match(r"^/api/pipelines/([^/]+)$", path)
342
527
  if match:
343
528
  return self._handle_delete_pipeline(match.group(1))
@@ -350,7 +535,7 @@ class ControllerHandler(
350
535
  if path == "/api/jobs":
351
536
  return self._handle_delete_completed_jobs()
352
537
 
353
- self._error_response("알 수 없는 엔드포인트", 404)
538
+ self._error_response("알 수 없는 엔드포인트", 404, code="ENDPOINT_NOT_FOUND")
354
539
 
355
540
  # ════════════════════════════════════════════════
356
541
  # 얇은 핸들러 (라우팅과 함께 유지)
@@ -364,12 +549,208 @@ class ControllerHandler(
364
549
  return self._json_response({"valid": True})
365
550
  self._json_response({"valid": False}, 401)
366
551
 
552
+ def _handle_stats(self, parsed):
553
+ qs = parse_qs(parsed.query)
554
+
555
+ # period 파라미터: day, week, month, all (기본값)
556
+ period = qs.get("period", ["all"])[0]
557
+ now = time.time()
558
+
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
564
+ else:
565
+ # from/to 커스텀 범위 지원 (ISO 날짜 또는 Unix timestamp)
566
+ from_ts = self._parse_ts(qs.get("from", [None])[0])
567
+
568
+ to_ts = self._parse_ts(qs.get("to", [None])[0]) or now
569
+
570
+ self._json_response(self._jobs_mod().get_stats(from_ts=from_ts, to_ts=to_ts))
571
+
572
+ @staticmethod
573
+ def _parse_ts(value):
574
+ """문자열을 Unix timestamp로 변환. ISO 날짜 또는 숫자 문자열 지원."""
575
+ if not value:
576
+ return None
577
+ try:
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
587
+
367
588
  def _handle_status(self):
368
589
  from utils import is_service_running
369
590
  from config import FIFO_PATH
370
591
  running, _ = is_service_running()
371
592
  self._json_response({"running": running, "fifo": str(FIFO_PATH)})
372
593
 
594
+ def _handle_health(self):
595
+ from utils import is_service_running
596
+ from config import FIFO_PATH, LOGS_DIR, PID_FILE, SESSIONS_DIR
597
+
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
606
+
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
621
+ try:
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:
634
+ pass
635
+
636
+ # ── 디스크 사용량 ──
637
+ logs_size_bytes = 0
638
+ if LOGS_DIR.exists():
639
+ for f in LOGS_DIR.iterdir():
640
+ try:
641
+ logs_size_bytes += f.stat().st_size
642
+ except OSError:
643
+ pass
644
+
645
+ disk_total, disk_used, disk_free = shutil.disk_usage("/")
646
+
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
665
+
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=..."""
710
+ qs = parse_qs(parsed.query)
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
+ # ── 웹훅 ──
724
+
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)
740
+
741
+ def _handle_logs_cleanup(self):
742
+ """보존 기간이 지난 완료/실패 작업 파일을 삭제한다."""
743
+ body = self._read_body() or {}
744
+ retention_days = body.get("retention_days", 30)
745
+ try:
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
+
373
754
  # ── 프로젝트 (얇은 위임) ──
374
755
 
375
756
  def _handle_list_projects(self):
@@ -382,15 +763,22 @@ class ControllerHandler(
382
763
  else:
383
764
  self._json_response(project)
384
765
 
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})
772
+
385
773
  def _handle_add_project(self):
386
774
  body = self._read_body()
387
775
  path = body.get("path", "").strip()
388
776
  if not path:
389
- return self._error_response("path 필드가 필요합니다")
777
+ return self._error_response("path 필드가 필요합니다", code="MISSING_FIELD")
390
778
  project, err = self._projects().add_project(
391
779
  path, name=body.get("name", "").strip(), description=body.get("description", "").strip())
392
780
  if err:
393
- self._error_response(err, 409)
781
+ self._error_response(err, 409, code="ALREADY_EXISTS")
394
782
  else:
395
783
  self._json_response(project, 201)
396
784
 
@@ -398,7 +786,7 @@ class ControllerHandler(
398
786
  body = self._read_body()
399
787
  path = body.get("path", "").strip()
400
788
  if not path:
401
- return self._error_response("path 필드가 필요합니다")
789
+ return self._error_response("path 필드가 필요합니다", code="MISSING_FIELD")
402
790
  project, err = self._projects().create_project(
403
791
  path, name=body.get("name", "").strip(),
404
792
  description=body.get("description", "").strip(),
@@ -436,16 +824,24 @@ class ControllerHandler(
436
824
  else:
437
825
  self._json_response(result)
438
826
 
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)
833
+
439
834
  def _handle_create_pipeline(self):
440
835
  body = self._read_body()
441
836
  path = body.get("project_path", "").strip()
442
837
  command = body.get("command", "").strip()
443
838
  if not path or not command:
444
- return self._error_response("project_path와 command 필드가 필요합니다")
839
+ return self._error_response("project_path와 command 필드가 필요합니다", code="MISSING_FIELD")
445
840
  result, err = self._pipeline().create_pipeline(
446
841
  path, command=command,
447
842
  interval=body.get("interval", "").strip(),
448
- name=body.get("name", "").strip())
843
+ name=body.get("name", "").strip(),
844
+ on_complete=body.get("on_complete", "").strip())
449
845
  if err:
450
846
  self._error_response(err, 400)
451
847
  else:
@@ -476,6 +872,7 @@ class ControllerHandler(
476
872
  command=body.get("command"),
477
873
  interval=body.get("interval"),
478
874
  name=body.get("name"),
875
+ on_complete=body.get("on_complete"),
479
876
  )
480
877
  if err:
481
878
  self._error_response(err, 400)
@@ -490,6 +887,47 @@ class ControllerHandler(
490
887
  else:
491
888
  self._json_response(result)
492
889
 
890
+ # ── Persona 핸들러 ──
891
+
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)
898
+
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)
913
+
914
+ def _handle_update_persona(self, persona_id):
915
+ body = self._read_body()
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)
922
+
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})
930
+
493
931
  def _handle_delete_pipeline(self, pipe_id):
494
932
  result, err = self._pipeline().delete_pipeline(pipe_id)
495
933
  if err: