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.
- package/README.md +2 -2
- package/bin/autoloop.sh +382 -0
- package/bin/ctl +327 -5
- package/bin/native-app.py +5 -2
- package/bin/watchdog.sh +357 -0
- package/cognitive/__init__.py +14 -0
- package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
- package/cognitive/dispatcher.py +192 -0
- package/cognitive/evaluator.py +289 -0
- package/cognitive/goal_engine.py +232 -0
- package/cognitive/learning.py +189 -0
- package/cognitive/orchestrator.py +303 -0
- package/cognitive/planner.py +207 -0
- package/cognitive/prompts/analyst.md +31 -0
- package/cognitive/prompts/coder.md +22 -0
- package/cognitive/prompts/reviewer.md +33 -0
- package/cognitive/prompts/tester.md +21 -0
- package/cognitive/prompts/writer.md +25 -0
- package/config.sh +6 -1
- package/dag/__init__.py +5 -0
- package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
- package/dag/__pycache__/graph.cpython-314.pyc +0 -0
- package/dag/graph.py +222 -0
- package/lib/jobs.sh +12 -1
- package/package.json +5 -1
- package/postinstall.sh +1 -1
- package/service/controller.sh +43 -11
- package/web/audit.py +122 -0
- package/web/checkpoint.py +80 -0
- package/web/config.py +2 -5
- package/web/handler.py +464 -26
- package/web/handler_fs.py +15 -14
- package/web/handler_goals.py +203 -0
- package/web/handler_jobs.py +165 -42
- package/web/handler_memory.py +203 -0
- package/web/jobs.py +576 -12
- package/web/personas.py +419 -0
- package/web/pipeline.py +682 -50
- package/web/presets.py +506 -0
- package/web/projects.py +58 -4
- package/web/static/api.js +90 -3
- package/web/static/app.js +8 -0
- package/web/static/base.css +51 -12
- package/web/static/context.js +14 -4
- package/web/static/form.css +3 -2
- package/web/static/goals.css +363 -0
- package/web/static/goals.js +300 -0
- package/web/static/i18n.js +288 -0
- package/web/static/index.html +142 -6
- package/web/static/jobs.css +951 -4
- package/web/static/jobs.js +890 -54
- package/web/static/memoryview.js +117 -0
- package/web/static/personas.js +228 -0
- package/web/static/pipeline.css +308 -1
- package/web/static/pipelines.js +249 -14
- package/web/static/presets.js +244 -0
- package/web/static/send.js +26 -4
- package/web/static/settings-style.css +34 -3
- package/web/static/settings.js +37 -1
- package/web/static/stream.js +242 -19
- package/web/static/utils.js +54 -2
- 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
|
-
|
|
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
|
-
|
|
180
|
-
except
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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:
|