claude-controller 0.1.2 → 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 +1189 -0
- package/bin/native-app.py +6 -3
- 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 +11 -5
- 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 +634 -473
- package/web/handler_fs.py +153 -0
- package/web/handler_goals.py +203 -0
- package/web/handler_jobs.py +372 -0
- package/web/handler_memory.py +203 -0
- package/web/handler_sessions.py +132 -0
- package/web/jobs.py +585 -13
- package/web/personas.py +419 -0
- package/web/pipeline.py +981 -0
- package/web/presets.py +506 -0
- package/web/projects.py +246 -0
- package/web/static/api.js +141 -0
- package/web/static/app.js +25 -1937
- package/web/static/attachments.js +144 -0
- package/web/static/base.css +497 -0
- package/web/static/context.js +204 -0
- package/web/static/dirs.js +246 -0
- package/web/static/form.css +763 -0
- package/web/static/goals.css +363 -0
- package/web/static/goals.js +300 -0
- package/web/static/i18n.js +625 -0
- package/web/static/index.html +215 -13
- package/web/static/{styles.css → jobs.css} +746 -1141
- package/web/static/jobs.js +1270 -0
- package/web/static/memoryview.js +117 -0
- package/web/static/personas.js +228 -0
- package/web/static/pipeline.css +338 -0
- package/web/static/pipelines.js +487 -0
- package/web/static/presets.js +244 -0
- package/web/static/send.js +135 -0
- package/web/static/settings-style.css +291 -0
- package/web/static/settings.js +81 -0
- package/web/static/stream.js +534 -0
- package/web/static/utils.js +131 -0
- package/web/webhook.py +210 -0
package/web/projects.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Controller Service — 프로젝트 관리
|
|
3
|
+
|
|
4
|
+
프로젝트는 자동화 대상 저장소/디렉토리를 등록·관리하는 단위이다.
|
|
5
|
+
기존 프로젝트를 등록하거나, 신규 프로젝트를 생성(디렉토리 + git init)할 수 있다.
|
|
6
|
+
저장: data/projects.json
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from config import DATA_DIR, LOGS_DIR
|
|
16
|
+
from utils import parse_meta_file
|
|
17
|
+
|
|
18
|
+
PROJECTS_FILE = DATA_DIR / "projects.json"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _load_projects() -> list[dict]:
|
|
22
|
+
"""프로젝트 목록을 파일에서 읽는다."""
|
|
23
|
+
try:
|
|
24
|
+
if PROJECTS_FILE.exists():
|
|
25
|
+
return json.loads(PROJECTS_FILE.read_text("utf-8"))
|
|
26
|
+
except (json.JSONDecodeError, OSError):
|
|
27
|
+
pass
|
|
28
|
+
return []
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _save_projects(projects: list[dict]):
|
|
32
|
+
"""프로젝트 목록을 파일에 저장한다."""
|
|
33
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
PROJECTS_FILE.write_text(
|
|
35
|
+
json.dumps(projects, ensure_ascii=False, indent=2), "utf-8"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _generate_id() -> str:
|
|
40
|
+
return f"{int(time.time())}-{os.getpid()}-{id(time) % 10000}"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _collect_job_stats_by_cwd() -> dict:
|
|
44
|
+
"""logs/ 디렉토리의 .meta 파일을 순회하여 cwd별 job 통계를 집계한다."""
|
|
45
|
+
stats = {}
|
|
46
|
+
if not LOGS_DIR.exists():
|
|
47
|
+
return stats
|
|
48
|
+
for mf in LOGS_DIR.glob("job_*.meta"):
|
|
49
|
+
meta = parse_meta_file(mf)
|
|
50
|
+
if not meta:
|
|
51
|
+
continue
|
|
52
|
+
cwd = meta.get("CWD", "")
|
|
53
|
+
if not cwd:
|
|
54
|
+
continue
|
|
55
|
+
norm = os.path.normpath(cwd)
|
|
56
|
+
if norm not in stats:
|
|
57
|
+
stats[norm] = {"total": 0, "running": 0, "done": 0, "failed": 0, "cost": 0.0}
|
|
58
|
+
s = stats[norm]
|
|
59
|
+
s["total"] += 1
|
|
60
|
+
status = meta.get("STATUS", "unknown")
|
|
61
|
+
if status == "running":
|
|
62
|
+
pid = meta.get("PID")
|
|
63
|
+
if pid:
|
|
64
|
+
try:
|
|
65
|
+
os.kill(int(pid), 0)
|
|
66
|
+
except (ProcessLookupError, ValueError, OSError):
|
|
67
|
+
status = "done"
|
|
68
|
+
if status == "running":
|
|
69
|
+
s["running"] += 1
|
|
70
|
+
elif status == "done":
|
|
71
|
+
s["done"] += 1
|
|
72
|
+
elif status == "failed":
|
|
73
|
+
s["failed"] += 1
|
|
74
|
+
return stats
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _detect_git_info(path: str) -> dict:
|
|
78
|
+
"""디렉토리의 git 정보를 감지한다."""
|
|
79
|
+
info = {"is_git": False, "branch": "", "remote": ""}
|
|
80
|
+
try:
|
|
81
|
+
result = subprocess.run(
|
|
82
|
+
["git", "rev-parse", "--is-inside-work-tree"],
|
|
83
|
+
cwd=path, capture_output=True, text=True, timeout=5,
|
|
84
|
+
)
|
|
85
|
+
if result.returncode != 0:
|
|
86
|
+
return info
|
|
87
|
+
info["is_git"] = True
|
|
88
|
+
|
|
89
|
+
result = subprocess.run(
|
|
90
|
+
["git", "branch", "--show-current"],
|
|
91
|
+
cwd=path, capture_output=True, text=True, timeout=5,
|
|
92
|
+
)
|
|
93
|
+
info["branch"] = result.stdout.strip()
|
|
94
|
+
|
|
95
|
+
result = subprocess.run(
|
|
96
|
+
["git", "remote", "get-url", "origin"],
|
|
97
|
+
cwd=path, capture_output=True, text=True, timeout=5,
|
|
98
|
+
)
|
|
99
|
+
info["remote"] = result.stdout.strip()
|
|
100
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
101
|
+
pass
|
|
102
|
+
return info
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ══════════════════════════════════════════════════════════════
|
|
106
|
+
# CRUD
|
|
107
|
+
# ══════════════════════════════════════════════════════════════
|
|
108
|
+
|
|
109
|
+
def list_projects(include_job_stats=True) -> list[dict]:
|
|
110
|
+
"""등록된 프로젝트 목록을 반환한다.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
include_job_stats: True이면 각 프로젝트의 job 통계를 포함한다.
|
|
114
|
+
"""
|
|
115
|
+
projects = _load_projects()
|
|
116
|
+
|
|
117
|
+
# job 통계를 위한 cwd→count 맵 (한 번만 순회)
|
|
118
|
+
job_stats = {}
|
|
119
|
+
if include_job_stats:
|
|
120
|
+
job_stats = _collect_job_stats_by_cwd()
|
|
121
|
+
|
|
122
|
+
for p in projects:
|
|
123
|
+
p["exists"] = os.path.isdir(p.get("path", ""))
|
|
124
|
+
if include_job_stats:
|
|
125
|
+
norm_path = os.path.normpath(p.get("path", ""))
|
|
126
|
+
stats = job_stats.get(norm_path, {})
|
|
127
|
+
p["job_stats"] = {
|
|
128
|
+
"total": stats.get("total", 0),
|
|
129
|
+
"running": stats.get("running", 0),
|
|
130
|
+
"done": stats.get("done", 0),
|
|
131
|
+
"failed": stats.get("failed", 0),
|
|
132
|
+
"total_cost": round(stats.get("cost", 0), 4),
|
|
133
|
+
}
|
|
134
|
+
return projects
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def get_project(project_id: str) -> tuple[dict | None, str | None]:
|
|
138
|
+
"""ID로 프로젝트를 조회한다."""
|
|
139
|
+
projects = _load_projects()
|
|
140
|
+
for p in projects:
|
|
141
|
+
if p["id"] == project_id:
|
|
142
|
+
p["exists"] = os.path.isdir(p.get("path", ""))
|
|
143
|
+
git_info = _detect_git_info(p["path"]) if p["exists"] else {}
|
|
144
|
+
p.update(git_info)
|
|
145
|
+
return p, None
|
|
146
|
+
return None, "프로젝트를 찾을 수 없습니다"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def add_project(path: str, name: str = "", description: str = "") -> tuple[dict | None, str | None]:
|
|
150
|
+
"""기존 디렉토리를 프로젝트로 등록한다."""
|
|
151
|
+
path = os.path.abspath(os.path.expanduser(path))
|
|
152
|
+
if not os.path.isdir(path):
|
|
153
|
+
return None, f"디렉토리가 존재하지 않습니다: {path}"
|
|
154
|
+
|
|
155
|
+
projects = _load_projects()
|
|
156
|
+
|
|
157
|
+
# 중복 체크
|
|
158
|
+
for p in projects:
|
|
159
|
+
if os.path.normpath(p["path"]) == os.path.normpath(path):
|
|
160
|
+
return None, f"이미 등록된 프로젝트입니다: {p['name']} ({p['id']})"
|
|
161
|
+
|
|
162
|
+
if not name:
|
|
163
|
+
name = os.path.basename(path)
|
|
164
|
+
|
|
165
|
+
git_info = _detect_git_info(path)
|
|
166
|
+
now = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
167
|
+
|
|
168
|
+
project = {
|
|
169
|
+
"id": _generate_id(),
|
|
170
|
+
"name": name,
|
|
171
|
+
"path": path,
|
|
172
|
+
"description": description,
|
|
173
|
+
"is_git": git_info["is_git"],
|
|
174
|
+
"branch": git_info["branch"],
|
|
175
|
+
"remote": git_info["remote"],
|
|
176
|
+
"created_at": now,
|
|
177
|
+
"last_used_at": now,
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
projects.append(project)
|
|
181
|
+
_save_projects(projects)
|
|
182
|
+
return project, None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def create_project(path: str, name: str = "", description: str = "",
|
|
186
|
+
init_git: bool = True) -> tuple[dict | None, str | None]:
|
|
187
|
+
"""신규 프로젝트를 생성한다 (디렉토리 생성 + git init + 등록)."""
|
|
188
|
+
path = os.path.abspath(os.path.expanduser(path))
|
|
189
|
+
|
|
190
|
+
if os.path.exists(path):
|
|
191
|
+
return None, f"이미 존재하는 경로입니다: {path}"
|
|
192
|
+
|
|
193
|
+
try:
|
|
194
|
+
os.makedirs(path)
|
|
195
|
+
except OSError as e:
|
|
196
|
+
return None, f"디렉토리 생성 실패: {e}"
|
|
197
|
+
|
|
198
|
+
if init_git:
|
|
199
|
+
try:
|
|
200
|
+
subprocess.run(
|
|
201
|
+
["git", "init"],
|
|
202
|
+
cwd=path, capture_output=True, text=True, timeout=10, check=True,
|
|
203
|
+
)
|
|
204
|
+
except (subprocess.CalledProcessError, OSError) as e:
|
|
205
|
+
return None, f"git init 실패: {e}"
|
|
206
|
+
|
|
207
|
+
if not name:
|
|
208
|
+
name = os.path.basename(path)
|
|
209
|
+
|
|
210
|
+
return add_project(path, name=name, description=description)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def remove_project(project_id: str) -> tuple[dict | None, str | None]:
|
|
214
|
+
"""프로젝트 등록을 해제한다 (디렉토리는 삭제하지 않음)."""
|
|
215
|
+
projects = _load_projects()
|
|
216
|
+
for i, p in enumerate(projects):
|
|
217
|
+
if p["id"] == project_id:
|
|
218
|
+
removed = projects.pop(i)
|
|
219
|
+
_save_projects(projects)
|
|
220
|
+
return removed, None
|
|
221
|
+
return None, "프로젝트를 찾을 수 없습니다"
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def update_project(project_id: str, **kwargs) -> tuple[dict | None, str | None]:
|
|
225
|
+
"""프로젝트 정보를 업데이트한다."""
|
|
226
|
+
projects = _load_projects()
|
|
227
|
+
allowed = {"name", "description"}
|
|
228
|
+
for p in projects:
|
|
229
|
+
if p["id"] == project_id:
|
|
230
|
+
for k, v in kwargs.items():
|
|
231
|
+
if k in allowed and v is not None:
|
|
232
|
+
p[k] = v
|
|
233
|
+
p["last_used_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
234
|
+
_save_projects(projects)
|
|
235
|
+
return p, None
|
|
236
|
+
return None, "프로젝트를 찾을 수 없습니다"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def touch_project(project_id: str):
|
|
240
|
+
"""last_used_at을 갱신한다."""
|
|
241
|
+
projects = _load_projects()
|
|
242
|
+
for p in projects:
|
|
243
|
+
if p["id"] == project_id:
|
|
244
|
+
p["last_used_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
|
245
|
+
_save_projects(projects)
|
|
246
|
+
return
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/* ═══════════════════════════════════════════════
|
|
2
|
+
API & Service — 백엔드 연결, API 호출, 서비스 상태
|
|
3
|
+
═══════════════════════════════════════════════ */
|
|
4
|
+
|
|
5
|
+
const LOCAL_BACKEND = 'http://localhost:8420';
|
|
6
|
+
let API = '';
|
|
7
|
+
let AUTH_TOKEN = '';
|
|
8
|
+
let _backendConnected = false;
|
|
9
|
+
let serviceRunning = null;
|
|
10
|
+
|
|
11
|
+
/* ── Connection health tracking ── */
|
|
12
|
+
let _connFailCount = 0;
|
|
13
|
+
const _CONN_FAIL_THRESHOLD = 3;
|
|
14
|
+
let _connBannerVisible = false;
|
|
15
|
+
|
|
16
|
+
function _updateConnBanner(ok) {
|
|
17
|
+
if (ok) {
|
|
18
|
+
if (_connFailCount > 0) _connFailCount = 0;
|
|
19
|
+
if (_connBannerVisible) {
|
|
20
|
+
_connBannerVisible = false;
|
|
21
|
+
const banner = document.getElementById('connLostBanner');
|
|
22
|
+
if (banner) banner.classList.remove('visible');
|
|
23
|
+
}
|
|
24
|
+
} else {
|
|
25
|
+
_connFailCount++;
|
|
26
|
+
if (_connFailCount >= _CONN_FAIL_THRESHOLD && !_connBannerVisible) {
|
|
27
|
+
_connBannerVisible = true;
|
|
28
|
+
const banner = document.getElementById('connLostBanner');
|
|
29
|
+
if (banner) banner.classList.add('visible');
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function apiFetch(path, options = {}) {
|
|
35
|
+
const headers = { 'Content-Type': 'application/json', ...options.headers };
|
|
36
|
+
if (AUTH_TOKEN) {
|
|
37
|
+
headers['Authorization'] = `Bearer ${AUTH_TOKEN}`;
|
|
38
|
+
}
|
|
39
|
+
let resp;
|
|
40
|
+
try {
|
|
41
|
+
resp = await fetch(`${API}${path}`, { ...options, headers });
|
|
42
|
+
} catch (networkErr) {
|
|
43
|
+
_updateConnBanner(false);
|
|
44
|
+
throw networkErr;
|
|
45
|
+
}
|
|
46
|
+
_updateConnBanner(true);
|
|
47
|
+
if (!resp.ok) {
|
|
48
|
+
let msg = '';
|
|
49
|
+
try {
|
|
50
|
+
const ct = resp.headers.get('content-type') || '';
|
|
51
|
+
if (ct.includes('application/json')) {
|
|
52
|
+
const body = await resp.json();
|
|
53
|
+
if (body.error && typeof body.error === 'object') {
|
|
54
|
+
msg = body.error.message || JSON.stringify(body.error);
|
|
55
|
+
} else {
|
|
56
|
+
msg = body.error || body.message || JSON.stringify(body);
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
msg = await resp.text();
|
|
60
|
+
}
|
|
61
|
+
} catch { /* parse fail — fall through */ }
|
|
62
|
+
throw new Error(msg || `HTTP ${resp.status}`);
|
|
63
|
+
}
|
|
64
|
+
const ct = resp.headers.get('content-type') || '';
|
|
65
|
+
if (ct.includes('application/json')) return resp.json();
|
|
66
|
+
return resp.text();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function checkStatus() {
|
|
70
|
+
try {
|
|
71
|
+
const data = await apiFetch('/api/status');
|
|
72
|
+
const running = data.running !== undefined ? data.running : (data.status === 'running');
|
|
73
|
+
serviceRunning = running;
|
|
74
|
+
} catch {
|
|
75
|
+
serviceRunning = null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* ── Goals API ── */
|
|
80
|
+
async function fetchGoals(status) {
|
|
81
|
+
const qs = status ? `?status=${status}` : '';
|
|
82
|
+
return apiFetch(`/api/goals${qs}`);
|
|
83
|
+
}
|
|
84
|
+
async function createGoal(objective, mode = 'gate', opts = {}) {
|
|
85
|
+
return apiFetch('/api/goals', {
|
|
86
|
+
method: 'POST',
|
|
87
|
+
body: JSON.stringify({ objective, mode, ...opts }),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async function getGoal(id) { return apiFetch(`/api/goals/${id}`); }
|
|
91
|
+
async function updateGoal(id, fields) {
|
|
92
|
+
return apiFetch(`/api/goals/${id}/update`, {
|
|
93
|
+
method: 'POST', body: JSON.stringify(fields),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async function approveGoal(id) {
|
|
97
|
+
return apiFetch(`/api/goals/${id}/approve`, { method: 'POST' });
|
|
98
|
+
}
|
|
99
|
+
async function cancelGoal(id) {
|
|
100
|
+
return apiFetch(`/api/goals/${id}`, { method: 'DELETE' });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* ── Memory API ── */
|
|
104
|
+
async function fetchMemories(params = {}) {
|
|
105
|
+
const qs = new URLSearchParams(params).toString();
|
|
106
|
+
return apiFetch(`/api/memory${qs ? '?' + qs : ''}`);
|
|
107
|
+
}
|
|
108
|
+
async function createMemory(data) {
|
|
109
|
+
return apiFetch('/api/memory', {
|
|
110
|
+
method: 'POST', body: JSON.stringify(data),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
async function getMemory(id) { return apiFetch(`/api/memory/${id}`); }
|
|
114
|
+
async function updateMemory(id, fields) {
|
|
115
|
+
return apiFetch(`/api/memory/${id}/update`, {
|
|
116
|
+
method: 'POST', body: JSON.stringify(fields),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async function deleteMemory(id) {
|
|
120
|
+
return apiFetch(`/api/memory/${id}`, { method: 'DELETE' });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function serviceAction(action) {
|
|
124
|
+
const btn = document.getElementById(`btn${action.charAt(0).toUpperCase() + action.slice(1)}`);
|
|
125
|
+
if (btn) btn.disabled = true;
|
|
126
|
+
try {
|
|
127
|
+
if (action === 'restart') {
|
|
128
|
+
await apiFetch('/api/service/stop', { method: 'POST' });
|
|
129
|
+
await new Promise(r => setTimeout(r, 500));
|
|
130
|
+
await apiFetch('/api/service/start', { method: 'POST' });
|
|
131
|
+
} else {
|
|
132
|
+
await apiFetch(`/api/service/${action}`, { method: 'POST' });
|
|
133
|
+
}
|
|
134
|
+
showToast(t(action === 'start' ? 'msg_service_start' : action === 'stop' ? 'msg_service_stop' : 'msg_service_restart'));
|
|
135
|
+
setTimeout(checkStatus, 1000);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
showToast(`${t('msg_service_failed')}: ${err.message}`, 'error');
|
|
138
|
+
} finally {
|
|
139
|
+
if (btn) btn.disabled = false;
|
|
140
|
+
}
|
|
141
|
+
}
|