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/LICENSE +21 -0
- package/README.md +216 -0
- package/bin/app-launcher.sh +22 -0
- package/bin/claude-sh +19 -0
- package/bin/controller +37 -0
- package/bin/native-app.py +102 -0
- package/bin/send +185 -0
- package/bin/start +75 -0
- package/config.sh +74 -0
- package/lib/checkpoint.sh +237 -0
- package/lib/executor.sh +183 -0
- package/lib/jobs.sh +333 -0
- package/lib/session.sh +78 -0
- package/lib/worktree.sh +122 -0
- package/package.json +61 -0
- package/postinstall.sh +30 -0
- package/service/controller.sh +503 -0
- package/web/auth.py +46 -0
- package/web/checkpoint.py +175 -0
- package/web/config.py +65 -0
- package/web/handler.py +780 -0
- package/web/jobs.py +228 -0
- package/web/server.py +16 -0
- package/web/static/app.js +2013 -0
- package/web/static/index.html +219 -0
- package/web/static/styles.css +1942 -0
- package/web/utils.py +109 -0
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
|