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.
package/web/jobs.py ADDED
@@ -0,0 +1,228 @@
1
+ """
2
+ Controller Service — Job 관리 및 서비스 제어 함수
3
+ """
4
+
5
+ import json
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import time
10
+
11
+ from config import LOGS_DIR, FIFO_PATH, SERVICE_SCRIPT, CONTROLLER_DIR
12
+ from utils import parse_meta_file, is_service_running
13
+
14
+
15
+ def get_all_jobs():
16
+ """logs/ 디렉토리의 모든 .meta 파일을 파싱하여 작업 목록을 반환한다."""
17
+ jobs = []
18
+ if not LOGS_DIR.exists():
19
+ return jobs
20
+
21
+ meta_files = sorted(LOGS_DIR.glob("job_*.meta"),
22
+ key=lambda f: int(f.stem.split("_")[1]),
23
+ reverse=True)
24
+ for mf in meta_files:
25
+ meta = parse_meta_file(mf)
26
+ if not meta:
27
+ continue
28
+
29
+ if meta.get("STATUS") == "running" and meta.get("PID"):
30
+ try:
31
+ os.kill(int(meta["PID"]), 0)
32
+ except (ProcessLookupError, ValueError, OSError):
33
+ meta["STATUS"] = "done"
34
+
35
+ # 실행 중인 작업의 session_id 조기 추출 시도
36
+ if not meta.get("SESSION_ID") and meta.get("STATUS") == "running":
37
+ out_file_early = LOGS_DIR / f"job_{meta.get('JOB_ID', '')}.out"
38
+ if out_file_early.exists():
39
+ try:
40
+ with open(out_file_early, "r") as ef:
41
+ for eline in ef:
42
+ try:
43
+ eobj = json.loads(eline.strip())
44
+ sid_early = eobj.get("session_id")
45
+ if sid_early:
46
+ meta["SESSION_ID"] = sid_early
47
+ break
48
+ except json.JSONDecodeError:
49
+ continue
50
+ except OSError:
51
+ pass
52
+
53
+ result_text = None
54
+ cost_usd = None
55
+ duration_ms = None
56
+ job_id_str = meta.get("JOB_ID", "")
57
+ if meta.get("STATUS") in ("done", "failed"):
58
+ out_file = LOGS_DIR / f"job_{job_id_str}.out"
59
+ if out_file.exists():
60
+ try:
61
+ with open(out_file, "r") as f:
62
+ for line in f:
63
+ try:
64
+ obj = json.loads(line.strip())
65
+ if obj.get("type") == "result":
66
+ result_text = obj.get("result", "")
67
+ cost_usd = obj.get("total_cost_usd")
68
+ duration_ms = obj.get("duration_ms")
69
+ if not meta.get("SESSION_ID") and obj.get("session_id"):
70
+ meta["SESSION_ID"] = obj["session_id"]
71
+ except json.JSONDecodeError:
72
+ continue
73
+ if result_text is None:
74
+ try:
75
+ data = json.loads(out_file.read_text())
76
+ result_text = data.get("result", "")
77
+ cost_usd = data.get("total_cost_usd")
78
+ duration_ms = data.get("duration_ms")
79
+ if not meta.get("SESSION_ID") and data.get("session_id"):
80
+ meta["SESSION_ID"] = data["session_id"]
81
+ except (json.JSONDecodeError, OSError):
82
+ pass
83
+ except OSError:
84
+ pass
85
+
86
+ jobs.append({
87
+ "job_id": job_id_str,
88
+ "status": meta.get("STATUS", "unknown"),
89
+ "session_id": meta.get("SESSION_ID", "") or None,
90
+ "prompt": meta.get("PROMPT", ""),
91
+ "created_at": meta.get("CREATED_AT", ""),
92
+ "uuid": meta.get("UUID", "") or None,
93
+ "cwd": meta.get("CWD", "") or None,
94
+ "result": result_text,
95
+ "cost_usd": cost_usd,
96
+ "duration_ms": duration_ms,
97
+ })
98
+ return jobs
99
+
100
+
101
+ def get_job_result(job_id):
102
+ """작업 결과(.out 파일)에서 result 필드를 추출한다."""
103
+ out_file = LOGS_DIR / f"job_{job_id}.out"
104
+ meta_file = LOGS_DIR / f"job_{job_id}.meta"
105
+
106
+ if not meta_file.exists():
107
+ return None, "작업을 찾을 수 없습니다"
108
+
109
+ meta = parse_meta_file(meta_file)
110
+ if meta.get("STATUS") == "running":
111
+ return {"status": "running", "result": None}, None
112
+
113
+ if not out_file.exists():
114
+ return None, "출력 파일이 없습니다"
115
+
116
+ try:
117
+ with open(out_file, "r") as f:
118
+ content = f.read()
119
+
120
+ result_data = None
121
+ for line in content.strip().split("\n"):
122
+ try:
123
+ obj = json.loads(line)
124
+ if obj.get("type") == "result":
125
+ result_data = obj
126
+ except json.JSONDecodeError:
127
+ continue
128
+
129
+ if result_data:
130
+ return {
131
+ "status": meta.get("STATUS", "unknown"),
132
+ "result": result_data.get("result"),
133
+ "cost_usd": result_data.get("total_cost_usd"),
134
+ "duration_ms": result_data.get("duration_ms"),
135
+ "session_id": result_data.get("session_id"),
136
+ "is_error": result_data.get("is_error", False),
137
+ }, None
138
+
139
+ try:
140
+ data = json.loads(content)
141
+ return {
142
+ "status": meta.get("STATUS", "unknown"),
143
+ "result": data.get("result"),
144
+ "cost_usd": data.get("total_cost_usd"),
145
+ "duration_ms": data.get("duration_ms"),
146
+ "session_id": data.get("session_id"),
147
+ "is_error": data.get("is_error", False),
148
+ }, None
149
+ except json.JSONDecodeError:
150
+ pass
151
+
152
+ return {"status": meta.get("STATUS", "unknown"), "result": content[:2000]}, None
153
+ except OSError as e:
154
+ return None, f"결과 파싱 실패: {e}"
155
+
156
+
157
+ def send_to_fifo(prompt, cwd=None, job_id=None, images=None, session=None, reuse_worktree=None):
158
+ """FIFO 파이프에 JSON 메시지를 전송한다."""
159
+ if not FIFO_PATH.exists():
160
+ return None, "FIFO 파이프가 존재하지 않습니다. 서비스가 실행 중인지 확인하세요."
161
+
162
+ if not job_id:
163
+ job_id = f"{int(time.time())}-web-{os.getpid()}-{id(prompt) % 10000}"
164
+
165
+ payload = {"id": job_id, "prompt": prompt}
166
+ if cwd:
167
+ payload["cwd"] = cwd
168
+ if images:
169
+ payload["images"] = images
170
+ if session:
171
+ payload["session"] = session
172
+ if reuse_worktree:
173
+ payload["reuse_worktree"] = reuse_worktree
174
+
175
+ try:
176
+ fd = os.open(str(FIFO_PATH), os.O_WRONLY | os.O_NONBLOCK)
177
+ with os.fdopen(fd, "w") as f:
178
+ f.write(json.dumps(payload, ensure_ascii=False) + "\n")
179
+ return {"job_id": job_id, "prompt": prompt, "cwd": cwd}, None
180
+ except OSError as e:
181
+ return None, f"FIFO 전송 실패: {e}"
182
+
183
+
184
+ def start_controller_service():
185
+ """컨트롤러 서비스를 백그라운드로 시작한다."""
186
+ running, pid = is_service_running()
187
+ if running:
188
+ return True, pid
189
+
190
+ if not SERVICE_SCRIPT.exists():
191
+ return False, None
192
+
193
+ log_file = LOGS_DIR / "service.log"
194
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
195
+
196
+ log_fh = open(log_file, "a")
197
+ try:
198
+ subprocess.Popen(
199
+ ["bash", str(SERVICE_SCRIPT), "start"],
200
+ stdout=log_fh,
201
+ stderr=subprocess.STDOUT,
202
+ stdin=subprocess.DEVNULL,
203
+ start_new_session=True,
204
+ cwd=str(CONTROLLER_DIR),
205
+ )
206
+ finally:
207
+ log_fh.close()
208
+
209
+ for _ in range(30):
210
+ time.sleep(0.1)
211
+ running, pid = is_service_running()
212
+ if running:
213
+ return True, pid
214
+
215
+ return False, None
216
+
217
+
218
+ def stop_controller_service():
219
+ """컨트롤러 서비스를 종료한다."""
220
+ running, pid = is_service_running()
221
+ if not running:
222
+ return False, "서비스가 실행 중이 아닙니다"
223
+
224
+ try:
225
+ os.kill(pid, signal.SIGTERM)
226
+ return True, None
227
+ except OSError as e:
228
+ return False, f"종료 실패: {e}"
package/web/server.py ADDED
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Controller Service — HTTP REST API 서버
4
+ native-app.py에서 `from server import ControllerHandler`로 임포트된다.
5
+
6
+ 모듈 구조:
7
+ config.py — 경로 및 설정 상수
8
+ utils.py — 유틸리티 함수 (meta 파싱, 서비스 상태 등)
9
+ jobs.py — Job 관리 및 서비스 제어
10
+ checkpoint.py — Checkpoint / Rewind 유틸리티
11
+ handler.py — HTTP REST API 핸들러 (ControllerHandler)
12
+ """
13
+
14
+ # backward-compatible: native-app.py가 `from server import ControllerHandler`로 사용
15
+ from handler import ControllerHandler # noqa: F401
16
+ from config import PORT # noqa: F401