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.
Files changed (68) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +327 -5
  4. package/bin/native-app.py +5 -2
  5. package/bin/watchdog.sh +357 -0
  6. package/cognitive/__init__.py +14 -0
  7. package/cognitive/__pycache__/__init__.cpython-314.pyc +0 -0
  8. package/cognitive/__pycache__/dispatcher.cpython-314.pyc +0 -0
  9. package/cognitive/__pycache__/evaluator.cpython-314.pyc +0 -0
  10. package/cognitive/__pycache__/goal_engine.cpython-314.pyc +0 -0
  11. package/cognitive/__pycache__/learning.cpython-314.pyc +0 -0
  12. package/cognitive/__pycache__/orchestrator.cpython-314.pyc +0 -0
  13. package/cognitive/__pycache__/planner.cpython-314.pyc +0 -0
  14. package/cognitive/dispatcher.py +192 -0
  15. package/cognitive/evaluator.py +289 -0
  16. package/cognitive/goal_engine.py +232 -0
  17. package/cognitive/learning.py +189 -0
  18. package/cognitive/orchestrator.py +303 -0
  19. package/cognitive/planner.py +207 -0
  20. package/cognitive/prompts/analyst.md +31 -0
  21. package/cognitive/prompts/coder.md +22 -0
  22. package/cognitive/prompts/reviewer.md +33 -0
  23. package/cognitive/prompts/tester.md +21 -0
  24. package/cognitive/prompts/writer.md +25 -0
  25. package/config.sh +6 -1
  26. package/dag/__init__.py +5 -0
  27. package/dag/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/dag/__pycache__/graph.cpython-314.pyc +0 -0
  29. package/dag/graph.py +222 -0
  30. package/lib/jobs.sh +12 -1
  31. package/package.json +5 -1
  32. package/postinstall.sh +1 -1
  33. package/service/controller.sh +43 -11
  34. package/web/audit.py +122 -0
  35. package/web/checkpoint.py +80 -0
  36. package/web/config.py +2 -5
  37. package/web/handler.py +464 -26
  38. package/web/handler_fs.py +15 -14
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +165 -42
  41. package/web/handler_memory.py +203 -0
  42. package/web/jobs.py +576 -12
  43. package/web/personas.py +419 -0
  44. package/web/pipeline.py +682 -50
  45. package/web/presets.py +506 -0
  46. package/web/projects.py +58 -4
  47. package/web/static/api.js +90 -3
  48. package/web/static/app.js +8 -0
  49. package/web/static/base.css +51 -12
  50. package/web/static/context.js +14 -4
  51. package/web/static/form.css +3 -2
  52. package/web/static/goals.css +363 -0
  53. package/web/static/goals.js +300 -0
  54. package/web/static/i18n.js +288 -0
  55. package/web/static/index.html +142 -6
  56. package/web/static/jobs.css +951 -4
  57. package/web/static/jobs.js +890 -54
  58. package/web/static/memoryview.js +117 -0
  59. package/web/static/personas.js +228 -0
  60. package/web/static/pipeline.css +308 -1
  61. package/web/static/pipelines.js +249 -14
  62. package/web/static/presets.js +244 -0
  63. package/web/static/send.js +26 -4
  64. package/web/static/settings-style.css +34 -3
  65. package/web/static/settings.js +37 -1
  66. package/web/static/stream.js +242 -19
  67. package/web/static/utils.js +54 -2
  68. 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": True,
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)
@@ -33,13 +33,26 @@ ALLOWED_UPLOAD_EXTS = IMAGE_EXTS | {
33
33
 
34
34
  class JobHandlerMixin:
35
35
 
36
- def _handle_jobs(self):
37
- self._json_response(self._jobs_mod().get_all_jobs())
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
- ext = ext if ext else ".bin"
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
- def _handle_job_stream(self, job_id):
165
- out_file = LOGS_DIR / f"job_{job_id}.out"
166
- meta_file = LOGS_DIR / f"job_{job_id}.meta"
167
-
168
- if not meta_file.exists():
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 self._json_response({"events": [], "offset": 0, "done": False})
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
- events.append({
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
- self._error_response(err, 400 if "찾을 수 없습니다" in err else 500)
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)