claude-controller 0.2.0 → 0.3.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/README.md +2 -2
- package/bin/autoloop.sh +382 -0
- package/bin/ctl +327 -5
- package/bin/native-app.py +5 -2
- package/bin/watchdog.sh +357 -0
- package/cognitive/__init__.py +14 -0
- package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
- package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
- package/cognitive/dispatcher.py +192 -0
- package/cognitive/evaluator.py +289 -0
- package/cognitive/goal_engine.py +232 -0
- package/cognitive/learning.py +189 -0
- package/cognitive/orchestrator.py +303 -0
- package/cognitive/planner.py +207 -0
- package/cognitive/prompts/analyst.md +31 -0
- package/cognitive/prompts/coder.md +22 -0
- package/cognitive/prompts/reviewer.md +33 -0
- package/cognitive/prompts/tester.md +21 -0
- package/cognitive/prompts/writer.md +25 -0
- package/config.sh +6 -1
- package/dag/__init__.py +5 -0
- package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
- package/dag/__pycache__/graph.cpython-314.pyc +0 -0
- package/dag/graph.py +222 -0
- package/lib/jobs.sh +12 -1
- package/package.json +5 -1
- package/postinstall.sh +1 -1
- package/service/controller.sh +43 -11
- package/web/audit.py +122 -0
- package/web/checkpoint.py +80 -0
- package/web/config.py +2 -5
- package/web/handler.py +464 -26
- package/web/handler_fs.py +15 -14
- package/web/handler_goals.py +203 -0
- package/web/handler_jobs.py +165 -42
- package/web/handler_memory.py +203 -0
- package/web/jobs.py +576 -12
- package/web/personas.py +419 -0
- package/web/pipeline.py +682 -50
- package/web/presets.py +506 -0
- package/web/projects.py +58 -4
- package/web/static/api.js +90 -3
- package/web/static/app.js +8 -0
- package/web/static/base.css +51 -12
- package/web/static/context.js +14 -4
- package/web/static/form.css +3 -2
- package/web/static/goals.css +363 -0
- package/web/static/goals.js +300 -0
- package/web/static/i18n.js +288 -0
- package/web/static/index.html +142 -6
- package/web/static/jobs.css +951 -4
- package/web/static/jobs.js +890 -54
- package/web/static/memoryview.js +117 -0
- package/web/static/personas.js +228 -0
- package/web/static/pipeline.css +308 -1
- package/web/static/pipelines.js +249 -14
- package/web/static/presets.js +244 -0
- package/web/static/send.js +26 -4
- package/web/static/settings-style.css +34 -3
- package/web/static/settings.js +37 -1
- package/web/static/stream.js +242 -19
- package/web/static/utils.js +54 -2
- package/web/webhook.py +210 -0
package/web/handler_fs.py
CHANGED
|
@@ -18,7 +18,7 @@ class FsHandlerMixin:
|
|
|
18
18
|
|
|
19
19
|
def _handle_get_config(self):
|
|
20
20
|
defaults = {
|
|
21
|
-
"skip_permissions":
|
|
21
|
+
"skip_permissions": False,
|
|
22
22
|
"allowed_tools": "Bash,Read,Write,Edit,Glob,Grep,Agent,NotebookEdit,WebFetch,WebSearch",
|
|
23
23
|
"model": "",
|
|
24
24
|
"max_jobs": 10,
|
|
@@ -39,7 +39,7 @@ class FsHandlerMixin:
|
|
|
39
39
|
def _handle_save_config(self):
|
|
40
40
|
body = self._read_body()
|
|
41
41
|
if not body or not isinstance(body, dict):
|
|
42
|
-
return self._error_response("설정 데이터가 필요합니다")
|
|
42
|
+
return self._error_response("설정 데이터가 필요합니다", code="MISSING_FIELD")
|
|
43
43
|
|
|
44
44
|
current = {}
|
|
45
45
|
try:
|
|
@@ -52,6 +52,7 @@ class FsHandlerMixin:
|
|
|
52
52
|
"skip_permissions", "allowed_tools", "model", "max_jobs",
|
|
53
53
|
"append_system_prompt", "target_repo", "base_branch",
|
|
54
54
|
"checkpoint_interval", "locale",
|
|
55
|
+
"webhook_url", "webhook_secret", "webhook_events",
|
|
55
56
|
}
|
|
56
57
|
for k, v in body.items():
|
|
57
58
|
if k in allowed_keys:
|
|
@@ -64,7 +65,7 @@ class FsHandlerMixin:
|
|
|
64
65
|
)
|
|
65
66
|
self._json_response({"ok": True, "config": current})
|
|
66
67
|
except OSError as e:
|
|
67
|
-
self._error_response(f"설정 저장 실패: {e}", 500)
|
|
68
|
+
self._error_response(f"설정 저장 실패: {e}", 500, code="CONFIG_SAVE_FAILED")
|
|
68
69
|
|
|
69
70
|
def _handle_get_recent_dirs(self):
|
|
70
71
|
try:
|
|
@@ -80,26 +81,26 @@ class FsHandlerMixin:
|
|
|
80
81
|
body = self._read_body()
|
|
81
82
|
dirs = body.get("dirs")
|
|
82
83
|
if not isinstance(dirs, list):
|
|
83
|
-
return self._error_response("dirs 배열이 필요합니다")
|
|
84
|
+
return self._error_response("dirs 배열이 필요합니다", code="MISSING_FIELD")
|
|
84
85
|
dirs = [d for d in dirs if isinstance(d, str)][:8]
|
|
85
86
|
try:
|
|
86
87
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
87
88
|
RECENT_DIRS_FILE.write_text(json.dumps(dirs, ensure_ascii=False), "utf-8")
|
|
88
89
|
self._json_response({"ok": True})
|
|
89
90
|
except OSError as e:
|
|
90
|
-
self._error_response(f"저장 실패: {e}", 500)
|
|
91
|
+
self._error_response(f"저장 실패: {e}", 500, code="SAVE_FAILED")
|
|
91
92
|
|
|
92
93
|
def _handle_dirs(self, dir_path):
|
|
93
94
|
try:
|
|
94
95
|
dir_path = os.path.abspath(os.path.expanduser(dir_path))
|
|
95
96
|
if not os.path.isdir(dir_path):
|
|
96
|
-
return self._error_response("디렉토리가 아닙니다", 400)
|
|
97
|
+
return self._error_response("디렉토리가 아닙니다", 400, code="NOT_A_DIRECTORY")
|
|
97
98
|
|
|
98
99
|
entries = []
|
|
99
100
|
try:
|
|
100
101
|
items = sorted(os.listdir(dir_path))
|
|
101
102
|
except PermissionError:
|
|
102
|
-
return self._error_response("접근 권한 없음", 403)
|
|
103
|
+
return self._error_response("접근 권한 없음", 403, code="PERMISSION_DENIED")
|
|
103
104
|
|
|
104
105
|
parent = os.path.dirname(dir_path)
|
|
105
106
|
if parent != dir_path:
|
|
@@ -122,7 +123,7 @@ class FsHandlerMixin:
|
|
|
122
123
|
|
|
123
124
|
self._json_response({"current": dir_path, "entries": entries})
|
|
124
125
|
except Exception as e:
|
|
125
|
-
self._error_response(f"디렉토리 읽기 실패: {e}", 500)
|
|
126
|
+
self._error_response(f"디렉토리 읽기 실패: {e}", 500, code="DIR_READ_ERROR")
|
|
126
127
|
|
|
127
128
|
def _handle_mkdir(self):
|
|
128
129
|
body = self._read_body()
|
|
@@ -130,23 +131,23 @@ class FsHandlerMixin:
|
|
|
130
131
|
name = body.get("name", "").strip()
|
|
131
132
|
|
|
132
133
|
if not parent or not name:
|
|
133
|
-
return self._error_response("parent와 name 필드가 필요합니다")
|
|
134
|
+
return self._error_response("parent와 name 필드가 필요합니다", code="MISSING_FIELD")
|
|
134
135
|
|
|
135
136
|
if "/" in name or "\\" in name or name in (".", ".."):
|
|
136
|
-
return self._error_response("잘못된 디렉토리 이름입니다")
|
|
137
|
+
return self._error_response("잘못된 디렉토리 이름입니다", code="INVALID_NAME")
|
|
137
138
|
|
|
138
139
|
try:
|
|
139
140
|
parent = os.path.abspath(os.path.expanduser(parent))
|
|
140
141
|
if not os.path.isdir(parent):
|
|
141
|
-
return self._error_response("상위 디렉토리가 존재하지 않습니다", 400)
|
|
142
|
+
return self._error_response("상위 디렉토리가 존재하지 않습니다", 400, code="DIR_NOT_FOUND")
|
|
142
143
|
|
|
143
144
|
new_dir = os.path.join(parent, name)
|
|
144
145
|
if os.path.exists(new_dir):
|
|
145
|
-
return self._error_response("이미 존재하는 이름입니다", 409)
|
|
146
|
+
return self._error_response("이미 존재하는 이름입니다", 409, code="ALREADY_EXISTS")
|
|
146
147
|
|
|
147
148
|
os.makedirs(new_dir)
|
|
148
149
|
self._json_response({"ok": True, "path": new_dir}, 201)
|
|
149
150
|
except PermissionError:
|
|
150
|
-
self._error_response("접근 권한 없음", 403)
|
|
151
|
+
self._error_response("접근 권한 없음", 403, code="PERMISSION_DENIED")
|
|
151
152
|
except OSError as e:
|
|
152
|
-
self._error_response(f"디렉토리 생성 실패: {e}", 500)
|
|
153
|
+
self._error_response(f"디렉토리 생성 실패: {e}", 500, code="DIR_CREATE_ERROR")
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Goal 관련 HTTP 핸들러 Mixin
|
|
3
|
+
|
|
4
|
+
포함 엔드포인트:
|
|
5
|
+
- GET /api/goals # 목표 목록 (status 필터 가능)
|
|
6
|
+
- GET /api/goals/:id # 목표 상세 (DAG 포함)
|
|
7
|
+
- POST /api/goals # 목표 생성
|
|
8
|
+
- POST /api/goals/:id/update # 목표 수정 (mode, budget 변경)
|
|
9
|
+
- POST /api/goals/:id/approve # Gate 모드: 다음 단계 승인
|
|
10
|
+
- DELETE /api/goals/:id # 목표 취소
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from urllib.parse import parse_qs
|
|
16
|
+
|
|
17
|
+
from config import CONTROLLER_DIR, DATA_DIR
|
|
18
|
+
|
|
19
|
+
# cognitive 패키지를 import 경로에 추가
|
|
20
|
+
if str(CONTROLLER_DIR) not in sys.path:
|
|
21
|
+
sys.path.insert(0, str(CONTROLLER_DIR))
|
|
22
|
+
|
|
23
|
+
from cognitive.goal_engine import GoalEngine, GoalStatus, ExecutionMode
|
|
24
|
+
|
|
25
|
+
# 모듈 수준 싱글턴
|
|
26
|
+
_goal_engine = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_engine():
|
|
30
|
+
global _goal_engine
|
|
31
|
+
if _goal_engine is None:
|
|
32
|
+
_goal_engine = GoalEngine(str(DATA_DIR))
|
|
33
|
+
return _goal_engine
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class GoalHandlerMixin:
|
|
37
|
+
|
|
38
|
+
def _handle_list_goals(self, parsed):
|
|
39
|
+
"""GET /api/goals — 목표 목록"""
|
|
40
|
+
qs = parse_qs(parsed.query)
|
|
41
|
+
status = qs.get("status", [None])[0]
|
|
42
|
+
goals = _get_engine().list_goals(status=status)
|
|
43
|
+
self._json_response(goals)
|
|
44
|
+
|
|
45
|
+
def _handle_get_goal(self, goal_id):
|
|
46
|
+
"""GET /api/goals/:id — 목표 상세 (DAG, 진행률 포함)"""
|
|
47
|
+
goal = _get_engine().get_goal(goal_id)
|
|
48
|
+
if goal is None:
|
|
49
|
+
return self._error_response(
|
|
50
|
+
"목표를 찾을 수 없습니다", 404, code="GOAL_NOT_FOUND")
|
|
51
|
+
# 실행 가능한 다음 태스크 정보도 포함
|
|
52
|
+
next_tasks = _get_engine().get_next_tasks(goal_id)
|
|
53
|
+
goal["next_tasks"] = next_tasks
|
|
54
|
+
self._json_response(goal)
|
|
55
|
+
|
|
56
|
+
def _handle_create_goal(self):
|
|
57
|
+
"""POST /api/goals — 목표 생성"""
|
|
58
|
+
body = self._read_body()
|
|
59
|
+
objective = body.get("objective", "").strip()
|
|
60
|
+
if not objective:
|
|
61
|
+
return self._error_response(
|
|
62
|
+
"objective 필드가 필요합니다", 400, code="MISSING_FIELD")
|
|
63
|
+
|
|
64
|
+
mode_str = body.get("mode", "gate")
|
|
65
|
+
try:
|
|
66
|
+
mode = ExecutionMode(mode_str)
|
|
67
|
+
except ValueError:
|
|
68
|
+
valid = [m.value for m in ExecutionMode]
|
|
69
|
+
return self._error_response(
|
|
70
|
+
f"유효하지 않은 mode: {mode_str}. 가능한 값: {valid}",
|
|
71
|
+
400, code="INVALID_MODE")
|
|
72
|
+
|
|
73
|
+
context = body.get("context", {})
|
|
74
|
+
if not isinstance(context, dict):
|
|
75
|
+
return self._error_response(
|
|
76
|
+
"context는 JSON 객체여야 합니다", 400, code="INVALID_PARAM")
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
budget_usd = float(body.get("budget_usd", 5.0))
|
|
80
|
+
max_tasks = int(body.get("max_tasks", 20))
|
|
81
|
+
except (ValueError, TypeError):
|
|
82
|
+
return self._error_response(
|
|
83
|
+
"budget_usd 또는 max_tasks 값이 유효하지 않습니다",
|
|
84
|
+
400, code="INVALID_PARAM")
|
|
85
|
+
|
|
86
|
+
if budget_usd <= 0:
|
|
87
|
+
return self._error_response(
|
|
88
|
+
"budget_usd는 0보다 커야 합니다", 400, code="INVALID_PARAM")
|
|
89
|
+
if max_tasks < 1:
|
|
90
|
+
return self._error_response(
|
|
91
|
+
"max_tasks는 1 이상이어야 합니다", 400, code="INVALID_PARAM")
|
|
92
|
+
|
|
93
|
+
goal = _get_engine().create_goal(
|
|
94
|
+
objective=objective,
|
|
95
|
+
mode=mode,
|
|
96
|
+
context=context,
|
|
97
|
+
budget_usd=budget_usd,
|
|
98
|
+
max_tasks=max_tasks,
|
|
99
|
+
)
|
|
100
|
+
self._json_response(goal, 201)
|
|
101
|
+
|
|
102
|
+
def _handle_update_goal(self, goal_id):
|
|
103
|
+
"""POST /api/goals/:id/update — 목표 수정 (mode, budget 변경)"""
|
|
104
|
+
engine = _get_engine()
|
|
105
|
+
goal = engine.get_goal(goal_id)
|
|
106
|
+
if goal is None:
|
|
107
|
+
return self._error_response(
|
|
108
|
+
"목표를 찾을 수 없습니다", 404, code="GOAL_NOT_FOUND")
|
|
109
|
+
|
|
110
|
+
if goal["status"] in (GoalStatus.COMPLETED.value,
|
|
111
|
+
GoalStatus.FAILED.value,
|
|
112
|
+
GoalStatus.CANCELLED.value):
|
|
113
|
+
return self._error_response(
|
|
114
|
+
f"종료된 목표({goal['status']})는 수정할 수 없습니다",
|
|
115
|
+
409, code="GOAL_ALREADY_FINISHED")
|
|
116
|
+
|
|
117
|
+
body = self._read_body()
|
|
118
|
+
changed = False
|
|
119
|
+
|
|
120
|
+
if "mode" in body:
|
|
121
|
+
try:
|
|
122
|
+
mode = ExecutionMode(body["mode"])
|
|
123
|
+
goal["mode"] = mode.value
|
|
124
|
+
changed = True
|
|
125
|
+
except ValueError:
|
|
126
|
+
valid = [m.value for m in ExecutionMode]
|
|
127
|
+
return self._error_response(
|
|
128
|
+
f"유효하지 않은 mode: {body['mode']}. 가능한 값: {valid}",
|
|
129
|
+
400, code="INVALID_MODE")
|
|
130
|
+
|
|
131
|
+
if "budget_usd" in body:
|
|
132
|
+
try:
|
|
133
|
+
budget = float(body["budget_usd"])
|
|
134
|
+
if budget <= 0:
|
|
135
|
+
raise ValueError
|
|
136
|
+
goal["budget_usd"] = budget
|
|
137
|
+
changed = True
|
|
138
|
+
except (ValueError, TypeError):
|
|
139
|
+
return self._error_response(
|
|
140
|
+
"budget_usd는 0보다 큰 숫자여야 합니다",
|
|
141
|
+
400, code="INVALID_PARAM")
|
|
142
|
+
|
|
143
|
+
if "max_tasks" in body:
|
|
144
|
+
try:
|
|
145
|
+
mt = int(body["max_tasks"])
|
|
146
|
+
if mt < 1:
|
|
147
|
+
raise ValueError
|
|
148
|
+
goal["max_tasks"] = mt
|
|
149
|
+
changed = True
|
|
150
|
+
except (ValueError, TypeError):
|
|
151
|
+
return self._error_response(
|
|
152
|
+
"max_tasks는 1 이상의 정수여야 합니다",
|
|
153
|
+
400, code="INVALID_PARAM")
|
|
154
|
+
|
|
155
|
+
if not changed:
|
|
156
|
+
return self._error_response(
|
|
157
|
+
"변경할 필드가 없습니다. mode, budget_usd, max_tasks 중 하나를 지정하세요.",
|
|
158
|
+
400, code="NO_CHANGES")
|
|
159
|
+
|
|
160
|
+
goal["updated_at"] = time.time()
|
|
161
|
+
engine._save_goal(goal)
|
|
162
|
+
self._json_response(goal)
|
|
163
|
+
|
|
164
|
+
def _handle_approve_goal(self, goal_id):
|
|
165
|
+
"""POST /api/goals/:id/approve — Gate 모드: 다음 단계 승인"""
|
|
166
|
+
engine = _get_engine()
|
|
167
|
+
goal = engine.get_goal(goal_id)
|
|
168
|
+
if goal is None:
|
|
169
|
+
return self._error_response(
|
|
170
|
+
"목표를 찾을 수 없습니다", 404, code="GOAL_NOT_FOUND")
|
|
171
|
+
|
|
172
|
+
if goal["status"] != GoalStatus.GATE_WAITING.value:
|
|
173
|
+
return self._error_response(
|
|
174
|
+
f"현재 상태({goal['status']})에서는 승인할 수 없습니다. "
|
|
175
|
+
"gate_waiting 상태일 때만 가능합니다.",
|
|
176
|
+
409, code="INVALID_STATE_TRANSITION")
|
|
177
|
+
|
|
178
|
+
engine.update_status(goal_id, GoalStatus.RUNNING)
|
|
179
|
+
next_tasks = engine.get_next_tasks(goal_id)
|
|
180
|
+
goal = engine.get_goal(goal_id)
|
|
181
|
+
|
|
182
|
+
self._json_response({
|
|
183
|
+
"goal": goal,
|
|
184
|
+
"next_tasks": next_tasks,
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
def _handle_cancel_goal(self, goal_id):
|
|
188
|
+
"""DELETE /api/goals/:id — 목표 취소"""
|
|
189
|
+
engine = _get_engine()
|
|
190
|
+
goal = engine.get_goal(goal_id)
|
|
191
|
+
if goal is None:
|
|
192
|
+
return self._error_response(
|
|
193
|
+
"목표를 찾을 수 없습니다", 404, code="GOAL_NOT_FOUND")
|
|
194
|
+
|
|
195
|
+
if goal["status"] in (GoalStatus.COMPLETED.value,
|
|
196
|
+
GoalStatus.FAILED.value,
|
|
197
|
+
GoalStatus.CANCELLED.value):
|
|
198
|
+
return self._error_response(
|
|
199
|
+
f"이미 종료된 목표({goal['status']})는 취소할 수 없습니다",
|
|
200
|
+
409, code="GOAL_ALREADY_FINISHED")
|
|
201
|
+
|
|
202
|
+
goal = engine.cancel_goal(goal_id)
|
|
203
|
+
self._json_response(goal)
|
package/web/handler_jobs.py
CHANGED
|
@@ -33,13 +33,26 @@ ALLOWED_UPLOAD_EXTS = IMAGE_EXTS | {
|
|
|
33
33
|
|
|
34
34
|
class JobHandlerMixin:
|
|
35
35
|
|
|
36
|
-
def _handle_jobs(self):
|
|
37
|
-
self.
|
|
36
|
+
def _handle_jobs(self, cwd_filter=None, page=1, limit=10):
|
|
37
|
+
all_jobs = self._jobs_mod().get_all_jobs(cwd_filter=cwd_filter)
|
|
38
|
+
total = len(all_jobs)
|
|
39
|
+
page = max(1, page)
|
|
40
|
+
limit = max(1, min(limit, 100))
|
|
41
|
+
pages = max(1, (total + limit - 1) // limit)
|
|
42
|
+
page = min(page, pages)
|
|
43
|
+
start = (page - 1) * limit
|
|
44
|
+
self._json_response({
|
|
45
|
+
"jobs": all_jobs[start:start + limit],
|
|
46
|
+
"total": total,
|
|
47
|
+
"page": page,
|
|
48
|
+
"limit": limit,
|
|
49
|
+
"pages": pages,
|
|
50
|
+
})
|
|
38
51
|
|
|
39
52
|
def _handle_job_result(self, job_id):
|
|
40
53
|
result, err = self._jobs_mod().get_job_result(job_id)
|
|
41
54
|
if err:
|
|
42
|
-
self._error_response(err, 404)
|
|
55
|
+
self._error_response(err, 404, code="JOB_NOT_FOUND")
|
|
43
56
|
else:
|
|
44
57
|
self._json_response(result)
|
|
45
58
|
|
|
@@ -49,18 +62,20 @@ class JobHandlerMixin:
|
|
|
49
62
|
filename = body.get("filename", "file")
|
|
50
63
|
|
|
51
64
|
if not data_b64:
|
|
52
|
-
return self._error_response("data 필드가 필요합니다")
|
|
65
|
+
return self._error_response("data 필드가 필요합니다", code="MISSING_FIELD")
|
|
53
66
|
if "," in data_b64:
|
|
54
67
|
data_b64 = data_b64.split(",", 1)[1]
|
|
55
68
|
|
|
56
69
|
try:
|
|
57
70
|
raw = base64.b64decode(data_b64)
|
|
58
71
|
except Exception:
|
|
59
|
-
return self._error_response("잘못된 base64 데이터")
|
|
72
|
+
return self._error_response("잘못된 base64 데이터", code="INVALID_DATA")
|
|
60
73
|
|
|
61
74
|
ext = os.path.splitext(filename)[1].lower()
|
|
62
75
|
if ext not in ALLOWED_UPLOAD_EXTS:
|
|
63
|
-
|
|
76
|
+
return self._error_response(
|
|
77
|
+
f"허용되지 않는 파일 형식입니다: {ext or '(확장자 없음)'}",
|
|
78
|
+
400, code="INVALID_FILE_TYPE")
|
|
64
79
|
prefix = "img" if ext in IMAGE_EXTS else "file"
|
|
65
80
|
safe_name = f"{prefix}_{int(time.time())}_{os.getpid()}_{id(raw) % 10000}{ext}"
|
|
66
81
|
|
|
@@ -81,7 +96,18 @@ class JobHandlerMixin:
|
|
|
81
96
|
body = self._read_body()
|
|
82
97
|
prompt = body.get("prompt", "").strip()
|
|
83
98
|
if not prompt:
|
|
84
|
-
return self._error_response("prompt 필드가 필요합니다")
|
|
99
|
+
return self._error_response("prompt 필드가 필요합니다", code="MISSING_FIELD")
|
|
100
|
+
|
|
101
|
+
# 페르소나 적용: system_prompt를 프롬프트 앞에 주입
|
|
102
|
+
persona_id = body.get("persona")
|
|
103
|
+
if persona_id:
|
|
104
|
+
import personas as _p
|
|
105
|
+
prompt = _p.apply_persona_to_prompt(persona_id, prompt)
|
|
106
|
+
|
|
107
|
+
# depends_on: 선행 작업 ID 목록 (예: [42, 43] 또는 "42,43")
|
|
108
|
+
depends_on = body.get("depends_on")
|
|
109
|
+
if isinstance(depends_on, str):
|
|
110
|
+
depends_on = [d.strip() for d in depends_on.split(",") if d.strip()]
|
|
85
111
|
|
|
86
112
|
result, err = self._jobs_mod().send_to_fifo(
|
|
87
113
|
prompt,
|
|
@@ -89,9 +115,10 @@ class JobHandlerMixin:
|
|
|
89
115
|
job_id=body.get("id") or None,
|
|
90
116
|
images=body.get("images") or None,
|
|
91
117
|
session=body.get("session") or None,
|
|
118
|
+
depends_on=depends_on or None,
|
|
92
119
|
)
|
|
93
120
|
if err:
|
|
94
|
-
self._error_response(err, 502)
|
|
121
|
+
self._error_response(err, 502, code="SEND_FAILED")
|
|
95
122
|
else:
|
|
96
123
|
self._json_response(result, 201)
|
|
97
124
|
|
|
@@ -100,21 +127,21 @@ class JobHandlerMixin:
|
|
|
100
127
|
if ok:
|
|
101
128
|
self._json_response({"started": True})
|
|
102
129
|
else:
|
|
103
|
-
self._error_response("서비스 시작 실패", 500)
|
|
130
|
+
self._error_response("서비스 시작 실패", 500, code="SERVICE_START_FAILED")
|
|
104
131
|
|
|
105
132
|
def _handle_service_stop(self):
|
|
106
133
|
ok, err = self._jobs_mod().stop_controller_service()
|
|
107
134
|
if ok:
|
|
108
135
|
self._json_response({"stopped": True})
|
|
109
136
|
else:
|
|
110
|
-
self._error_response(err or "서비스 종료 실패", 500)
|
|
137
|
+
self._error_response(err or "서비스 종료 실패", 500, code="SERVICE_STOP_FAILED")
|
|
111
138
|
|
|
112
139
|
def _handle_delete_job(self, job_id):
|
|
113
140
|
meta_file = LOGS_DIR / f"job_{job_id}.meta"
|
|
114
141
|
out_file = LOGS_DIR / f"job_{job_id}.out"
|
|
115
142
|
|
|
116
143
|
if not meta_file.exists():
|
|
117
|
-
return self._error_response("작업을 찾을 수 없습니다", 404)
|
|
144
|
+
return self._error_response("작업을 찾을 수 없습니다", 404, code="JOB_NOT_FOUND")
|
|
118
145
|
|
|
119
146
|
meta = parse_meta_file(meta_file)
|
|
120
147
|
if meta and meta.get("STATUS") == "running":
|
|
@@ -122,7 +149,7 @@ class JobHandlerMixin:
|
|
|
122
149
|
if pid:
|
|
123
150
|
try:
|
|
124
151
|
os.kill(int(pid), 0)
|
|
125
|
-
return self._error_response("실행 중인 작업은 삭제할 수 없습니다", 409)
|
|
152
|
+
return self._error_response("실행 중인 작업은 삭제할 수 없습니다", 409, code="JOB_RUNNING")
|
|
126
153
|
except (ProcessLookupError, ValueError, OSError):
|
|
127
154
|
pass
|
|
128
155
|
|
|
@@ -133,7 +160,7 @@ class JobHandlerMixin:
|
|
|
133
160
|
out_file.unlink()
|
|
134
161
|
self._json_response({"deleted": True, "job_id": job_id})
|
|
135
162
|
except OSError as e:
|
|
136
|
-
self._error_response(f"삭제 실패: {e}", 500)
|
|
163
|
+
self._error_response(f"삭제 실패: {e}", 500, code="DELETE_FAILED")
|
|
137
164
|
|
|
138
165
|
def _handle_delete_completed_jobs(self):
|
|
139
166
|
deleted = []
|
|
@@ -143,13 +170,6 @@ class JobHandlerMixin:
|
|
|
143
170
|
continue
|
|
144
171
|
status = meta.get("STATUS", "")
|
|
145
172
|
if status in ("done", "failed"):
|
|
146
|
-
pid = meta.get("PID")
|
|
147
|
-
if pid and status == "running":
|
|
148
|
-
try:
|
|
149
|
-
os.kill(int(pid), 0)
|
|
150
|
-
continue
|
|
151
|
-
except (ProcessLookupError, ValueError, OSError):
|
|
152
|
-
pass
|
|
153
173
|
job_id = meta.get("JOB_ID", "")
|
|
154
174
|
out_file = LOGS_DIR / f"job_{job_id}.out"
|
|
155
175
|
try:
|
|
@@ -161,22 +181,14 @@ class JobHandlerMixin:
|
|
|
161
181
|
pass
|
|
162
182
|
self._json_response({"deleted": deleted, "count": len(deleted)})
|
|
163
183
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
return self._error_response("작업을 찾을 수 없습니다", 404)
|
|
170
|
-
|
|
171
|
-
parsed = urlparse(self.path)
|
|
172
|
-
qs = parse_qs(parsed.query)
|
|
173
|
-
offset = int(qs.get("offset", [0])[0])
|
|
174
|
-
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _parse_stream_events(out_file, offset):
|
|
186
|
+
"""out 파일에서 offset 이후의 스트림 이벤트를 파싱한다. (events, new_offset) 반환."""
|
|
187
|
+
events = []
|
|
188
|
+
new_offset = offset
|
|
175
189
|
if not out_file.exists():
|
|
176
|
-
return
|
|
177
|
-
|
|
190
|
+
return events, new_offset
|
|
178
191
|
try:
|
|
179
|
-
events = []
|
|
180
192
|
with open(out_file, "r") as f:
|
|
181
193
|
f.seek(offset)
|
|
182
194
|
for raw_line in f:
|
|
@@ -199,28 +211,120 @@ class JobHandlerMixin:
|
|
|
199
211
|
"input": str(tp.get("input", ""))[:200]
|
|
200
212
|
})
|
|
201
213
|
elif evt_type == "result":
|
|
202
|
-
|
|
214
|
+
result_evt = {
|
|
203
215
|
"type": "result",
|
|
204
216
|
"result": evt.get("result", ""),
|
|
205
217
|
"cost_usd": evt.get("total_cost_usd"),
|
|
206
218
|
"duration_ms": evt.get("duration_ms"),
|
|
207
219
|
"is_error": evt.get("is_error", False),
|
|
208
220
|
"session_id": evt.get("session_id", "")
|
|
209
|
-
}
|
|
221
|
+
}
|
|
222
|
+
if result_evt["is_error"]:
|
|
223
|
+
from jobs import classify_error
|
|
224
|
+
result_evt["user_error"] = classify_error(evt.get("result", ""))
|
|
225
|
+
events.append(result_evt)
|
|
210
226
|
except json.JSONDecodeError:
|
|
211
227
|
continue
|
|
212
228
|
new_offset = f.tell()
|
|
229
|
+
except OSError:
|
|
230
|
+
pass
|
|
231
|
+
return events, new_offset
|
|
232
|
+
|
|
233
|
+
def _handle_job_stream(self, job_id):
|
|
234
|
+
# SSE content negotiation — Accept 헤더로 분기
|
|
235
|
+
accept = self.headers.get("Accept", "")
|
|
236
|
+
if "text/event-stream" in accept:
|
|
237
|
+
return self._handle_job_stream_sse(job_id)
|
|
238
|
+
|
|
239
|
+
out_file = LOGS_DIR / f"job_{job_id}.out"
|
|
240
|
+
meta_file = LOGS_DIR / f"job_{job_id}.meta"
|
|
241
|
+
|
|
242
|
+
if not meta_file.exists():
|
|
243
|
+
return self._error_response("작업을 찾을 수 없습니다", 404, code="JOB_NOT_FOUND")
|
|
244
|
+
|
|
245
|
+
parsed = urlparse(self.path)
|
|
246
|
+
qs = parse_qs(parsed.query)
|
|
247
|
+
offset = self._safe_int(qs.get("offset", [0])[0], 0)
|
|
213
248
|
|
|
249
|
+
if not out_file.exists():
|
|
250
|
+
return self._json_response({"events": [], "offset": 0, "done": False})
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
events, new_offset = self._parse_stream_events(out_file, offset)
|
|
214
254
|
meta = parse_meta_file(meta_file)
|
|
215
255
|
done = meta.get("STATUS", "") in ("done", "failed")
|
|
216
256
|
self._json_response({"events": events, "offset": new_offset, "done": done})
|
|
217
257
|
except OSError as e:
|
|
218
|
-
self._error_response(f"스트림 읽기 실패: {e}", 500)
|
|
258
|
+
self._error_response(f"스트림 읽기 실패: {e}", 500, code="STREAM_READ_ERROR")
|
|
259
|
+
|
|
260
|
+
def _handle_job_stream_sse(self, job_id):
|
|
261
|
+
"""SSE 실시간 스트림 — 이벤트를 push 방식으로 전달한다."""
|
|
262
|
+
import time as _time
|
|
263
|
+
|
|
264
|
+
out_file = LOGS_DIR / f"job_{job_id}.out"
|
|
265
|
+
meta_file = LOGS_DIR / f"job_{job_id}.meta"
|
|
266
|
+
|
|
267
|
+
if not meta_file.exists():
|
|
268
|
+
return self._error_response("작업을 찾을 수 없습니다", 404, code="JOB_NOT_FOUND")
|
|
269
|
+
|
|
270
|
+
self.send_response(200)
|
|
271
|
+
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
272
|
+
self.send_header("Cache-Control", "no-cache")
|
|
273
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
274
|
+
self._set_cors_headers()
|
|
275
|
+
self.end_headers()
|
|
276
|
+
|
|
277
|
+
offset = 0
|
|
278
|
+
last_activity = _time.time()
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
while True:
|
|
282
|
+
events, new_offset = self._parse_stream_events(out_file, offset)
|
|
283
|
+
offset = new_offset
|
|
284
|
+
|
|
285
|
+
for evt in events:
|
|
286
|
+
data = json.dumps(evt, ensure_ascii=False)
|
|
287
|
+
self.wfile.write(f"data: {data}\n\n".encode("utf-8"))
|
|
288
|
+
|
|
289
|
+
if events:
|
|
290
|
+
self.wfile.flush()
|
|
291
|
+
last_activity = _time.time()
|
|
292
|
+
|
|
293
|
+
# 작업 완료 확인
|
|
294
|
+
meta = parse_meta_file(meta_file)
|
|
295
|
+
status = meta.get("STATUS", "")
|
|
296
|
+
if status == "running" and meta.get("PID"):
|
|
297
|
+
try:
|
|
298
|
+
os.kill(int(meta["PID"]), 0)
|
|
299
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
300
|
+
status = "done"
|
|
301
|
+
|
|
302
|
+
if status in ("done", "failed"):
|
|
303
|
+
# 최종 이벤트 한 번 더 수집
|
|
304
|
+
final_events, _ = self._parse_stream_events(out_file, offset)
|
|
305
|
+
for evt in final_events:
|
|
306
|
+
data = json.dumps(evt, ensure_ascii=False)
|
|
307
|
+
self.wfile.write(f"data: {data}\n\n".encode("utf-8"))
|
|
308
|
+
self.wfile.write(f"event: done\ndata: {{\"status\":\"{status}\"}}\n\n".encode("utf-8"))
|
|
309
|
+
self.wfile.flush()
|
|
310
|
+
break
|
|
311
|
+
|
|
312
|
+
# Heartbeat — 15초 동안 이벤트 없으면 keepalive 전송
|
|
313
|
+
now = _time.time()
|
|
314
|
+
if now - last_activity > 15:
|
|
315
|
+
self.wfile.write(b": heartbeat\n\n")
|
|
316
|
+
self.wfile.flush()
|
|
317
|
+
last_activity = now
|
|
318
|
+
|
|
319
|
+
_time.sleep(0.3)
|
|
320
|
+
|
|
321
|
+
except (BrokenPipeError, ConnectionResetError, OSError):
|
|
322
|
+
pass # 클라이언트 연결 끊김
|
|
219
323
|
|
|
220
324
|
def _handle_job_checkpoints(self, job_id):
|
|
221
325
|
checkpoints, err = self._ckpt_mod().get_job_checkpoints(job_id)
|
|
222
326
|
if err:
|
|
223
|
-
self._error_response(err, 404)
|
|
327
|
+
self._error_response(err, 404, code="JOB_NOT_FOUND")
|
|
224
328
|
else:
|
|
225
329
|
self._json_response(checkpoints)
|
|
226
330
|
|
|
@@ -229,21 +333,40 @@ class JobHandlerMixin:
|
|
|
229
333
|
matched = [j for j in jobs if j.get("session_id") == session_id]
|
|
230
334
|
if not matched:
|
|
231
335
|
return self._error_response(
|
|
232
|
-
f"Session ID '{session_id[:8]}...'에 해당하는 작업을 찾을 수 없습니다", 404)
|
|
336
|
+
f"Session ID '{session_id[:8]}...'에 해당하는 작업을 찾을 수 없습니다", 404, code="SESSION_NOT_FOUND")
|
|
233
337
|
self._json_response(matched[0])
|
|
234
338
|
|
|
339
|
+
def _handle_job_diff(self, job_id):
|
|
340
|
+
parsed = urlparse(self.path)
|
|
341
|
+
qs = parse_qs(parsed.query)
|
|
342
|
+
from_hash = qs.get("from", [""])[0].strip()
|
|
343
|
+
to_hash = qs.get("to", [""])[0].strip()
|
|
344
|
+
|
|
345
|
+
if not from_hash:
|
|
346
|
+
return self._error_response("from 파라미터가 필요합니다", code="MISSING_FIELD")
|
|
347
|
+
|
|
348
|
+
result, err = self._ckpt_mod().diff_checkpoints(job_id, from_hash, to_hash or None)
|
|
349
|
+
if err:
|
|
350
|
+
status = 404 if "찾을 수 없습니다" in err else 500
|
|
351
|
+
self._error_response(err, status, code="DIFF_FAILED")
|
|
352
|
+
else:
|
|
353
|
+
self._json_response(result)
|
|
354
|
+
|
|
235
355
|
def _handle_job_rewind(self, job_id):
|
|
236
356
|
body = self._read_body()
|
|
237
357
|
checkpoint_hash = body.get("checkpoint", "").strip()
|
|
238
358
|
new_prompt = body.get("prompt", "").strip()
|
|
239
359
|
|
|
240
360
|
if not checkpoint_hash:
|
|
241
|
-
return self._error_response("checkpoint 필드가 필요합니다")
|
|
361
|
+
return self._error_response("checkpoint 필드가 필요합니다", code="MISSING_FIELD")
|
|
242
362
|
if not new_prompt:
|
|
243
|
-
return self._error_response("prompt 필드가 필요합니다")
|
|
363
|
+
return self._error_response("prompt 필드가 필요합니다", code="MISSING_FIELD")
|
|
244
364
|
|
|
245
365
|
result, err = self._ckpt_mod().rewind_job(job_id, checkpoint_hash, new_prompt)
|
|
246
366
|
if err:
|
|
247
|
-
|
|
367
|
+
if "찾을 수 없습니다" in err:
|
|
368
|
+
self._error_response(err, 400, code="CHECKPOINT_NOT_FOUND")
|
|
369
|
+
else:
|
|
370
|
+
self._error_response(err, 500, code="REWIND_FAILED")
|
|
248
371
|
else:
|
|
249
372
|
self._json_response(result, 201)
|