claude-controller 0.1.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.
@@ -0,0 +1,175 @@
1
+ """
2
+ Controller Service — Checkpoint / Rewind 유틸리티
3
+ """
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import signal
9
+ import subprocess
10
+ import time
11
+
12
+ from config import LOGS_DIR
13
+ from utils import parse_meta_file
14
+ from jobs import send_to_fifo
15
+
16
+
17
+ def get_job_checkpoints(job_id):
18
+ """worktree의 git log에서 해당 job의 checkpoint 커밋 목록을 반환한다."""
19
+ meta_file = LOGS_DIR / f"job_{job_id}.meta"
20
+ if not meta_file.exists():
21
+ return None, "작업을 찾을 수 없습니다"
22
+
23
+ meta = parse_meta_file(meta_file)
24
+ wt_path = meta.get("WORKTREE", "")
25
+ if not wt_path or not os.path.isdir(wt_path):
26
+ return [], None
27
+
28
+ try:
29
+ result = subprocess.run(
30
+ ["git", "log", "--format=%H|%aI|%s", f"--grep=ckpt:{job_id}:"],
31
+ cwd=wt_path, capture_output=True, text=True, timeout=10
32
+ )
33
+ checkpoints = []
34
+ for line in result.stdout.strip().split("\n"):
35
+ if not line:
36
+ continue
37
+ parts = line.split("|", 2)
38
+ if len(parts) < 3:
39
+ continue
40
+ hash_val, ts, msg = parts
41
+
42
+ turn_match = re.search(rf"ckpt:{job_id}:(\d+)", msg)
43
+ turn_num = int(turn_match.group(1)) if turn_match else 0
44
+
45
+ diff_result = subprocess.run(
46
+ ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", hash_val],
47
+ cwd=wt_path, capture_output=True, text=True, timeout=5
48
+ )
49
+ files = [f for f in diff_result.stdout.strip().split("\n") if f]
50
+
51
+ checkpoints.append({
52
+ "hash": hash_val,
53
+ "turn": turn_num,
54
+ "timestamp": ts,
55
+ "message": msg,
56
+ "files_changed": len(files),
57
+ "files": files[:10],
58
+ })
59
+
60
+ return checkpoints, None
61
+ except (subprocess.TimeoutExpired, OSError) as e:
62
+ return None, f"체크포인트 조회 실패: {e}"
63
+
64
+
65
+ def extract_conversation_context(out_file, max_chars=4000):
66
+ """stream-json 출력에서 대화 컨텍스트를 추출한다."""
67
+ if not out_file.exists():
68
+ return ""
69
+
70
+ parts = []
71
+ turn = 0
72
+
73
+ try:
74
+ with open(out_file, "r") as f:
75
+ for line in f:
76
+ try:
77
+ evt = json.loads(line.strip())
78
+ except json.JSONDecodeError:
79
+ continue
80
+
81
+ evt_type = evt.get("type", "")
82
+ if evt_type == "assistant":
83
+ turn += 1
84
+ msg = evt.get("message", {})
85
+ content = msg.get("content", [])
86
+
87
+ texts = [c.get("text", "")[:300] for c in content if c.get("type") == "text"]
88
+ tools = [c for c in content if c.get("type") == "tool_use"]
89
+
90
+ if texts:
91
+ parts.append(f"--- Turn {turn} ---")
92
+ parts.append("\n".join(texts))
93
+
94
+ for t in tools:
95
+ name = t.get("name", "?")
96
+ inp = str(t.get("input", ""))[:150]
97
+ parts.append(f"[Tool: {name}] {inp}")
98
+
99
+ elif evt_type == "result":
100
+ r = evt.get("result", "")
101
+ if r:
102
+ parts.append(f"--- Result ---")
103
+ parts.append(r[:500])
104
+ except OSError:
105
+ pass
106
+
107
+ full = "\n".join(parts)
108
+ return full[:max_chars]
109
+
110
+
111
+ def rewind_job(job_id, checkpoint_hash, new_prompt):
112
+ """job을 특정 checkpoint로 되돌리고 새 job을 디스패치한다."""
113
+ meta_file = LOGS_DIR / f"job_{job_id}.meta"
114
+ out_file = LOGS_DIR / f"job_{job_id}.out"
115
+
116
+ if not meta_file.exists():
117
+ return None, "작업을 찾을 수 없습니다"
118
+
119
+ meta = parse_meta_file(meta_file)
120
+ wt_path = meta.get("WORKTREE", "")
121
+
122
+ if not wt_path or not os.path.isdir(wt_path):
123
+ return None, "워크트리를 찾을 수 없습니다"
124
+
125
+ # 실행 중이면 종료
126
+ status = meta.get("STATUS", "")
127
+ pid_str = meta.get("PID", "")
128
+ if status == "running" and pid_str:
129
+ try:
130
+ os.kill(int(pid_str), signal.SIGTERM)
131
+ time.sleep(1)
132
+ except (ProcessLookupError, ValueError, OSError):
133
+ pass
134
+
135
+ # checkpoint로 reset
136
+ try:
137
+ subprocess.run(
138
+ ["git", "reset", "--hard", checkpoint_hash],
139
+ cwd=wt_path, capture_output=True, timeout=10, check=True
140
+ )
141
+ except subprocess.CalledProcessError as e:
142
+ return None, f"체크포인트 복원 실패: {e}"
143
+
144
+ # 대화 컨텍스트 추출
145
+ context = extract_conversation_context(out_file)
146
+
147
+ # 리와인드 프롬프트 구성
148
+ if context:
149
+ full_prompt = (
150
+ f"[이전 작업 컨텍스트 — 아래는 이전 세션에서 수행된 작업 요약입니다]\n"
151
+ f"{context}\n\n"
152
+ f"[Rewind 지시사항]\n"
153
+ f"파일 상태가 위 작업 중간의 체크포인트 시점으로 복원되었습니다.\n"
154
+ f"이전 작업 내용을 참고하되, 이어서 다음을 수행하세요:\n\n"
155
+ f"{new_prompt}"
156
+ )
157
+ else:
158
+ full_prompt = new_prompt
159
+
160
+ # FIFO로 새 job 전송 (기존 worktree 재사용)
161
+ result, err = send_to_fifo(
162
+ full_prompt,
163
+ cwd=wt_path,
164
+ reuse_worktree=wt_path,
165
+ )
166
+
167
+ if err:
168
+ return None, err
169
+
170
+ return {
171
+ "rewound_from": job_id,
172
+ "checkpoint": checkpoint_hash,
173
+ "worktree": wt_path,
174
+ "new_job": result,
175
+ }, None
package/web/config.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ Controller Service — 경로 및 설정 상수
3
+ """
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ # ══════════════════════════════════════════════════════════════
9
+ # 경로 설정 — web/ 안에 위치하므로 parent = controller/
10
+ # ══════════════════════════════════════════════════════════════
11
+
12
+ WEB_DIR = Path(__file__).resolve().parent
13
+ CONTROLLER_DIR = WEB_DIR.parent
14
+ STATIC_DIR = WEB_DIR / "static"
15
+ FIFO_PATH = CONTROLLER_DIR / "queue" / "controller.pipe"
16
+ PID_FILE = CONTROLLER_DIR / "service" / "controller.pid"
17
+ LOGS_DIR = CONTROLLER_DIR / "logs"
18
+ UPLOADS_DIR = CONTROLLER_DIR / "uploads"
19
+ DATA_DIR = CONTROLLER_DIR / "data"
20
+ RECENT_DIRS_FILE = DATA_DIR / "recent_dirs.json"
21
+ SETTINGS_FILE = DATA_DIR / "settings.json"
22
+ SERVICE_SCRIPT = CONTROLLER_DIR / "service" / "controller.sh"
23
+ SESSIONS_DIR = CONTROLLER_DIR / "sessions"
24
+ QUEUE_DIR = CONTROLLER_DIR / "queue"
25
+ CLAUDE_PROJECTS_DIR = Path.home() / ".claude" / "projects"
26
+
27
+ PORT = int(os.environ.get("PORT", 8420))
28
+
29
+ # ══════════════════════════════════════════════════════════════
30
+ # 보안 설정
31
+ # ══════════════════════════════════════════════════════════════
32
+
33
+ # 허용된 Origin 목록 (CORS)
34
+ # 환경변수 ALLOWED_ORIGINS로 오버라이드 가능 (쉼표 구분)
35
+ _DEFAULT_ORIGINS = [
36
+ "http://claude.won-space.com",
37
+ "https://claude.won-space.com",
38
+ "http://localhost:8420",
39
+ "https://localhost:8420",
40
+ ]
41
+ ALLOWED_ORIGINS: list[str] = [
42
+ o.strip() for o in
43
+ os.environ.get("ALLOWED_ORIGINS", "").split(",")
44
+ if o.strip()
45
+ ] or _DEFAULT_ORIGINS
46
+
47
+ # 허용된 Host 헤더 (DNS Rebinding 방지)
48
+ ALLOWED_HOSTS = {"localhost", "127.0.0.1", "[::1]"}
49
+
50
+ # 토큰 인증 필수 여부
51
+ # false: CORS + Host 검증만으로 보안 (기본값, 자동 연결에 적합)
52
+ # true: 모든 API 요청에 Authorization: Bearer <token> 필수
53
+ AUTH_REQUIRED = os.environ.get("AUTH_REQUIRED", "false").lower() == "true"
54
+
55
+ # 인증 면제 경로 (AUTH_REQUIRED=true일 때만 적용)
56
+ AUTH_EXEMPT_PREFIXES = ("/static/", "/uploads/", "/api/auth/")
57
+ AUTH_EXEMPT_PATHS = {"/", "/index.html", "/styles.css", "/app.js"}
58
+
59
+ # 앱 실행 시 브라우저에서 열 공개 URL
60
+ # 환경변수 PUBLIC_URL로 오버라이드 가능
61
+ PUBLIC_URL = os.environ.get("PUBLIC_URL", "https://claude.won-space.com")
62
+
63
+ # SSL 인증서 경로 (mkcert 생성 파일)
64
+ SSL_CERT = os.environ.get("SSL_CERT", str(CONTROLLER_DIR / "certs" / "localhost+1.pem"))
65
+ SSL_KEY = os.environ.get("SSL_KEY", str(CONTROLLER_DIR / "certs" / "localhost+1-key.pem"))