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/presets.py ADDED
@@ -0,0 +1,506 @@
1
+ """
2
+ Preset Engine -- 자동화 구조 프리셋
3
+
4
+ 프리셋 = 여러 파이프라인을 묶은 템플릿.
5
+ 한 번에 적용하면 프로젝트에 맞게 파이프라인 세트가 생성된다.
6
+
7
+ 내장 프리셋:
8
+ 1. continuous-dev -- 지속적 개발 (이슈 수정 + 코드 품질 + 유지보수)
9
+ 2. code-review -- 코드 리뷰 자동화 (리뷰 + 리팩터링 사이클)
10
+ 3. docs-and-tests -- 문서화 + 테스트 커버리지 강화
11
+ 4. security-ops -- 보안 감사 + 취약점 모니터링
12
+ 5. full-lifecycle -- 전체 라이프사이클 (기획 -> 개발 -> 검증 -> 정리)
13
+
14
+ 사용자 정의 프리셋:
15
+ data/presets.json에 저장 (내장 프리셋을 복제/수정하거나 새로 생성)
16
+ """
17
+
18
+ import json
19
+ import os
20
+ import time
21
+ from pathlib import Path
22
+
23
+ from config import DATA_DIR
24
+ import pipeline as _pipeline_mod
25
+
26
+ PRESETS_FILE = DATA_DIR / "presets.json"
27
+
28
+
29
+ # ==================================================================
30
+ # 내장(built-in) 프리셋 정의
31
+ # ==================================================================
32
+
33
+ BUILTIN_PRESETS = [
34
+ {
35
+ "id": "continuous-dev",
36
+ "name": "지속적 개발",
37
+ "name_en": "Continuous Dev",
38
+ "description": "이슈 수정, 코드 품질 개선, 유지보수를 자동 순환하는 3-파이프라인 세트",
39
+ "description_en": "3-pipeline set: issue fix -> code quality -> maintenance cycle",
40
+ "icon": "rocket",
41
+ "builtin": True,
42
+ "pipelines": [
43
+ {
44
+ "ref": "issue-fix",
45
+ "name": "이슈 수정",
46
+ "command": (
47
+ "프로젝트의 TODO, FIXME, 알려진 버그를 찾아 가장 중요한 1개를 수정해.\n\n"
48
+ "작업 순서:\n"
49
+ "1. grep -rn 'TODO\\|FIXME\\|HACK\\|BUG' --include='*.py' --include='*.js' --include='*.ts' --include='*.sh' . 로 이슈 목록 파악\n"
50
+ "2. 가장 심각한 1개를 선택\n"
51
+ "3. 관련 코드를 읽고 분석\n"
52
+ "4. 최소 범위로 수정\n\n"
53
+ "규칙: 한 번에 1개만. 기존 동작을 깨뜨리지 말 것."
54
+ ),
55
+ "interval": "5m",
56
+ "on_complete_ref": "quality",
57
+ },
58
+ {
59
+ "ref": "quality",
60
+ "name": "코드 품질",
61
+ "command": (
62
+ "코드 품질과 UX를 개선해.\n\n"
63
+ "작업 순서:\n"
64
+ "1. git log --oneline -10 으로 최근 변경사항 파악\n"
65
+ "2. 최근 변경된 파일을 읽고 다음 중 1가지 개선:\n"
66
+ " - 에러 핸들링 보강\n"
67
+ " - 코드 중복 제거\n"
68
+ " - 타입/유효성 검증 추가\n"
69
+ " - 사용자 경험 개선\n"
70
+ "3. 최소 범위로 구현\n\n"
71
+ "규칙: 한 번에 1가지만. 새 파일 생성보다 기존 파일 수정 우선."
72
+ ),
73
+ "interval": "10m",
74
+ "on_complete_ref": None,
75
+ },
76
+ {
77
+ "ref": "maintenance",
78
+ "name": "유지보수",
79
+ "command": (
80
+ "프로젝트 상태를 점검하고 유지보수 작업을 수행해.\n\n"
81
+ "1. 오래된 임시 파일, 로그 정리 대상 확인\n"
82
+ "2. 설정 파일의 일관성 점검\n"
83
+ "3. 의존성 버전 확인\n"
84
+ "4. 발견된 문제 중 안전하게 처리 가능한 1가지 수행\n\n"
85
+ "실제 삭제는 불필요함이 확실한 파일만. 현재 사용 중인 파일은 절대 건드리지 말 것."
86
+ ),
87
+ "interval": "20m",
88
+ "on_complete_ref": None,
89
+ },
90
+ ],
91
+ },
92
+ {
93
+ "id": "code-review",
94
+ "name": "코드 리뷰",
95
+ "name_en": "Code Review",
96
+ "description": "최근 변경사항을 자동 리뷰하고 리팩터링 제안을 실행하는 2-파이프라인 세트",
97
+ "description_en": "2-pipeline set: auto-review recent changes + execute refactoring",
98
+ "icon": "search",
99
+ "builtin": True,
100
+ "pipelines": [
101
+ {
102
+ "ref": "review",
103
+ "name": "코드 리뷰",
104
+ "command": (
105
+ "최근 변경사항을 리뷰해.\n\n"
106
+ "1. git diff HEAD~3 --stat 으로 최근 변경 파일 확인\n"
107
+ "2. 변경된 파일들을 읽고 다음 관점에서 리뷰:\n"
108
+ " - 보안 취약점 (인젝션, XSS, 경로 탈출 등)\n"
109
+ " - 성능 문제 (N+1 쿼리, 불필요한 루프 등)\n"
110
+ " - 에러 핸들링 누락\n"
111
+ " - 코드 스타일 일관성\n"
112
+ "3. 발견된 문제를 심각도순으로 정리하여 보고\n"
113
+ "4. 가장 심각한 문제 1개를 직접 수정\n\n"
114
+ "리뷰 결과는 구체적 파일명:라인 형태로 지적할 것."
115
+ ),
116
+ "interval": "5m",
117
+ "on_complete_ref": "refactor",
118
+ },
119
+ {
120
+ "ref": "refactor",
121
+ "name": "리팩터링",
122
+ "command": (
123
+ "코드 구조를 개선하는 리팩터링을 1건 수행해.\n\n"
124
+ "1. git log --oneline -5 로 최근 작업 맥락 파악\n"
125
+ "2. 다음 중 하나를 선택하여 수행:\n"
126
+ " - 긴 함수 분리 (30줄 이상)\n"
127
+ " - 매직 넘버를 상수로 추출\n"
128
+ " - 중복 코드 공통 함수로 추출\n"
129
+ " - 네이밍 개선\n"
130
+ "3. 리팩터링 후 동작이 동일한지 확인\n\n"
131
+ "규칙: 한 번에 1건만. 동작 변경 없이 구조만 개선."
132
+ ),
133
+ "interval": "10m",
134
+ "on_complete_ref": None,
135
+ },
136
+ ],
137
+ },
138
+ {
139
+ "id": "docs-and-tests",
140
+ "name": "문서 + 테스트",
141
+ "name_en": "Docs & Tests",
142
+ "description": "문서화와 테스트 커버리지를 자동으로 강화하는 2-파이프라인 세트",
143
+ "description_en": "2-pipeline set: auto-documentation + test coverage improvement",
144
+ "icon": "book",
145
+ "builtin": True,
146
+ "pipelines": [
147
+ {
148
+ "ref": "docs",
149
+ "name": "문서화",
150
+ "command": (
151
+ "프로젝트 문서를 개선해.\n\n"
152
+ "1. README.md 또는 주요 문서 파일을 읽어서 현재 상태 파악\n"
153
+ "2. 코드와 문서 사이의 불일치를 찾음:\n"
154
+ " - 문서에 없는 새 기능\n"
155
+ " - 변경된 API/설정 반영 누락\n"
156
+ " - 설치/사용 방법 업데이트 필요\n"
157
+ "3. 가장 중요한 불일치 1건을 수정\n\n"
158
+ "규칙: 기존 문서 스타일 유지. 과도한 내용 추가 금지."
159
+ ),
160
+ "interval": "10m",
161
+ "on_complete_ref": None,
162
+ },
163
+ {
164
+ "ref": "tests",
165
+ "name": "테스트",
166
+ "command": (
167
+ "테스트 커버리지를 강화해.\n\n"
168
+ "1. 기존 테스트 파일들을 확인 (test_*, *_test.*, *_spec.*)\n"
169
+ "2. 테스트가 없거나 부족한 핵심 모듈을 식별\n"
170
+ "3. 가장 중요한 모듈에 대해 테스트 1-2개 추가\n"
171
+ "4. 추가한 테스트가 통과하는지 실행하여 확인\n\n"
172
+ "규칙: 기존 테스트 프레임워크/스타일 따를 것. 실행 가능한 테스트만 작성."
173
+ ),
174
+ "interval": "10m",
175
+ "on_complete_ref": None,
176
+ },
177
+ ],
178
+ },
179
+ {
180
+ "id": "security-ops",
181
+ "name": "보안 감사",
182
+ "name_en": "Security Ops",
183
+ "description": "보안 취약점 스캔과 하드닝을 자동 수행하는 2-파이프라인 세트",
184
+ "description_en": "2-pipeline set: vulnerability scanning + security hardening",
185
+ "icon": "shield",
186
+ "builtin": True,
187
+ "pipelines": [
188
+ {
189
+ "ref": "scan",
190
+ "name": "보안 스캔",
191
+ "command": (
192
+ "보안 취약점을 스캔해.\n\n"
193
+ "1. 다음 패턴을 grep으로 검색:\n"
194
+ " - eval(, exec(, subprocess.call(shell=True\n"
195
+ " - innerHTML, dangerouslySetInnerHTML\n"
196
+ " - password, secret, token 이 하드코딩된 곳\n"
197
+ " - os.path.join에 사용자 입력이 직접 들어가는 곳\n"
198
+ "2. 발견된 항목을 OWASP 분류에 따라 심각도 판정\n"
199
+ "3. 가장 심각한 1건을 직접 수정\n\n"
200
+ "규칙: 테스트/예제 파일은 무시. 실제 서비스 코드만 대상."
201
+ ),
202
+ "interval": "10m",
203
+ "on_complete_ref": "harden",
204
+ },
205
+ {
206
+ "ref": "harden",
207
+ "name": "보안 강화",
208
+ "command": (
209
+ "서비스 보안을 강화해.\n\n"
210
+ "1. 입력 검증 로직이 빠진 API 엔드포인트를 찾음\n"
211
+ "2. 파일 접근 시 경로 탈출(path traversal) 방어 확인\n"
212
+ "3. CORS, CSP 등 보안 헤더 설정 점검\n"
213
+ "4. 발견된 취약점 중 1건을 방어 코드 추가로 해결\n\n"
214
+ "규칙: 기존 동작 깨뜨리지 않을 것. 방어 코드는 최소한으로."
215
+ ),
216
+ "interval": "20m",
217
+ "on_complete_ref": None,
218
+ },
219
+ ],
220
+ },
221
+ {
222
+ "id": "full-lifecycle",
223
+ "name": "전체 라이프사이클",
224
+ "name_en": "Full Lifecycle",
225
+ "description": "기획 -> 개발 -> 검증 -> 정리 의 전체 개발 사이클을 자동화하는 4-파이프라인 세트",
226
+ "description_en": "4-pipeline set: plan -> develop -> verify -> cleanup full cycle",
227
+ "icon": "layers",
228
+ "builtin": True,
229
+ "pipelines": [
230
+ {
231
+ "ref": "plan",
232
+ "name": "기획/분석",
233
+ "command": (
234
+ "프로젝트를 분석하고 다음 작업을 기획해.\n\n"
235
+ "1. 프로젝트 구조와 최근 변경사항(git log -10) 파악\n"
236
+ "2. README, PLAN, TODO 등 기획 문서 확인\n"
237
+ "3. 현재 가장 필요한 개선사항 3가지를 우선순위별로 정리\n"
238
+ "4. 1순위 항목의 구현 계획을 구체적으로 작성 (파일명, 변경 내용)\n\n"
239
+ "결과는 PLAN.md나 TODO에 반영. 실제 코드 변경은 하지 말 것."
240
+ ),
241
+ "interval": "10m",
242
+ "on_complete_ref": "develop",
243
+ },
244
+ {
245
+ "ref": "develop",
246
+ "name": "개발",
247
+ "command": (
248
+ "기획된 항목 중 1개를 구현해.\n\n"
249
+ "1. PLAN.md, TODO 등에서 가장 우선순위 높은 미완료 항목 확인\n"
250
+ "2. 해당 항목의 관련 코드를 모두 읽고 분석\n"
251
+ "3. 최소 범위로 구현\n"
252
+ "4. 구현 후 해당 항목 상태를 업데이트\n\n"
253
+ "규칙: 한 번에 1개만. 범위 확장 금지."
254
+ ),
255
+ "interval": "5m",
256
+ "on_complete_ref": "verify",
257
+ },
258
+ {
259
+ "ref": "verify",
260
+ "name": "검증",
261
+ "command": (
262
+ "최근 변경사항을 검증해.\n\n"
263
+ "1. git diff HEAD~1 으로 최근 변경 내용 확인\n"
264
+ "2. 변경된 코드에 대해:\n"
265
+ " - 문법 오류 확인\n"
266
+ " - 엣지 케이스 검토\n"
267
+ " - 기존 기능과의 호환성 확인\n"
268
+ "3. 테스트가 있으면 실행\n"
269
+ "4. 문제 발견 시 즉시 수정\n\n"
270
+ "규칙: 검증 범위를 최근 변경으로 한정. 무관한 코드 수정 금지."
271
+ ),
272
+ "interval": "5m",
273
+ "on_complete_ref": "cleanup",
274
+ },
275
+ {
276
+ "ref": "cleanup",
277
+ "name": "정리",
278
+ "command": (
279
+ "코드와 프로젝트를 정리해.\n\n"
280
+ "1. dead code 검출 (사용되지 않는 import, 함수, 변수)\n"
281
+ "2. 불필요한 console.log, print, 디버그 코드 제거\n"
282
+ "3. 코드 포매팅 일관성 확인\n"
283
+ "4. 발견된 것 중 1건 정리\n\n"
284
+ "규칙: 한 번에 1건만. 주석은 의미 있는 것만 남길 것."
285
+ ),
286
+ "interval": "10m",
287
+ "on_complete_ref": None,
288
+ },
289
+ ],
290
+ },
291
+ ]
292
+
293
+
294
+ # ==================================================================
295
+ # 유틸리티
296
+ # ==================================================================
297
+
298
+ def _load_custom_presets() -> list[dict]:
299
+ try:
300
+ if PRESETS_FILE.exists():
301
+ return json.loads(PRESETS_FILE.read_text("utf-8"))
302
+ except (json.JSONDecodeError, OSError):
303
+ pass
304
+ return []
305
+
306
+
307
+ def _save_custom_presets(presets: list[dict]):
308
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
309
+ PRESETS_FILE.write_text(
310
+ json.dumps(presets, ensure_ascii=False, indent=2), "utf-8"
311
+ )
312
+
313
+
314
+ # ==================================================================
315
+ # CRUD
316
+ # ==================================================================
317
+
318
+ def list_presets() -> list[dict]:
319
+ """내장 + 사용자 정의 프리셋 모두 반환 (파이프라인 상세는 제외한 요약)."""
320
+ result = []
321
+ for p in BUILTIN_PRESETS:
322
+ result.append({
323
+ "id": p["id"],
324
+ "name": p["name"],
325
+ "name_en": p.get("name_en", ""),
326
+ "description": p["description"],
327
+ "description_en": p.get("description_en", ""),
328
+ "icon": p.get("icon", "layers"),
329
+ "builtin": True,
330
+ "pipeline_count": len(p["pipelines"]),
331
+ "pipeline_names": [pp["name"] for pp in p["pipelines"]],
332
+ })
333
+ for p in _load_custom_presets():
334
+ result.append({
335
+ "id": p["id"],
336
+ "name": p["name"],
337
+ "name_en": p.get("name_en", ""),
338
+ "description": p["description"],
339
+ "description_en": p.get("description_en", ""),
340
+ "icon": p.get("icon", "layers"),
341
+ "builtin": False,
342
+ "pipeline_count": len(p.get("pipelines", [])),
343
+ "pipeline_names": [pp["name"] for pp in p.get("pipelines", [])],
344
+ })
345
+ return result
346
+
347
+
348
+ def get_preset(preset_id: str) -> tuple[dict | None, str | None]:
349
+ """프리셋 상세 조회 (파이프라인 템플릿 포함)."""
350
+ for p in BUILTIN_PRESETS:
351
+ if p["id"] == preset_id:
352
+ return p, None
353
+ for p in _load_custom_presets():
354
+ if p["id"] == preset_id:
355
+ return p, None
356
+ return None, "프리셋을 찾을 수 없습니다"
357
+
358
+
359
+ def apply_preset(
360
+ preset_id: str,
361
+ project_path: str,
362
+ overrides: dict | None = None,
363
+ ) -> tuple[dict | None, str | None]:
364
+ """프리셋을 프로젝트에 적용 -- 파이프라인 세트를 생성한다.
365
+
366
+ Args:
367
+ preset_id: 적용할 프리셋 ID
368
+ project_path: 대상 프로젝트 경로
369
+ overrides: 파이프라인별 오버라이드 (선택)
370
+ 예: {"issue-fix": {"interval": "10m"}, "quality": {"command": "..."}}
371
+
372
+ Returns:
373
+ (결과 dict, 에러 문자열)
374
+ """
375
+ preset, err = get_preset(preset_id)
376
+ if err:
377
+ return None, err
378
+
379
+ project_path = os.path.abspath(os.path.expanduser(project_path))
380
+ if not os.path.isdir(project_path):
381
+ return None, f"디렉토리가 존재하지 않습니다: {project_path}"
382
+
383
+ overrides = overrides or {}
384
+ created_pipes = []
385
+ ref_to_id = {} # ref -> 생성된 pipeline ID (체이닝용)
386
+
387
+ # 1단계: 파이프라인 생성 (on_complete 없이)
388
+ for tmpl in preset["pipelines"]:
389
+ ref = tmpl["ref"]
390
+ ovr = overrides.get(ref, {})
391
+
392
+ command = ovr.get("command", tmpl["command"])
393
+ interval = ovr.get("interval", tmpl.get("interval", ""))
394
+ name = ovr.get("name", tmpl["name"])
395
+
396
+ pipe, pipe_err = _pipeline_mod.create_pipeline(
397
+ project_path=project_path,
398
+ command=command,
399
+ interval=interval,
400
+ name=name,
401
+ )
402
+ if pipe_err:
403
+ return None, f"파이프라인 '{name}' 생성 실패: {pipe_err}"
404
+
405
+ created_pipes.append(pipe)
406
+ ref_to_id[ref] = pipe["id"]
407
+
408
+ # 2단계: 체이닝 연결 (on_complete_ref -> 실제 ID)
409
+ for tmpl, pipe in zip(preset["pipelines"], created_pipes):
410
+ target_ref = tmpl.get("on_complete_ref")
411
+ if target_ref and target_ref in ref_to_id:
412
+ _pipeline_mod.update_pipeline(
413
+ pipe["id"],
414
+ on_complete=ref_to_id[target_ref],
415
+ )
416
+
417
+ return {
418
+ "preset_id": preset_id,
419
+ "preset_name": preset["name"],
420
+ "project_path": project_path,
421
+ "pipelines_created": len(created_pipes),
422
+ "pipelines": [
423
+ {"id": p["id"], "name": p["name"], "interval": p.get("interval")}
424
+ for p in created_pipes
425
+ ],
426
+ }, None
427
+
428
+
429
+ def save_as_preset(
430
+ name: str,
431
+ description: str = "",
432
+ pipeline_ids: list[str] | None = None,
433
+ ) -> tuple[dict | None, str | None]:
434
+ """현재 활성 파이프라인들을 사용자 정의 프리셋으로 저장한다.
435
+
436
+ Args:
437
+ name: 프리셋 이름
438
+ description: 설명
439
+ pipeline_ids: 저장할 파이프라인 ID 목록 (None이면 전체)
440
+ """
441
+ all_pipes = _pipeline_mod.list_pipelines()
442
+ if pipeline_ids:
443
+ pipes = [p for p in all_pipes if p["id"] in pipeline_ids]
444
+ else:
445
+ pipes = all_pipes
446
+
447
+ if not pipes:
448
+ return None, "저장할 파이프라인이 없습니다"
449
+
450
+ # ID -> ref 매핑 생성
451
+ id_to_ref = {}
452
+ templates = []
453
+ for i, p in enumerate(pipes):
454
+ ref = f"p{i}"
455
+ id_to_ref[p["id"]] = ref
456
+ templates.append({
457
+ "ref": ref,
458
+ "name": p["name"],
459
+ "command": p["command"],
460
+ "interval": p.get("interval") or "",
461
+ "on_complete_ref": None, # 아래에서 연결
462
+ })
463
+
464
+ # 체이닝 복원
465
+ for p, tmpl in zip(pipes, templates):
466
+ chain_id = p.get("on_complete")
467
+ if chain_id and chain_id in id_to_ref:
468
+ tmpl["on_complete_ref"] = id_to_ref[chain_id]
469
+
470
+ preset_id = f"custom-{int(time.time())}"
471
+ new_preset = {
472
+ "id": preset_id,
473
+ "name": name,
474
+ "name_en": "",
475
+ "description": description,
476
+ "description_en": "",
477
+ "icon": "bookmark",
478
+ "builtin": False,
479
+ "pipelines": templates,
480
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
481
+ }
482
+
483
+ customs = _load_custom_presets()
484
+ customs.append(new_preset)
485
+ _save_custom_presets(customs)
486
+
487
+ return {
488
+ "id": preset_id,
489
+ "name": name,
490
+ "pipeline_count": len(templates),
491
+ }, None
492
+
493
+
494
+ def delete_preset(preset_id: str) -> tuple[dict | None, str | None]:
495
+ """사용자 정의 프리셋 삭제. 내장 프리셋은 삭제 불가."""
496
+ for p in BUILTIN_PRESETS:
497
+ if p["id"] == preset_id:
498
+ return None, "내장 프리셋은 삭제할 수 없습니다"
499
+
500
+ customs = _load_custom_presets()
501
+ for i, p in enumerate(customs):
502
+ if p["id"] == preset_id:
503
+ removed = customs.pop(i)
504
+ _save_custom_presets(customs)
505
+ return removed, None
506
+ return None, "프리셋을 찾을 수 없습니다"
package/web/projects.py CHANGED
@@ -12,7 +12,8 @@ import subprocess
12
12
  import time
