claude-controller 0.1.2 → 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/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, LOGS_DIR, UPLOADS_DIR, DATA_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 utils import parse_meta_file, is_service_running, cwd_to_project_dir, scan_claude_sessions
27
- from jobs import get_all_jobs, get_job_result, send_to_fifo, start_controller_service, stop_controller_service
28
- from checkpoint import get_job_checkpoints, rewind_job
29
- from auth import verify_token, get_token
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
- # MIME 타입 맵 (업로드/정적 파일 공용)
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
- class ControllerHandler(http.server.BaseHTTPRequestHandler):
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
- # ── OPTIONS (CORS preflight) ──
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 = qs.get("cwd", [None])[0]
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
- dir_path = qs.get("path", [os.path.expanduser("~")])[0]
241
- return self._handle_dirs(dir_path)
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
- # API 핸들러
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
- def _handle_jobs(self):
333
- self._json_response(get_all_jobs())
373
+ # ── 프로젝트 (얇은 위임) ──
374
+
375
+ def _handle_list_projects(self):
376
+ self._json_response(self._projects().list_projects())
334
377
 
335
- def _handle_job_result(self, job_id):
336
- result, err = get_job_result(job_id)
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(result)
383
+ self._json_response(project)
341
384
 
342
- def _handle_upload(self):
385
+ def _handle_add_project(self):
343
386
  body = self._read_body()
344
- data_b64 = body.get("data", "")
345
- filename = body.get("filename", "file")
346
-
347
- if not data_b64:
348
- return self._error_response("data 필드가 필요합니다")
349
- if "," in data_b64:
350
- data_b64 = data_b64.split(",", 1)[1]
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
- try:
353
- raw = base64.b64decode(data_b64)
354
- except Exception:
355
- return self._error_response("잘못된 base64 데이터")
356
-
357
- ext = os.path.splitext(filename)[1].lower()
358
- if ext not in ALLOWED_UPLOAD_EXTS:
359
- ext = ext if ext else ".bin"
360
- prefix = "img" if ext in IMAGE_EXTS else "file"
361
- safe_name = f"{prefix}_{int(time.time())}_{os.getpid()}_{id(raw) % 10000}{ext}"
362
-
363
- UPLOADS_DIR.mkdir(parents=True, exist_ok=True)
364
- filepath = UPLOADS_DIR / safe_name
365
- filepath.write_bytes(raw)
366
-
367
- is_image = ext in IMAGE_EXTS
368
- self._json_response({
369
- "path": str(filepath),
370
- "filename": safe_name,
371
- "originalName": filename,
372
- "size": len(raw),
373
- "isImage": is_image,
374
- }, 201)
375
-
376
- def _handle_send(self):
397
+ def _handle_create_project(self):
377
398
  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
- )
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, 502)
407
+ self._error_response(err, 400)
391
408
  else:
392
- self._json_response(result, 201)
409
+ self._json_response(project, 201)
393
410
 
394
- def _handle_service_start(self):
395
- ok, _ = start_controller_service()
396
- if ok:
397
- self._json_response({"started": True})
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._error_response("서비스 시작 실패", 500)
418
+ self._json_response(project)
400
419
 
401
- def _handle_service_stop(self):
402
- ok, err = stop_controller_service()
403
- if ok:
404
- self._json_response({"stopped": True})
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._error_response(err or "서비스 종료 실패", 500)
425
+ self._json_response({"removed": True, "project": project})
407
426
 
408
- def _handle_delete_job(self, job_id):
409
- meta_file = LOGS_DIR / f"job_{job_id}.meta"
410
- out_file = LOGS_DIR / f"job_{job_id}.out"
427
+ # ── 파이프라인 (얇은 위임) ──
411
428
 
412
- if not meta_file.exists():
413
- return self._error_response("작업을 찾을 수 없습니다", 404)
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
- parsed = urlparse(self.path)
468
- qs = parse_qs(parsed.query)
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(checkpoints)
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
- result, err = rewind_job(job_id, checkpoint_hash, new_prompt)
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 if "찾을 수 없습니다" in err else 500)
450
+ self._error_response(err, 400)
547
451
  else:
548
452
  self._json_response(result, 201)
549
453
 
