claude-controller 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/autoloop.sh +382 -0
- package/bin/ctl +1189 -0
- package/bin/native-app.py +6 -3
- package/bin/watchdog.sh +357 -0
- package/cognitive/__init__.py +14 -0
- package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
- package/cognitive/dispatcher.py +192 -0
- package/cognitive/evaluator.py +289 -0
- package/cognitive/goal_engine.py +232 -0
- package/cognitive/learning.py +189 -0
- package/cognitive/orchestrator.py +303 -0
- package/cognitive/planner.py +207 -0
- package/cognitive/prompts/analyst.md +31 -0
- package/cognitive/prompts/coder.md +22 -0
- package/cognitive/prompts/reviewer.md +33 -0
- package/cognitive/prompts/tester.md +21 -0
- package/cognitive/prompts/writer.md +25 -0
- package/config.sh +6 -1
- package/dag/__init__.py +5 -0
- package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
- package/dag/__pycache__/graph.cpython-314.pyc +0 -0
- package/dag/graph.py +222 -0
- package/lib/jobs.sh +12 -1
- package/package.json +11 -5
- package/postinstall.sh +1 -1
- package/service/controller.sh +43 -11
- package/web/audit.py +122 -0
- package/web/checkpoint.py +80 -0
- package/web/config.py +2 -5
- package/web/handler.py +634 -473
- package/web/handler_fs.py +153 -0
- package/web/handler_goals.py +203 -0
- package/web/handler_jobs.py +372 -0
- package/web/handler_memory.py +203 -0
- package/web/handler_sessions.py +132 -0
- package/web/jobs.py +585 -13
- package/web/personas.py +419 -0
- package/web/pipeline.py +981 -0
- package/web/presets.py +506 -0
- package/web/projects.py +246 -0
- package/web/static/api.js +141 -0
- package/web/static/app.js +25 -1937
- package/web/static/attachments.js +144 -0
- package/web/static/base.css +497 -0
- package/web/static/context.js +204 -0
- package/web/static/dirs.js +246 -0
- package/web/static/form.css +763 -0
- package/web/static/goals.css +363 -0
- package/web/static/goals.js +300 -0
- package/web/static/i18n.js +625 -0
- package/web/static/index.html +215 -13
- package/web/static/{styles.css → jobs.css} +746 -1141
- package/web/static/jobs.js +1270 -0
- package/web/static/memoryview.js +117 -0
- package/web/static/personas.js +228 -0
- package/web/static/pipeline.css +338 -0
- package/web/static/pipelines.js +487 -0
- package/web/static/presets.js +244 -0
- package/web/static/send.js +135 -0
- package/web/static/settings-style.css +291 -0
- package/web/static/settings.js +81 -0
- package/web/static/stream.js +534 -0
- package/web/static/utils.js +131 -0
- package/web/webhook.py +210 -0
package/web/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,
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
except
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
241
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
329
|
-
|
|
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
|
-
|
|
333
|
-
|
|
555
|
+
# period 파라미터: day, week, month, all (기본값)
|
|
556
|
+
period = qs.get("period", ["all"])[0]
|
|
557
|
+
now = time.time()
|
|
334
558
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
565
|
+
# from/to 커스텀 범위 지원 (ISO 날짜 또는 Unix timestamp)
|
|
566
|
+
from_ts = self._parse_ts(qs.get("from", [None])[0])
|
|
341
567
|
|
|
342
|
-
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
except
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
413
|
-
|
|
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
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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(
|
|
764
|
+
self._json_response(project)
|
|
524
765
|
|
|
525
|
-
def
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
|
773
|
+
def _handle_add_project(self):
|
|
535
774
|
body = self._read_body()
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
if
|
|
542
|
-
|
|
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
|
-
|
|
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
|
|
846
|
+
self._error_response(err, 400)
|
|
547
847
|
else:
|
|
548
848
|
self._json_response(result, 201)
|
|
549
849
|
|
|
550
|
-
def
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
882
|
+
def _handle_pipeline_reset(self, pipe_id):
|
|
690
883
|
body = self._read_body()
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
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
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
)
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
|
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
|
-
|
|
732
|
-
if
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
if
|
|
746
|
-
|
|
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
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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 ("/", ""):
|