13
13
  from pathlib import Path
14
14
 
15
- from config import DATA_DIR
15
+ from config import DATA_DIR, LOGS_DIR
16
+ from utils import parse_meta_file
16
17
 
17
18
  PROJECTS_FILE = DATA_DIR / "projects.json"
18
19
 
@@ -39,6 +40,40 @@ def _generate_id() -> str:
39
40
  return f"{int(time.time())}-{os.getpid()}-{id(time) % 10000}"
40
41
 
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
+
42
77
  def _detect_git_info(path: str) -> dict:
43
78
  """디렉토리의 git 정보를 감지한다."""
44
79
  info = {"is_git": False, "branch": "", "remote": ""}
@@ -71,12 +106,31 @@ def _detect_git_info(path: str) -> dict:
71
106
  # CRUD
72
107
  # ══════════════════════════════════════════════════════════════
73
108
 
74
- def list_projects() -> list[dict]:
75
- """등록된 프로젝트 목록을 반환한다."""
109
+ def list_projects(include_job_stats=True) -> list[dict]:
110
+ """등록된 프로젝트 목록을 반환한다.
111
+
112
+ Args:
113
+ include_job_stats: True이면 각 프로젝트의 job 통계를 포함한다.
114
+ """
76
115
  projects = _load_projects()
77
- # 경로 유효성 보강
116
+
117
+ # job 통계를 위한 cwd→count 맵 (한 번만 순회)
118
+ job_stats = {}
119
+ if include_job_stats:
120
+ job_stats = _collect_job_stats_by_cwd()
121
+
78
122
  for p in projects:
79
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
+ }
80
134
  return projects
81
135
 
82
136