550
- def _handle_sessions(self, filter_cwd=None):
551
- """Claude Code 네이티브 세션 + history.log + job meta 파일을 합쳐 세션 목록을 반환한다."""
552
- seen = {}
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
- # 0) Claude Code 네이티브 세션 스캔
555
- if filter_cwd:
556
- proj_name = cwd_to_project_dir(filter_cwd)
557
- project_dirs = [CLAUDE_PROJECTS_DIR / proj_name]
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
- if CLAUDE_PROJECTS_DIR.exists():
560
- all_dirs = sorted(
561
- (d for d in CLAUDE_PROJECTS_DIR.iterdir() if d.is_dir()),
562
- key=lambda d: d.stat().st_mtime,
563
- reverse=True,
564
- )
565
- project_dirs = all_dirs[:15]
566
- else:
567
- project_dirs = []
568
-
569
- for pd in project_dirs:
570
- native = scan_claude_sessions(pd, limit=60)
571
- for sid, info in native.items():
572
- if sid not in seen:
573
- seen[sid] = info
574
-
575
- # 1) Job meta 파일에서 보강
576
- if LOGS_DIR.exists():
577
- meta_files = sorted(
578
- LOGS_DIR.glob("job_*.meta"),
579
- key=lambda f: int(f.stem.split("_")[1]),
580
- reverse=True,
581
- )
582
- for mf in meta_files:
583
- meta = parse_meta_file(mf)
584
- if not meta:
585
- continue
586
- sid = meta.get("SESSION_ID", "").strip()
587
- if not sid:
588
- continue
589
-
590
- status = meta.get("STATUS", "unknown")
591
- if status == "running" and meta.get("PID"):
592
- try:
593
- os.kill(int(meta["PID"]), 0)
594
- except (ProcessLookupError, ValueError, OSError):
595
- status = "done"
596
-
597
- job_id = meta.get("JOB_ID", "")
598
- cost_usd = None
599
- if status in ("done", "failed"):
600
- out_file = LOGS_DIR / f"job_{job_id}.out"
601
- if out_file.exists():
602
- try:
603
- for line in open(out_file, "r"):
604
- try:
605
- obj = json.loads(line.strip())
606
- if obj.get("type") == "result":
607
- cost_usd = obj.get("total_cost_usd")
608
- except json.JSONDecodeError:
609
- continue
610
- except OSError:
611
- pass
612
-
613
- entry = {
614
- "session_id": sid,
615
- "job_id": job_id,
616
- "prompt": meta.get("PROMPT", ""),
617
- "timestamp": meta.get("CREATED_AT", ""),
618
- "status": status,
619
- "cwd": meta.get("CWD", ""),
620
- "cost_usd": cost_usd,
621
- "slug": "",
622
- }
623
-
624
- if sid not in seen:
625
- seen[sid] = entry
626
- else:
627
- existing = seen[sid]
628
- if existing.get("job_id") is None:
629
- existing.update({"job_id": job_id, "status": status, "cost_usd": cost_usd})
630
- else:
631
- try:
632
- if int(job_id) > int(existing.get("job_id", 0)):
633
- seen[sid] = entry
634
- except (ValueError, TypeError):
635
- pass
636
-
637
- # 2) history.log 보충
638
- history_file = SESSIONS_DIR / "history.log"
639
- if history_file.exists():
640
- try:
641
- for line in history_file.read_text("utf-8").strip().split("\n"):
642
- parts = line.split("|", 2)
643
- if len(parts) >= 2:
644
- ts, sid = parts[0].strip(), parts[1].strip()
645
- if not sid:
646
- continue
647
- prompt = parts[2].strip() if len(parts) > 2 else ""
648
- if sid not in seen:
649
- seen[sid] = {
650
- "session_id": sid, "job_id": None,
651
- "prompt": prompt, "timestamp": ts,
652
- "status": "done", "cwd": None,
653
- "cost_usd": None, "slug": "",
654
- }
655
- except OSError:
656
- pass
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 _handle_save_config(self):
472
+ def _handle_update_pipeline(self, pipe_id):
690
473
  body = self._read_body()
691
- if not body or not isinstance(body, dict):
692
- return self._error_response("설정 데이터가 필요합니다")
693
-
694
- current = {}
695
- try:
696
- if SETTINGS_FILE.exists():
697
- current = json.loads(SETTINGS_FILE.read_text("utf-8"))
698
- except (json.JSONDecodeError, OSError):
699
- pass
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
- try:
711
- DATA_DIR.mkdir(parents=True, exist_ok=True)
712
- SETTINGS_FILE.write_text(
713
- json.dumps(current, ensure_ascii=False, indent=2), "utf-8"
714
- )
715
- self._json_response({"ok": True, "config": current})
716
- except OSError as e:
717
- self._error_response(f"설정 저장 실패: {e}", 500)
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
- dirs = body.get("dirs")
732
- if not isinstance(dirs, list):
733
- return self._error_response("dirs 배열이 필요합니다")
734
- dirs = [d for d in dirs if isinstance(d, str)][:8]
735
- try:
736
- DATA_DIR.mkdir(parents=True, exist_ok=True)
737
- RECENT_DIRS_FILE.write_text(json.dumps(dirs, ensure_ascii=False), "utf-8")
738
- self._json_response({"ok": True})
739
- except OSError as e:
740
- self._error_response(f"저장 실패: {e}", 500)
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 _handle_dirs(self, dir_path):
743
- try:
744
- dir_path = os.path.abspath(os.path.expanduser(dir_path))
745
- if not os.path.isdir(dir_path):
746
- return self._error_response("디렉토리가 아닙니다", 400)
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 ("/", ""):