claude-controller 0.1.1 → 0.2.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/bin/ctl +867 -0
- package/bin/native-app.py +1 -1
- package/package.json +7 -5
- package/postinstall.sh +96 -15
- package/web/handler.py +190 -467
- package/web/handler_fs.py +152 -0
- package/web/handler_jobs.py +249 -0
- package/web/handler_sessions.py +132 -0
- package/web/jobs.py +9 -1
- package/web/pipeline.py +349 -0
- package/web/projects.py +192 -0
- package/web/static/api.js +54 -0
- package/web/static/app.js +17 -1937
- package/web/static/attachments.js +144 -0
- package/web/static/base.css +458 -0
- package/web/static/context.js +194 -0
- package/web/static/dirs.js +246 -0
- package/web/static/form.css +762 -0
- package/web/static/i18n.js +337 -0
- package/web/static/index.html +77 -11
- package/web/static/jobs.css +580 -0
- package/web/static/jobs.js +434 -0
- package/web/static/pipeline.css +31 -0
- package/web/static/pipelines.js +252 -0
- package/web/static/send.js +113 -0
- package/web/static/settings-style.css +260 -0
- package/web/static/settings.js +45 -0
- package/web/static/stream.js +311 -0
- package/web/static/utils.js +79 -0
- package/web/static/styles.css +0 -1922
package/web/handler.py
CHANGED
|
@@ -5,31 +5,49 @@ 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
|
|
15
20
|
import sys
|
|
16
|
-
import time
|
|
17
21
|
from urllib.parse import urlparse, parse_qs
|
|
18
22
|
|
|
19
23
|
from config import (
|
|
20
|
-
STATIC_DIR,
|
|
21
|
-
RECENT_DIRS_FILE, SETTINGS_FILE, SESSIONS_DIR,
|
|
22
|
-
CLAUDE_PROJECTS_DIR, FIFO_PATH,
|
|
24
|
+
STATIC_DIR, UPLOADS_DIR,
|
|
23
25
|
ALLOWED_ORIGINS, ALLOWED_HOSTS,
|
|
24
26
|
AUTH_REQUIRED, AUTH_EXEMPT_PREFIXES, AUTH_EXEMPT_PATHS,
|
|
25
27
|
)
|
|
26
|
-
from
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
from auth import verify_token
|
|
29
|
+
import jobs
|
|
30
|
+
import checkpoint
|
|
31
|
+
import projects as _projects_mod
|
|
32
|
+
import pipeline as _pipeline_mod
|
|
33
|
+
|
|
34
|
+
from handler_jobs import JobHandlerMixin
|
|
35
|
+
from handler_sessions import SessionHandlerMixin
|
|
36
|
+
from handler_fs import FsHandlerMixin
|
|
37
|
+
|
|
38
|
+
# 핫 리로드 대상 모듈
|
|
39
|
+
_HOT_MODULES = [jobs, checkpoint, _projects_mod, _pipeline_mod]
|
|
30
40
|
|
|
31
41
|
|
|
32
|
-
|
|
42
|
+
def _hot_reload():
|
|
43
|
+
for mod in _HOT_MODULES:
|
|
44
|
+
try:
|
|
45
|
+
importlib.reload(mod)
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# MIME 타입 맵
|
|
33
51
|
MIME_TYPES = {
|
|
34
52
|
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
|
35
53
|
".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp",
|
|
@@ -57,51 +75,45 @@ MIME_TYPES = {
|
|
|
57
75
|
".svg": "image/svg+xml", ".ico": "image/x-icon",
|
|
58
76
|
}
|
|
59
77
|
|
|
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
78
|
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
class ControllerHandler(
|
|
80
|
+
JobHandlerMixin,
|
|
81
|
+
SessionHandlerMixin,
|
|
82
|
+
FsHandlerMixin,
|
|
83
|
+
http.server.BaseHTTPRequestHandler,
|
|
84
|
+
):
|
|
75
85
|
"""Controller REST API + 정적 파일 서빙 핸들러"""
|
|
76
86
|
|
|
77
87
|
def log_message(self, format, *args):
|
|
78
88
|
sys.stderr.write(f" [{self.log_date_time_string()}] {format % args}\n")
|
|
79
89
|
|
|
90
|
+
# ── Mixin 모듈 접근자 (리로드 후에도 최신 참조) ──
|
|
91
|
+
|
|
92
|
+
def _jobs_mod(self): return jobs
|
|
93
|
+
def _ckpt_mod(self): return checkpoint
|
|
94
|
+
def _projects(self): return _projects_mod
|
|
95
|
+
def _pipeline(self): return _pipeline_mod
|
|
96
|
+
|
|
80
97
|
# ════════════════════════════════════════════════
|
|
81
98
|
# 보안 미들웨어
|
|
82
99
|
# ════════════════════════════════════════════════
|
|
83
100
|
|
|
84
101
|
def _get_origin(self):
|
|
85
|
-
"""요청의 Origin 헤더를 반환한다."""
|
|
86
102
|
return self.headers.get("Origin", "")
|
|
87
103
|
|
|
88
104
|
def _set_cors_headers(self):
|
|
89
|
-
"""허용된 Origin만 CORS 응답에 포함한다."""
|
|
90
105
|
origin = self._get_origin()
|
|
91
106
|
if origin in ALLOWED_ORIGINS:
|
|
92
107
|
self.send_header("Access-Control-Allow-Origin", origin)
|
|
93
108
|
self.send_header("Vary", "Origin")
|
|
94
109
|
elif not origin:
|
|
95
|
-
# same-origin 요청 (Origin 헤더 없음) — 로컬 접근 허용
|
|
96
110
|
self.send_header("Access-Control-Allow-Origin", "null")
|
|
97
111
|
self.send_header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS")
|
|
98
112
|
self.send_header("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
|
99
113
|
self.send_header("Access-Control-Allow-Credentials", "true")
|
|
100
114
|
|
|
101
115
|
def _check_host(self) -> bool:
|
|
102
|
-
"""Host 헤더를 검증하여 DNS Rebinding 공격을 차단한다."""
|
|
103
116
|
host = self.headers.get("Host", "")
|
|
104
|
-
# 포트 번호 제거 후 호스트명만 비교
|
|
105
117
|
hostname = host.split(":")[0] if ":" in host else host
|
|
106
118
|
if hostname not in ALLOWED_HOSTS:
|
|
107
119
|
self._send_forbidden("잘못된 Host 헤더")
|
|
@@ -109,24 +121,18 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
|
|
|
109
121
|
return True
|
|
110
122
|
|
|
111
123
|
def _check_auth(self, path: str) -> bool:
|
|
112
|
-
"""토큰 인증을 검증한다. AUTH_REQUIRED=false면 항상 통과."""
|
|
113
124
|
if not AUTH_REQUIRED:
|
|
114
125
|
return True
|
|
115
|
-
|
|
116
|
-
# 정적 파일 및 면제 경로
|
|
117
126
|
if path in AUTH_EXEMPT_PATHS:
|
|
118
127
|
return True
|
|
119
128
|
for prefix in AUTH_EXEMPT_PREFIXES:
|
|
120
129
|
if path.startswith(prefix):
|
|
121
130
|
return True
|
|
122
|
-
|
|
123
|
-
# Authorization: Bearer <token>
|
|
124
131
|
auth_header = self.headers.get("Authorization", "")
|
|
125
132
|
if auth_header.startswith("Bearer "):
|
|
126
133
|
token = auth_header[7:]
|
|
127
134
|
if verify_token(token):
|
|
128
135
|
return True
|
|
129
|
-
|
|
130
136
|
self._send_unauthorized()
|
|
131
137
|
return False
|
|
132
138
|
|
|
@@ -175,7 +181,6 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
|
|
|
175
181
|
return {}
|
|
176
182
|
|
|
177
183
|
def _serve_file(self, file_path, base_dir):
|
|
178
|
-
"""파일을 읽어 HTTP 응답으로 전송한다. 경로 탈출 방지 포함."""
|
|
179
184
|
try:
|
|
180
185
|
resolved = file_path.resolve()
|
|
181
186
|
if not str(resolved).startswith(str(base_dir.resolve())):
|
|
@@ -196,25 +201,27 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
|
|
|
196
201
|
self.send_response(200)
|
|
197
202
|
self.send_header("Content-Type", mime)
|
|
198
203
|
self._set_cors_headers()
|
|
204
|
+
self.send_header("Cache-Control", "no-cache, must-revalidate")
|
|
199
205
|
self.send_header("Content-Length", str(len(data)))
|
|
200
206
|
self.end_headers()
|
|
201
207
|
self.wfile.write(data)
|
|
202
208
|
except OSError:
|
|
203
209
|
self._error_response("파일 읽기 실패", 500)
|
|
204
210
|
|
|
205
|
-
#
|
|
211
|
+
# ════════════════════════════════════════════════
|
|
212
|
+
# HTTP 라우팅
|
|
213
|
+
# ════════════════════════════════════════════════
|
|
214
|
+
|
|
206
215
|
def do_OPTIONS(self):
|
|
207
|
-
# preflight는 Host/Auth 검사 면제 (브라우저 자동 요청)
|
|
208
216
|
self.send_response(204)
|
|
209
217
|
self._set_cors_headers()
|
|
210
218
|
self.end_headers()
|
|
211
219
|
|
|
212
|
-
# ── GET ──
|
|
213
220
|
def do_GET(self):
|
|
221
|
+
_hot_reload()
|
|
214
222
|
parsed = urlparse(self.path)
|
|
215
223
|
path = parsed.path.rstrip("/") or "/"
|
|
216
224
|
|
|
217
|
-
# 보안 게이트
|
|
218
225
|
if not self._check_host():
|
|
219
226
|
return
|
|
220
227
|
if not self._check_auth(path):
|
|
@@ -222,24 +229,31 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
|
|
|
222
229
|
|
|
223
230
|
if path == "/api/auth/verify":
|
|
224
231
|
return self._handle_auth_verify()
|
|
225
|
-
|
|
226
232
|
if path == "/api/status":
|
|
227
233
|
return self._handle_status()
|
|
228
234
|
if path == "/api/jobs":
|
|
229
235
|
return self._handle_jobs()
|
|
230
236
|
if path == "/api/sessions":
|
|
231
237
|
qs = parse_qs(parsed.query)
|
|
232
|
-
filter_cwd
|
|
233
|
-
return self._handle_sessions(filter_cwd=filter_cwd)
|
|
238
|
+
return self._handle_sessions(filter_cwd=qs.get("cwd", [None])[0])
|
|
234
239
|
if path == "/api/config":
|
|
235
240
|
return self._handle_get_config()
|
|
236
241
|
if path == "/api/recent-dirs":
|
|
237
242
|
return self._handle_get_recent_dirs()
|
|
238
243
|
if path == "/api/dirs":
|
|
239
244
|
qs = parse_qs(parsed.query)
|
|
240
|
-
|
|
241
|
-
|
|
245
|
+
return self._handle_dirs(qs.get("path", [os.path.expanduser("~")])[0])
|
|
246
|
+
if path == "/api/projects":
|
|
247
|
+
return self._handle_list_projects()
|
|
248
|
+
if path == "/api/pipelines":
|
|
249
|
+
return self._handle_list_pipelines()
|
|
242
250
|
|
|
251
|
+
match = re.match(r"^/api/pipelines/([^/]+)/status$", path)
|
|
252
|
+
if match:
|
|
253
|
+
return self._handle_pipeline_status(match.group(1))
|
|
254
|
+
match = re.match(r"^/api/projects/([^/]+)$", path)
|
|
255
|
+
if match:
|
|
256
|
+
return self._handle_get_project(match.group(1))
|
|
243
257
|
match = re.match(r"^/api/jobs/(\w+)/result$", path)
|
|
244
258
|
if match:
|
|
245
259
|
return self._handle_job_result(match.group(1))
|
|
@@ -256,15 +270,13 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
|
|
|
256
270
|
if match:
|
|
257
271
|
return self._serve_file(UPLOADS_DIR / match.group(1), UPLOADS_DIR)
|
|
258
272
|
|
|
259
|
-
# 정적 파일
|
|
260
273
|
self._serve_static(parsed.path)
|
|
261
274
|
|
|
262
|
-
# ── POST ──
|
|
263
275
|
def do_POST(self):
|
|
276
|
+
_hot_reload()
|
|
264
277
|
parsed = urlparse(self.path)
|
|
265
278
|
path = parsed.path.rstrip("/") or "/"
|
|
266
279
|
|
|
267
|
-
# 보안 게이트
|
|
268
280
|
if not self._check_host():
|
|
269
281
|
return
|
|
270
282
|
if not self._check_auth(path):
|
|
@@ -272,7 +284,6 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
|
|
|
272
284
|
|
|
273
285
|
if path == "/api/auth/verify":
|
|
274
286
|
return self._handle_auth_verify()
|
|
275
|
-
|
|
276
287
|
if path == "/api/send":
|
|
277
288
|
return self._handle_send()
|
|
278
289
|
if path == "/api/upload":
|
|
@@ -285,24 +296,54 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
|
|
|
285
296
|
return self._handle_save_config()
|
|
286
297
|
if path == "/api/recent-dirs":
|
|
287
298
|
return self._handle_save_recent_dirs()
|
|
288
|
-
|
|
299
|
+
if path == "/api/mkdir":
|
|
300
|
+
return self._handle_mkdir()
|
|
301
|
+
if path == "/api/projects":
|
|
302
|
+
return self._handle_add_project()
|
|
303
|
+
if path == "/api/projects/create":
|
|
304
|
+
return self._handle_create_project()
|
|
305
|
+
if path == "/api/pipelines":
|
|
306
|
+
return self._handle_create_pipeline()
|
|
307
|
+
if path == "/api/pipelines/tick-all":
|
|
308
|
+
return self._json_response(self._pipeline().tick_all())
|
|
309
|
+
|
|
310
|
+
match = re.match(r"^/api/pipelines/([^/]+)/run$", path)
|
|
311
|
+
if match:
|
|
312
|
+
return self._handle_pipeline_run(match.group(1))
|
|
313
|
+
match = re.match(r"^/api/pipelines/([^/]+)/stop$", path)
|
|
314
|
+
if match:
|
|
315
|
+
return self._handle_pipeline_stop(match.group(1))
|
|
316
|
+
match = re.match(r"^/api/pipelines/([^/]+)/update$", path)
|
|
317
|
+
if match:
|
|
318
|
+
return self._handle_update_pipeline(match.group(1))
|
|
319
|
+
match = re.match(r"^/api/pipelines/([^/]+)/reset$", path)
|
|
320
|
+
if match:
|
|
321
|
+
return self._handle_pipeline_reset(match.group(1))
|
|
322
|
+
match = re.match(r"^/api/projects/([^/]+)$", path)
|
|
323
|
+
if match:
|
|
324
|
+
return self._handle_update_project(match.group(1))
|
|
289
325
|
match = re.match(r"^/api/jobs/(\w+)/rewind$", path)
|
|
290
326
|
if match:
|
|
291
327
|
return self._handle_job_rewind(match.group(1))
|
|
292
328
|
|
|
293
329
|
self._error_response("알 수 없는 엔드포인트", 404)
|
|
294
330
|
|
|
295
|
-
# ── DELETE ──
|
|
296
331
|
def do_DELETE(self):
|
|
332
|
+
_hot_reload()
|
|
297
333
|
parsed = urlparse(self.path)
|
|
298
334
|
path = parsed.path.rstrip("/") or "/"
|
|
299
335
|
|
|
300
|
-
# 보안 게이트
|
|
301
336
|
if not self._check_host():
|
|
302
337
|
return
|
|
303
338
|
if not self._check_auth(path):
|
|
304
339
|
return
|
|
305
340
|
|
|
341
|
+
match = re.match(r"^/api/pipelines/([^/]+)$", path)
|
|
342
|
+
if match:
|
|
343
|
+
return self._handle_delete_pipeline(match.group(1))
|
|
344
|
+
match = re.match(r"^/api/projects/([^/]+)$", path)
|
|
345
|
+
if match:
|
|
346
|
+
return self._handle_remove_project(match.group(1))
|
|
306
347
|
match = re.match(r"^/api/jobs/(\w+)$", path)
|
|
307
348
|
if match:
|
|
308
349
|
return self._handle_delete_job(match.group(1))
|
|
@@ -312,12 +353,10 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
|
|
|
312
353
|
self._error_response("알 수 없는 엔드포인트", 404)
|
|
313
354
|
|
|
314
355
|
# ════════════════════════════════════════════════
|
|
315
|
-
#
|
|
356
|
+
# 얇은 핸들러 (라우팅과 함께 유지)
|
|
316
357
|
# ════════════════════════════════════════════════
|
|
317
358
|
|
|
318
359
|
def _handle_auth_verify(self):
|
|
319
|
-
"""POST /api/auth/verify — 토큰 검증 엔드포인트.
|
|
320
|
-
프론트엔드에서 토큰 유효성을 확인할 때 사용한다."""
|
|
321
360
|
auth_header = self.headers.get("Authorization", "")
|
|
322
361
|
if auth_header.startswith("Bearer "):
|
|
323
362
|
token = auth_header[7:]
|
|
@@ -326,453 +365,137 @@ class ControllerHandler(http.server.BaseHTTPRequestHandler):
|
|
|
326
365
|
self._json_response({"valid": False}, 401)
|
|
327
366
|
|
|
328
367
|
def _handle_status(self):
|
|
368
|
+
from utils import is_service_running
|
|
369
|
+
from config import FIFO_PATH
|
|
329
370
|
running, _ = is_service_running()
|
|
330
371
|
self._json_response({"running": running, "fifo": str(FIFO_PATH)})
|
|
331
372
|
|
|
332
|
-
|
|
333
|
-
|
|
373
|
+
# ── 프로젝트 (얇은 위임) ──
|
|
374
|
+
|
|
375
|
+
def _handle_list_projects(self):
|
|
376
|
+
self._json_response(self._projects().list_projects())
|
|
334
377
|
|
|
335
|
-
def
|
|
336
|
-
|
|
378
|
+
def _handle_get_project(self, project_id):
|
|
379
|
+
project, err = self._projects().get_project(project_id)
|
|
337
380
|
if err:
|
|
338
381
|
self._error_response(err, 404)
|
|
339
382
|
else:
|
|
340
|
-
self._json_response(
|
|
383
|
+
self._json_response(project)
|
|
341
384
|
|
|
342
|
-
def
|
|
385
|
+
def _handle_add_project(self):
|
|
343
386
|
body = self._read_body()
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
if
|
|
350
|
-
|
|
387
|
+
path = body.get("path", "").strip()
|
|
388
|
+
if not path:
|
|
389
|
+
return self._error_response("path 필드가 필요합니다")
|
|
390
|
+
project, err = self._projects().add_project(
|
|
391
|
+
path, name=body.get("name", "").strip(), description=body.get("description", "").strip())
|
|
392
|
+
if err:
|
|
393
|
+
self._error_response(err, 409)
|
|
394
|
+
else:
|
|
395
|
+
self._json_response(project, 201)
|
|
351
396
|
|
|
352
|
-
|
|
353
|
-
raw = base64.b64decode(data_b64)
|
|
354
|
-
except Exception:
|
|
355
|
-
return self._error_response("잘못된 base64 데이터")
|
|
356
|
-
|
|
357
|
-
ext = os.path.splitext(filename)[1].lower()
|
|
358
|
-
if ext not in ALLOWED_UPLOAD_EXTS:
|
|
359
|
-
ext = ext if ext else ".bin"
|
|
360
|
-
prefix = "img" if ext in IMAGE_EXTS else "file"
|
|
361
|
-
safe_name = f"{prefix}_{int(time.time())}_{os.getpid()}_{id(raw) % 10000}{ext}"
|
|
362
|
-
|
|
363
|
-
UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
|
364
|
-
filepath = UPLOADS_DIR / safe_name
|
|
365
|
-
filepath.write_bytes(raw)
|
|
366
|
-
|
|
367
|
-
is_image = ext in IMAGE_EXTS
|
|
368
|
-
self._json_response({
|
|
369
|
-
"path": str(filepath),
|
|
370
|
-
"filename": safe_name,
|
|
371
|
-
"originalName": filename,
|
|
372
|
-
"size": len(raw),
|
|
373
|
-
"isImage": is_image,
|
|
374
|
-
}, 201)
|
|
375
|
-
|
|
376
|
-
def _handle_send(self):
|
|
397
|
+
def _handle_create_project(self):
|
|
377
398
|
body = self._read_body()
|
|
378
|
-
|
|
379
|
-
if not
|
|
380
|
-
return self._error_response("
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
job_id=body.get("id") or None,
|
|
386
|
-
images=body.get("images") or None,
|
|
387
|
-
session=body.get("session") or None,
|
|
388
|
-
)
|
|
399
|
+
path = body.get("path", "").strip()
|
|
400
|
+
if not path:
|
|
401
|
+
return self._error_response("path 필드가 필요합니다")
|
|
402
|
+
project, err = self._projects().create_project(
|
|
403
|
+
path, name=body.get("name", "").strip(),
|
|
404
|
+
description=body.get("description", "").strip(),
|
|
405
|
+
init_git=body.get("init_git", True))
|
|
389
406
|
if err:
|
|
390
|
-
self._error_response(err,
|
|
407
|
+
self._error_response(err, 400)
|
|
391
408
|
else:
|
|
392
|
-
self._json_response(
|
|
409
|
+
self._json_response(project, 201)
|
|
393
410
|
|
|
394
|
-
def
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
411
|
+
def _handle_update_project(self, project_id):
|
|
412
|
+
body = self._read_body()
|
|
413
|
+
project, err = self._projects().update_project(
|
|
414
|
+
project_id, name=body.get("name"), description=body.get("description"))
|
|
415
|
+
if err:
|
|
416
|
+
self._error_response(err, 404)
|
|
398
417
|
else:
|
|
399
|
-
self.
|
|
418
|
+
self._json_response(project)
|
|
400
419
|
|
|
401
|
-
def
|
|
402
|
-
|
|
403
|
-
if
|
|
404
|
-
self.
|
|
420
|
+
def _handle_remove_project(self, project_id):
|
|
421
|
+
project, err = self._projects().remove_project(project_id)
|
|
422
|
+
if err:
|
|
423
|
+
self._error_response(err, 404)
|
|
405
424
|
else:
|
|
406
|
-
self.
|
|
425
|
+
self._json_response({"removed": True, "project": project})
|
|
407
426
|
|
|
408
|
-
|
|
409
|
-
meta_file = LOGS_DIR / f"job_{job_id}.meta"
|
|
410
|
-
out_file = LOGS_DIR / f"job_{job_id}.out"
|
|
427
|
+
# ── 파이프라인 (얇은 위임) ──
|
|
411
428
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
meta = parse_meta_file(meta_file)
|
|
416
|
-
if meta and meta.get("STATUS") == "running":
|
|
417
|
-
pid = meta.get("PID")
|
|
418
|
-
if pid:
|
|
419
|
-
try:
|
|
420
|
-
os.kill(int(pid), 0)
|
|
421
|
-
return self._error_response("실행 중인 작업은 삭제할 수 없습니다", 409)
|
|
422
|
-
except (ProcessLookupError, ValueError, OSError):
|
|
423
|
-
pass
|
|
424
|
-
|
|
425
|
-
try:
|
|
426
|
-
if meta_file.exists():
|
|
427
|
-
meta_file.unlink()
|
|
428
|
-
if out_file.exists():
|
|
429
|
-
out_file.unlink()
|
|
430
|
-
self._json_response({"deleted": True, "job_id": job_id})
|
|
431
|
-
except OSError as e:
|
|
432
|
-
self._error_response(f"삭제 실패: {e}", 500)
|
|
433
|
-
|
|
434
|
-
def _handle_delete_completed_jobs(self):
|
|
435
|
-
deleted = []
|
|
436
|
-
for mf in list(LOGS_DIR.glob("job_*.meta")):
|
|
437
|
-
meta = parse_meta_file(mf)
|
|
438
|
-
if not meta:
|
|
439
|
-
continue
|
|
440
|
-
status = meta.get("STATUS", "")
|
|
441
|
-
if status in ("done", "failed"):
|
|
442
|
-
pid = meta.get("PID")
|
|
443
|
-
if pid and status == "running":
|
|
444
|
-
try:
|
|
445
|
-
os.kill(int(pid), 0)
|
|
446
|
-
continue
|
|
447
|
-
except (ProcessLookupError, ValueError, OSError):
|
|
448
|
-
pass
|
|
449
|
-
job_id = meta.get("JOB_ID", "")
|
|
450
|
-
out_file = LOGS_DIR / f"job_{job_id}.out"
|
|
451
|
-
try:
|
|
452
|
-
mf.unlink()
|
|
453
|
-
if out_file.exists():
|
|
454
|
-
out_file.unlink()
|
|
455
|
-
deleted.append(job_id)
|
|
456
|
-
except OSError:
|
|
457
|
-
pass
|
|
458
|
-
self._json_response({"deleted": deleted, "count": len(deleted)})
|
|
459
|
-
|
|
460
|
-
def _handle_job_stream(self, job_id):
|
|
461
|
-
out_file = LOGS_DIR / f"job_{job_id}.out"
|
|
462
|
-
meta_file = LOGS_DIR / f"job_{job_id}.meta"
|
|
463
|
-
|
|
464
|
-
if not meta_file.exists():
|
|
465
|
-
return self._error_response("작업을 찾을 수 없습니다", 404)
|
|
429
|
+
def _handle_list_pipelines(self):
|
|
430
|
+
self._json_response(self._pipeline().list_pipelines())
|
|
466
431
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
offset = int(qs.get("offset", [0])[0])
|
|
470
|
-
|
|
471
|
-
if not out_file.exists():
|
|
472
|
-
return self._json_response({"events": [], "offset": 0, "done": False})
|
|
473
|
-
|
|
474
|
-
try:
|
|
475
|
-
with open(out_file, "r") as f:
|
|
476
|
-
f.seek(offset)
|
|
477
|
-
new_data = f.read()
|
|
478
|
-
new_offset = f.tell()
|
|
479
|
-
|
|
480
|
-
events = []
|
|
481
|
-
for line in new_data.strip().split("\n"):
|
|
482
|
-
if not line.strip():
|
|
483
|
-
continue
|
|
484
|
-
try:
|
|
485
|
-
evt = json.loads(line)
|
|
486
|
-
evt_type = evt.get("type", "")
|
|
487
|
-
if evt_type == "assistant":
|
|
488
|
-
msg = evt.get("message", {})
|
|
489
|
-
content = msg.get("content", [])
|
|
490
|
-
text_parts = [c.get("text", "") for c in content if c.get("type") == "text"]
|
|
491
|
-
if text_parts:
|
|
492
|
-
events.append({"type": "text", "text": "".join(text_parts)})
|
|
493
|
-
tool_parts = [c for c in content if c.get("type") == "tool_use"]
|
|
494
|
-
for tp in tool_parts:
|
|
495
|
-
events.append({
|
|
496
|
-
"type": "tool_use",
|
|
497
|
-
"tool": tp.get("name", ""),
|
|
498
|
-
"input": str(tp.get("input", ""))[:200]
|
|
499
|
-
})
|
|
500
|
-
elif evt_type == "result":
|
|
501
|
-
events.append({
|
|
502
|
-
"type": "result",
|
|
503
|
-
"result": evt.get("result", ""),
|
|
504
|
-
"cost_usd": evt.get("total_cost_usd"),
|
|
505
|
-
"duration_ms": evt.get("duration_ms"),
|
|
506
|
-
"is_error": evt.get("is_error", False)
|
|
507
|
-
})
|
|
508
|
-
except json.JSONDecodeError:
|
|
509
|
-
continue
|
|
510
|
-
|
|
511
|
-
meta = parse_meta_file(meta_file)
|
|
512
|
-
done = meta.get("STATUS", "") in ("done", "failed")
|
|
513
|
-
|
|
514
|
-
self._json_response({"events": events, "offset": new_offset, "done": done})
|
|
515
|
-
except OSError as e:
|
|
516
|
-
self._error_response(f"스트림 읽기 실패: {e}", 500)
|
|
517
|
-
|
|
518
|
-
def _handle_job_checkpoints(self, job_id):
|
|
519
|
-
checkpoints, err = get_job_checkpoints(job_id)
|
|
432
|
+
def _handle_pipeline_status(self, pipe_id):
|
|
433
|
+
result, err = self._pipeline().get_pipeline_status(pipe_id)
|
|
520
434
|
if err:
|
|
521
435
|
self._error_response(err, 404)
|
|
522
436
|
else:
|
|
523
|
-
self._json_response(
|
|
524
|
-
|
|
525
|
-
def _handle_job_by_session(self, session_id):
|
|
526
|
-
"""Session ID로 가장 최근 job을 찾아 반환한다."""
|
|
527
|
-
jobs = get_all_jobs()
|
|
528
|
-
matched = [j for j in jobs if j.get("session_id") == session_id]
|
|
529
|
-
if not matched:
|
|
530
|
-
return self._error_response(
|
|
531
|
-
f"Session ID '{session_id[:8]}...'에 해당하는 작업을 찾을 수 없습니다", 404)
|
|
532
|
-
self._json_response(matched[0])
|
|
533
|
-
|
|
534
|
-
def _handle_job_rewind(self, job_id):
|
|
535
|
-
body = self._read_body()
|
|
536
|
-
checkpoint_hash = body.get("checkpoint", "").strip()
|
|
537
|
-
new_prompt = body.get("prompt", "").strip()
|
|
538
|
-
|
|
539
|
-
if not checkpoint_hash:
|
|
540
|
-
return self._error_response("checkpoint 필드가 필요합니다")
|
|
541
|
-
if not new_prompt:
|
|
542
|
-
return self._error_response("prompt 필드가 필요합니다")
|
|
437
|
+
self._json_response(result)
|
|
543
438
|
|
|
544
|
-
|
|
439
|
+
def _handle_create_pipeline(self):
|
|
440
|
+
body = self._read_body()
|
|
441
|
+
path = body.get("project_path", "").strip()
|
|
442
|
+
command = body.get("command", "").strip()
|
|
443
|
+
if not path or not command:
|
|
444
|
+
return self._error_response("project_path와 command 필드가 필요합니다")
|
|
445
|
+
result, err = self._pipeline().create_pipeline(
|
|
446
|
+
path, command=command,
|
|
447
|
+
interval=body.get("interval", "").strip(),
|
|
448
|
+
name=body.get("name", "").strip())
|
|
545
449
|
if err:
|
|
546
|
-
self._error_response(err, 400
|
|
450
|
+
self._error_response(err, 400)
|
|
547
451
|
else:
|
|
548
452
|
self._json_response(result, 201)
|
|
549
453
|
|
|
550
|
-
def
|
|
551
|
-
|
|
552
|
-
|
|
454
|
+
def _handle_pipeline_run(self, pipe_id):
|
|
455
|
+
body = self._read_body()
|
|
456
|
+
if body.get("force"):
|
|
457
|
+
result, err = self._pipeline().force_run(pipe_id)
|
|
458
|
+
else:
|
|
459
|
+
result, err = self._pipeline().run_next(pipe_id)
|
|
460
|
+
if err:
|
|
461
|
+
self._error_response(err, 400)
|
|
462
|
+
else:
|
|
463
|
+
self._json_response(result)
|
|
553
464
|
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
465
|
+
def _handle_pipeline_stop(self, pipe_id):
|
|
466
|
+
result, err = self._pipeline().stop_pipeline(pipe_id)
|
|
467
|
+
if err:
|
|
468
|
+
self._error_response(err, 400)
|
|
558
469
|
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
|
|
657
|
-
|
|
658
|
-
# cwd 필터 적용
|
|
659
|
-
if filter_cwd:
|
|
660
|
-
norm = os.path.normpath(filter_cwd)
|
|
661
|
-
seen = {
|
|
662
|
-
sid: s for sid, s in seen.items()
|
|
663
|
-
if s.get("cwd") and os.path.normpath(s["cwd"]) == norm
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
sessions = sorted(seen.values(), key=lambda s: s.get("timestamp") or "", reverse=True)
|
|
667
|
-
self._json_response(sessions[:50])
|
|
668
|
-
|
|
669
|
-
def _handle_get_config(self):
|
|
670
|
-
defaults = {
|
|
671
|
-
"skip_permissions": True,
|
|
672
|
-
"allowed_tools": "Bash,Read,Write,Edit,Glob,Grep,Agent,NotebookEdit,WebFetch,WebSearch",
|
|
673
|
-
"model": "",
|
|
674
|
-
"max_jobs": 10,
|
|
675
|
-
"append_system_prompt": "",
|
|
676
|
-
"target_repo": "",
|
|
677
|
-
"base_branch": "main",
|
|
678
|
-
"checkpoint_interval": 5,
|
|
679
|
-
"locale": "ko",
|
|
680
|
-
}
|
|
681
|
-
try:
|
|
682
|
-
if SETTINGS_FILE.exists():
|
|
683
|
-
saved = json.loads(SETTINGS_FILE.read_text("utf-8"))
|
|
684
|
-
defaults.update(saved)
|
|
685
|
-
except (json.JSONDecodeError, OSError):
|
|
686
|
-
pass
|
|
687
|
-
self._json_response(defaults)
|
|
470
|
+
self._json_response(result)
|
|
688
471
|
|
|
689
|
-
def
|
|
472
|
+
def _handle_update_pipeline(self, pipe_id):
|
|
690
473
|
body = self._read_body()
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
allowed_keys = {
|
|
702
|
-
"skip_permissions", "allowed_tools", "model", "max_jobs",
|
|
703
|
-
"append_system_prompt", "target_repo", "base_branch",
|
|
704
|
-
"checkpoint_interval", "locale",
|
|
705
|
-
}
|
|
706
|
-
for k, v in body.items():
|
|
707
|
-
if k in allowed_keys:
|
|
708
|
-
current[k] = v
|
|
474
|
+
result, err = self._pipeline().update_pipeline(
|
|
475
|
+
pipe_id,
|
|
476
|
+
command=body.get("command"),
|
|
477
|
+
interval=body.get("interval"),
|
|
478
|
+
name=body.get("name"),
|
|
479
|
+
)
|
|
480
|
+
if err:
|
|
481
|
+
self._error_response(err, 400)
|
|
482
|
+
else:
|
|
483
|
+
self._json_response(result)
|
|
709
484
|
|
|
710
|
-
|
|
711
|
-
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
712
|
-
SETTINGS_FILE.write_text(
|
|
713
|
-
json.dumps(current, ensure_ascii=False, indent=2), "utf-8"
|
|
714
|
-
)
|
|
715
|
-
self._json_response({"ok": True, "config": current})
|
|
716
|
-
except OSError as e:
|
|
717
|
-
self._error_response(f"설정 저장 실패: {e}", 500)
|
|
718
|
-
|
|
719
|
-
def _handle_get_recent_dirs(self):
|
|
720
|
-
try:
|
|
721
|
-
if RECENT_DIRS_FILE.exists():
|
|
722
|
-
data = json.loads(RECENT_DIRS_FILE.read_text("utf-8"))
|
|
723
|
-
else:
|
|
724
|
-
data = []
|
|
725
|
-
self._json_response(data)
|
|
726
|
-
except (json.JSONDecodeError, OSError):
|
|
727
|
-
self._json_response([])
|
|
728
|
-
|
|
729
|
-
def _handle_save_recent_dirs(self):
|
|
485
|
+
def _handle_pipeline_reset(self, pipe_id):
|
|
730
486
|
body = self._read_body()
|
|
731
|
-
|
|
732
|
-
if
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
737
|
-
RECENT_DIRS_FILE.write_text(json.dumps(dirs, ensure_ascii=False), "utf-8")
|
|
738
|
-
self._json_response({"ok": True})
|
|
739
|
-
except OSError as e:
|
|
740
|
-
self._error_response(f"저장 실패: {e}", 500)
|
|
487
|
+
result, err = self._pipeline().reset_phase(pipe_id, phase=body.get("phase"))
|
|
488
|
+
if err:
|
|
489
|
+
self._error_response(err, 400)
|
|
490
|
+
else:
|
|
491
|
+
self._json_response(result)
|
|
741
492
|
|
|
742
|
-
def
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
entries = []
|
|
749
|
-
try:
|
|
750
|
-
items = sorted(os.listdir(dir_path))
|
|
751
|
-
except PermissionError:
|
|
752
|
-
return self._error_response("접근 권한 없음", 403)
|
|
753
|
-
|
|
754
|
-
parent = os.path.dirname(dir_path)
|
|
755
|
-
if parent != dir_path:
|
|
756
|
-
entries.append({"name": "..", "path": parent, "type": "dir"})
|
|
757
|
-
|
|
758
|
-
for item in items:
|
|
759
|
-
if item.startswith("."):
|
|
760
|
-
continue
|
|
761
|
-
full = os.path.join(dir_path, item)
|
|
762
|
-
entry = {"name": item, "path": full}
|
|
763
|
-
if os.path.isdir(full):
|
|
764
|
-
entry["type"] = "dir"
|
|
765
|
-
else:
|
|
766
|
-
entry["type"] = "file"
|
|
767
|
-
try:
|
|
768
|
-
entry["size"] = os.path.getsize(full)
|
|
769
|
-
except OSError:
|
|
770
|
-
entry["size"] = 0
|
|
771
|
-
entries.append(entry)
|
|
772
|
-
|
|
773
|
-
self._json_response({"current": dir_path, "entries": entries})
|
|
774
|
-
except Exception as e:
|
|
775
|
-
self._error_response(f"디렉토리 읽기 실패: {e}", 500)
|
|
493
|
+
def _handle_delete_pipeline(self, pipe_id):
|
|
494
|
+
result, err = self._pipeline().delete_pipeline(pipe_id)
|
|
495
|
+
if err:
|
|
496
|
+
self._error_response(err, 404)
|
|
497
|
+
else:
|
|
498
|
+
self._json_response({"deleted": True, "pipeline": result})
|
|
776
499
|
|
|
777
500
|
def _serve_static(self, url_path):
|
|
778
501
|
if url_path in ("/", ""):
|