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.
Files changed (71) hide show
  1. package/README.md +2 -2
  2. package/bin/autoloop.sh +382 -0
  3. package/bin/ctl +1189 -0
  4. package/bin/native-app.py +6 -3
  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 +11 -5
  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 +634 -473
  38. package/web/handler_fs.py +153 -0
  39. package/web/handler_goals.py +203 -0
  40. package/web/handler_jobs.py +372 -0
  41. package/web/handler_memory.py +203 -0
  42. package/web/handler_sessions.py +132 -0
  43. package/web/jobs.py +585 -13
  44. package/web/personas.py +419 -0
  45. package/web/pipeline.py +981 -0
  46. package/web/presets.py +506 -0
  47. package/web/projects.py +246 -0
  48. package/web/static/api.js +141 -0
  49. package/web/static/app.js +25 -1937
  50. package/web/static/attachments.js +144 -0
  51. package/web/static/base.css +497 -0
  52. package/web/static/context.js +204 -0
  53. package/web/static/dirs.js +246 -0
  54. package/web/static/form.css +763 -0
  55. package/web/static/goals.css +363 -0
  56. package/web/static/goals.js +300 -0
  57. package/web/static/i18n.js +625 -0
  58. package/web/static/index.html +215 -13
  59. package/web/static/{styles.css → jobs.css} +746 -1141
  60. package/web/static/jobs.js +1270 -0
  61. package/web/static/memoryview.js +117 -0
  62. package/web/static/personas.js +228 -0
  63. package/web/static/pipeline.css +338 -0
  64. package/web/static/pipelines.js +487 -0
  65. package/web/static/presets.js +244 -0
  66. package/web/static/send.js +135 -0
  67. package/web/static/settings-style.css +291 -0
  68. package/web/static/settings.js +81 -0
  69. package/web/static/stream.js +534 -0
  70. package/web/static/utils.js +131 -0
  71. package/web/webhook.py +210 -0
@@ -0,0 +1,487 @@
1
+ /* ═══════════════════════════════════════════════
2
+ Pipeline — on/off 자동화
3
+ 상태: active (ON) / stopped (OFF)
4
+ ═══════════════════════════════════════════════ */
5
+
6
+ let _pipelines = [];
7
+ let _pipelinePollTimer = null;
8
+ let _pipelineCountdownTimer = null;
9
+ const _POLL_FAST = 2000; // job 실행 중
10
+ const _POLL_NORMAL = 5000; // 대기 중
11
+
12
+ async function fetchPipelines() {
13
+ try {
14
+ _pipelines = await apiFetch('/api/pipelines');
15
+ renderPipelines();
16
+
17
+ const hasActive = _pipelines.some(p => p.status === 'active');
18
+ const hasRunning = _pipelines.some(p => p.status === 'active' && p.job_id);
19
+ const desiredInterval = hasRunning ? _POLL_FAST : _POLL_NORMAL;
20
+
21
+ if (hasActive) {
22
+ // 간격이 바뀌었으면 타이머 재설정
23
+ if (_pipelinePollTimer && _pipelinePollTimer._interval !== desiredInterval) {
24
+ clearInterval(_pipelinePollTimer);
25
+ _pipelinePollTimer = null;
26
+ }
27
+ if (!_pipelinePollTimer) {
28
+ _pipelinePollTimer = setInterval(_pipelinePollTick, desiredInterval);
29
+ _pipelinePollTimer._interval = desiredInterval;
30
+ }
31
+ } else if (_pipelinePollTimer) {
32
+ clearInterval(_pipelinePollTimer);
33
+ _pipelinePollTimer = null;
34
+ }
35
+
36
+ if (hasActive && !_pipelineCountdownTimer) {
37
+ _pipelineCountdownTimer = setInterval(_updateCountdowns, 1000);
38
+ } else if (!hasActive && _pipelineCountdownTimer) {
39
+ clearInterval(_pipelineCountdownTimer);
40
+ _pipelineCountdownTimer = null;
41
+ }
42
+ } catch { /* silent */ }
43
+ }
44
+
45
+ async function _pipelinePollTick() {
46
+ const hasActive = _pipelines.some(p => p.status === 'active');
47
+ if (hasActive) {
48
+ try {
49
+ const results = await apiFetch('/api/pipelines/tick-all', {
50
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
51
+ });
52
+ // 완료/에러/디스패치 감지 시 jobs 목록도 즉시 갱신
53
+ if (Array.isArray(results) && results.some(r => r.result && (r.result.action === 'completed' || r.result.action === 'error' || r.result.action === 'dispatched'))) {
54
+ fetchJobs();
55
+ }
56
+ } catch { /* silent */ }
57
+ }
58
+ fetchPipelines();
59
+ }
60
+
61
+ function _formatTimer(sec) {
62
+ const m = Math.floor(sec / 60);
63
+ const s = sec % 60;
64
+ return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
65
+ }
66
+
67
+ function _updateCountdowns() {
68
+ for (const p of _pipelines) {
69
+ if (p.status !== 'active') continue;
70
+ const el = document.querySelector(`[data-pipe-timer="${p.id}"]`);
71
+ if (!el) continue;
72
+
73
+ if (!p.next_run) {
74
+ el.textContent = _formatTimer(0);
75
+ // next_run 없고 job도 없으면 즉시 dispatch (프리셋 생성 직후 등)
76
+ if (!p._dispatching && !p.job_id) {
77
+ p._dispatching = true;
78
+ apiFetch(`/api/pipelines/${encodeURIComponent(p.id)}/run`, {
79
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
80
+ }).then(() => { fetchPipelines(); fetchJobs(); setTimeout(fetchJobs, 1000); })
81
+ .catch(() => {})
82
+ .finally(() => { p._dispatching = false; });
83
+ }
84
+ continue;
85
+ }
86
+
87
+ const remaining = Math.max(0, Math.floor((new Date(p.next_run).getTime() - Date.now()) / 1000));
88
+ el.textContent = _formatTimer(remaining);
89
+
90
+ // 타이머 0 → 이미 job_id가 있으면 skip (이중 발사 방지)
91
+ if (remaining <= 0 && !p._dispatching && !p.job_id) {
92
+ p._dispatching = true;
93
+ // 로컬에서 즉시 next_run 갱신 → 타이머 끊김 방지
94
+ if (p.interval_sec) {
95
+ p.next_run = new Date(Date.now() + p.interval_sec * 1000).toISOString().slice(0, 19);
96
+ }
97
+ apiFetch(`/api/pipelines/${encodeURIComponent(p.id)}/run`, {
98
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
99
+ }).then(() => { fetchPipelines(); fetchJobs(); setTimeout(fetchJobs, 1000); })
100
+ .catch(() => {})
101
+ .finally(() => { p._dispatching = false; });
102
+ }
103
+ }
104
+ }
105
+
106
+ function renderPipelines() {
107
+ const container = document.getElementById('pipelineList');
108
+ const countEl = document.getElementById('pipelineCount');
109
+ if (!container) return;
110
+
111
+ if (countEl) countEl.textContent = _pipelines.length > 0 ? `(${_pipelines.length})` : '';
112
+
113
+ if (_pipelines.length === 0) {
114
+ container.innerHTML = '<div class="empty-state" style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem;">자동화가 없습니다.</div>';
115
+ return;
116
+ }
117
+
118
+ container.innerHTML = _pipelines.map(p => {
119
+ const isOn = p.status === 'active';
120
+ const isRunning = isOn && p.job_id;
121
+
122
+ let timerHtml = '';
123
+ if (isOn) {
124
+ let remaining = 0;
125
+ if (p.next_run) {
126
+ remaining = Math.max(0, Math.floor((new Date(p.next_run).getTime() - Date.now()) / 1000));
127
+ }
128
+ timerHtml = `<span data-pipe-timer="${p.id}" style="font-family:var(--font-mono,monospace);font-size:0.72rem;color:var(--text-muted);">${_formatTimer(remaining)}</span>`;
129
+ }
130
+
131
+ const cmdPreview = (p.command || '').substring(0, 80) + ((p.command || '').length > 80 ? '...' : '');
132
+ const projectName = p.project_path ? p.project_path.split('/').filter(Boolean).pop() : '';
133
+ const projectPath = p.project_path || '';
134
+
135
+ // 적응형 인터벌 표시
136
+ const baseInterval = p.interval_sec;
137
+ const effectiveInterval = p.effective_interval_sec;
138
+ const isAdapted = baseInterval && effectiveInterval && baseInterval !== effectiveInterval;
139
+ let intervalLabel = '';
140
+ if (p.interval) {
141
+ if (isAdapted) {
142
+ const effMin = Math.round(effectiveInterval / 60);
143
+ const pct = Math.round((effectiveInterval - baseInterval) / baseInterval * 100);
144
+ intervalLabel = `<span style="font-size:0.65rem;padding:1px 5px;background:rgba(168,85,247,0.1);color:#a855f7;border-radius:3px;" title="기본: ${escapeHtml(p.interval)}, 적응: ${effMin}분 (${pct > 0 ? '+' : ''}${pct}%)">${effMin}분 적응</span>`;
145
+ } else {
146
+ intervalLabel = `<span style="font-size:0.65rem;padding:1px 5px;background:rgba(59,130,246,0.1);color:var(--primary);border-radius:3px;">${escapeHtml(p.interval)} 반복</span>`;
147
+ }
148
+ }
149
+
150
+ // 체이닝 표시
151
+ const chainLabel = p.on_complete ? `<span style="font-size:0.65rem;padding:1px 5px;background:rgba(34,197,94,0.1);color:#22c55e;border-radius:3px;" title="완료 시 트리거">→ chain</span>` : '';
152
+
153
+ const runningBadge = isRunning ? `<span class="pipe-running-badge"><span class="pipe-running-dot"></span>실행 중</span>` : '';
154
+ const runCount = p.run_count ? `<span style="font-size:0.65rem;color:var(--text-muted);">${p.run_count}회</span>` : '';
155
+
156
+ const toggleBtn = isOn
157
+ ? `<button class="btn btn-sm" onclick="stopPipeline('${p.id}')">OFF</button>`
158
+ : `<button class="btn btn-sm btn-primary" onclick="runPipeline('${p.id}')">ON</button>`;
159
+
160
+ return `<div class="pipeline-card${isRunning ? ' is-running' : ''}" data-pipe-id="${p.id}">
161
+ <div class="pipeline-card-header">
162
+ <div class="pipeline-card-title">${escapeHtml(p.name || p.id)}</div>
163
+ <div style="display:flex;gap:4px;align-items:center;">${runningBadge} ${intervalLabel} ${chainLabel} ${timerHtml}</div>
164
+ </div>
165
+ ${projectName ? `<div style="font-size:0.65rem;color:var(--text-muted);margin:2px 0 0 0;display:flex;align-items:center;gap:3px;" title="${escapeHtml(projectPath)}"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>${escapeHtml(projectName)}</div>` : ''}
166
+ <div class="pipeline-card-goal" style="font-size:0.75rem;color:var(--text-secondary);margin:4px 0;font-family:var(--font-mono,monospace);">${escapeHtml(cmdPreview)}</div>
167
+ ${p.last_error ? `<div style="font-size:0.7rem;color:var(--danger);margin:4px 0;padding:4px 8px;background:rgba(239,68,68,0.1);border-radius:4px;">${escapeHtml(p.last_error)}</div>` : ''}
168
+ <div style="display:flex;gap:8px;font-size:0.7rem;color:var(--text-muted);align-items:center;">
169
+ ${runCount}
170
+ <span>${p.last_run || ''}</span>
171
+ </div>
172
+ <div class="pipeline-card-actions" style="margin-top:8px;display:flex;gap:6px;">
173
+ ${toggleBtn}
174
+ <button class="btn btn-sm" onclick="editPipeline('${p.id}')">수정</button>
175
+ ${p.run_count ? `<button class="btn btn-sm" onclick="showPipelineHistory('${p.id}')">이력</button>` : ''}
176
+ <button class="btn btn-sm btn-danger" onclick="deletePipeline('${p.id}')">삭제</button>
177
+ </div>
178
+ </div>`;
179
+ }).join('');
180
+ }
181
+
182
+ async function runPipeline(pipeId) {
183
+ try {
184
+ const data = await apiFetch(`/api/pipelines/${encodeURIComponent(pipeId)}/run`, {
185
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
186
+ body: JSON.stringify({}),
187
+ });
188
+ showToast(`${data.name || ''}: ON`);
189
+ fetchPipelines();
190
+ fetchJobs();
191
+ setTimeout(fetchJobs, 1000);
192
+ } catch (err) {
193
+ showToast(err.message || '실행 실패', 'error');
194
+ }
195
+ }
196
+
197
+ async function stopPipeline(pipeId) {
198
+ try {
199
+ await apiFetch(`/api/pipelines/${encodeURIComponent(pipeId)}/stop`, {
200
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}',
201
+ });
202
+ showToast('자동화 OFF');
203
+ fetchPipelines();
204
+ } catch (err) {
205
+ showToast(err.message || 'OFF 실패', 'error');
206
+ }
207
+ }
208
+
209
+ async function editPipeline(pipeId) {
210
+ try {
211
+ const data = await apiFetch(`/api/pipelines/${encodeURIComponent(pipeId)}/status`);
212
+ const isOn = data.status === 'active';
213
+ const statusBadge = isOn
214
+ ? '<span class="badge" style="background:var(--primary)20;color:var(--primary);">ON</span>'
215
+ : '<span class="badge" style="background:var(--text-muted)20;color:var(--text-muted);">OFF</span>';
216
+
217
+ const editId = 'pipeEdit_' + Date.now();
218
+ const overlay = document.createElement('div');
219
+ overlay.className = 'settings-overlay';
220
+ overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;';
221
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
222
+ overlay.innerHTML = `<div class="settings-panel" style="max-width:520px;margin:0;">
223
+ <div class="settings-header">
224
+ <div class="settings-title" style="display:flex;align-items:center;gap:8px;">
225
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
226
+ <span>자동화 수정</span>
227
+ ${statusBadge}
228
+ </div>
229
+ <button class="settings-close" onclick="this.closest('.settings-overlay').remove()">
230
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
231
+ </button>
232
+ </div>
233
+ <div class="settings-body">
234
+ <div style="margin-bottom:12px;">
235
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">이름</label>
236
+ <input id="${editId}_name" type="text" value="${escapeHtml(data.name || '')}"
237
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;box-sizing:border-box;">
238
+ </div>
239
+ <div style="margin-bottom:12px;">
240
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">명령어 (프롬프트)</label>
241
+ <textarea id="${editId}_cmd" rows="3"
242
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;font-family:var(--font-mono,monospace);resize:vertical;box-sizing:border-box;">${escapeHtml(data.command || '')}</textarea>
243
+ </div>
244
+ <div style="margin-bottom:12px;">
245
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">반복 간격 <span style="font-weight:400;color:var(--text-muted);">(예: 30s, 5m, 1h / 비우면 1회)</span></label>
246
+ <input id="${editId}_interval" type="text" value="${escapeHtml(data.interval || '')}" placeholder="예: 1m"
247
+ style="width:120px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;">
248
+ </div>
249
+ <div style="font-size:0.7rem;color:var(--text-muted);display:flex;gap:12px;">
250
+ <span>경로: <code>${escapeHtml(data.project_path)}</code></span>
251
+ ${data.run_count ? `<span>${data.run_count}회 실행</span>` : ''}
252
+ </div>
253
+ </div>
254
+ <div class="settings-footer" style="display:flex;gap:6px;">
255
+ <button class="btn btn-sm btn-primary" onclick="_savePipelineEdit('${data.id}','${editId}',this)">저장</button>
256
+ ${isOn
257
+ ? `<button class="btn btn-sm" onclick="stopPipeline('${data.id}');this.closest('.settings-overlay').remove();">OFF</button>`
258
+ : `<button class="btn btn-sm" onclick="runPipeline('${data.id}');this.closest('.settings-overlay').remove();">ON</button>`}
259
+ <button class="btn btn-sm" onclick="this.closest('.settings-overlay').remove()">닫기</button>
260
+ </div>
261
+ </div>`;
262
+ document.body.appendChild(overlay);
263
+ } catch (err) {
264
+ showToast(err.message || '조회 실패', 'error');
265
+ }
266
+ }
267
+
268
+ async function _savePipelineEdit(pipeId, editId, btn) {
269
+ const name = document.getElementById(editId + '_name').value.trim();
270
+ const command = document.getElementById(editId + '_cmd').value.trim();
271
+ const interval = document.getElementById(editId + '_interval').value.trim();
272
+ if (!command) { showToast('명령어를 입력하세요', 'error'); return; }
273
+
274
+ btn.disabled = true;
275
+ btn.textContent = '저장 중...';
276
+ try {
277
+ await apiFetch(`/api/pipelines/${encodeURIComponent(pipeId)}/update`, {
278
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
279
+ body: JSON.stringify({ name, command, interval }),
280
+ });
281
+ showToast('자동화 수정 완료');
282
+ btn.closest('.settings-overlay').remove();
283
+ fetchPipelines();
284
+ } catch (err) {
285
+ showToast(err.message || '수정 실패', 'error');
286
+ btn.disabled = false;
287
+ btn.textContent = '저장';
288
+ }
289
+ }
290
+
291
+ async function deletePipeline(pipeId) {
292
+ if (!confirm('이 자동화를 삭제하시겠습니까?')) return;
293
+ try {
294
+ await apiFetch(`/api/pipelines/${encodeURIComponent(pipeId)}`, { method: 'DELETE' });
295
+ showToast('자동화 삭제됨');
296
+ fetchPipelines();
297
+ } catch (err) {
298
+ showToast(err.message || '삭제 실패', 'error');
299
+ }
300
+ }
301
+
302
+ /* ── 진화 요약 패널 ── */
303
+ async function fetchEvolutionSummary() {
304
+ const el = document.getElementById('evolutionSummary');
305
+ if (!el) return;
306
+ try {
307
+ const data = await apiFetch('/api/pipelines/evolution');
308
+ const cls = data.classifications || {};
309
+ const total = (cls.has_change || 0) + (cls.no_change || 0) + (cls.unknown || 0);
310
+ const adaptations = (data.interval_adaptations || []);
311
+
312
+ let adaptHtml = '';
313
+ if (adaptations.length > 0) {
314
+ adaptHtml = adaptations.map(a =>
315
+ `<span style="font-size:0.65rem;color:#a855f7;">${escapeHtml(a.name)}: ${a.change_pct > 0 ? '+' : ''}${a.change_pct}%</span>`
316
+ ).join(' ');
317
+ }
318
+
319
+ el.innerHTML = `<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;font-size:0.7rem;color:var(--text-muted);">
320
+ <span>총 ${data.total_runs}회 실행</span>
321
+ <span>효율 ${data.efficiency_pct}%</span>
322
+ ${total > 0 ? `<span style="color:var(--text-secondary);">변경:${cls.has_change||0} / 무변경:${cls.no_change||0}</span>` : ''}
323
+ ${adaptHtml}
324
+ </div>`;
325
+ } catch { el.innerHTML = ''; }
326
+ }
327
+
328
+ /* ── 새 자동화 생성 모달 ── */
329
+ function openCreatePipeline() {
330
+ const cwd = document.getElementById('cwdInput')?.value?.trim() || '';
331
+ const formId = 'pipeCreate_' + Date.now();
332
+ const overlay = document.createElement('div');
333
+ overlay.className = 'settings-overlay';
334
+ overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;';
335
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
336
+ overlay.innerHTML = `<div class="settings-panel" style="max-width:520px;margin:0;">
337
+ <div class="settings-header">
338
+ <div class="settings-title" style="display:flex;align-items:center;gap:8px;">
339
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
340
+ <span>새 자동화</span>
341
+ </div>
342
+ <button class="settings-close" onclick="this.closest('.settings-overlay').remove()">
343
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
344
+ </button>
345
+ </div>
346
+ <div class="settings-body">
347
+ <div style="margin-bottom:12px;">
348
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">이름</label>
349
+ <input id="${formId}_name" type="text" placeholder="예: code-quality"
350
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;box-sizing:border-box;">
351
+ </div>
352
+ <div style="margin-bottom:12px;">
353
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">프로젝트 경로</label>
354
+ <input id="${formId}_path" type="text" value="${escapeHtml(cwd)}"
355
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;font-family:var(--font-mono,monospace);box-sizing:border-box;">
356
+ </div>
357
+ <div style="margin-bottom:12px;">
358
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">명령어 (프롬프트)</label>
359
+ <textarea id="${formId}_cmd" rows="4" placeholder="Claude에게 시킬 작업을 입력하세요"
360
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;font-family:var(--font-mono,monospace);resize:vertical;box-sizing:border-box;"></textarea>
361
+ </div>
362
+ <div style="display:flex;gap:12px;margin-bottom:12px;">
363
+ <div style="flex:1;">
364
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">반복 간격 <span style="font-weight:400;color:var(--text-muted);">(비우면 1회)</span></label>
365
+ <input id="${formId}_interval" type="text" placeholder="예: 5m, 1h"
366
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;box-sizing:border-box;">
367
+ </div>
368
+ <div style="flex:1;">
369
+ <label style="font-size:0.72rem;font-weight:600;color:var(--text-secondary);display:block;margin-bottom:4px;">체이닝 <span style="font-weight:400;color:var(--text-muted);">(완료 시 트리거)</span></label>
370
+ <select id="${formId}_chain"
371
+ style="width:100%;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg-secondary);color:var(--text-primary);font-size:0.8rem;box-sizing:border-box;">
372
+ <option value="">없음</option>
373
+ ${_pipelines.map(p => `<option value="${escapeHtml(p.id)}">${escapeHtml(p.name || p.id)}</option>`).join('')}
374
+ </select>
375
+ </div>
376
+ </div>
377
+ </div>
378
+ <div class="settings-footer" style="display:flex;gap:6px;">
379
+ <button class="btn btn-sm btn-primary" onclick="_submitCreatePipeline('${formId}',this)">생성 및 실행</button>
380
+ <button class="btn btn-sm" onclick="this.closest('.settings-overlay').remove()">취소</button>
381
+ </div>
382
+ </div>`;
383
+ document.body.appendChild(overlay);
384
+ // 포커스
385
+ setTimeout(() => document.getElementById(formId + '_name')?.focus(), 100);
386
+ }
387
+
388
+ async function _submitCreatePipeline(formId, btn) {
389
+ const name = document.getElementById(formId + '_name').value.trim();
390
+ const project_path = document.getElementById(formId + '_path').value.trim();
391
+ const command = document.getElementById(formId + '_cmd').value.trim();
392
+ const interval = document.getElementById(formId + '_interval').value.trim();
393
+ const on_complete = document.getElementById(formId + '_chain').value;
394
+
395
+ if (!project_path) { showToast('프로젝트 경로를 입력하세요', 'error'); return; }
396
+ if (!command) { showToast('명령어를 입력하세요', 'error'); return; }
397
+
398
+ btn.disabled = true;
399
+ btn.textContent = '생성 중...';
400
+ try {
401
+ await apiFetch('/api/pipelines', {
402
+ method: 'POST',
403
+ headers: { 'Content-Type': 'application/json' },
404
+ body: JSON.stringify({ name, project_path, command, interval, on_complete }),
405
+ });
406
+ showToast('자동화 생성 완료');
407
+ btn.closest('.settings-overlay').remove();
408
+ fetchPipelines();
409
+ fetchJobs();
410
+ } catch (err) {
411
+ showToast(err.message || '생성 실패', 'error');
412
+ btn.disabled = false;
413
+ btn.textContent = '생성 및 실행';
414
+ }
415
+ }
416
+
417
+ /* ── 실행 이력 모달 ── */
418
+ async function showPipelineHistory(pipeId) {
419
+ try {
420
+ const data = await apiFetch(`/api/pipelines/${encodeURIComponent(pipeId)}/history`);
421
+ const overlay = document.createElement('div');
422
+ overlay.className = 'settings-overlay';
423
+ overlay.style.cssText = 'display:flex;align-items:center;justify-content:center;';
424
+ overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
425
+
426
+ const entries = data.entries || [];
427
+ let tableHtml = '';
428
+ if (entries.length === 0) {
429
+ tableHtml = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem;">실행 이력이 없습니다.</div>';
430
+ } else {
431
+ const rows = entries.map((h, i) => {
432
+ const cls = h.classification || 'unknown';
433
+ const clsBadge = cls === 'has_change'
434
+ ? '<span class="pipe-hist-badge pipe-hist-change">변경</span>'
435
+ : cls === 'no_change'
436
+ ? '<span class="pipe-hist-badge pipe-hist-nochange">무변경</span>'
437
+ : '<span class="pipe-hist-badge pipe-hist-unknown">?</span>';
438
+ const dur = h.duration_ms ? `${(h.duration_ms / 1000).toFixed(1)}s` : '-';
439
+ const time = h.completed_at || '';
440
+ const resultPreview = escapeHtml((h.result || '').substring(0, 120));
441
+ return `<tr>
442
+ <td style="white-space:nowrap;">${time}</td>
443
+ <td>${clsBadge}</td>
444
+ <td>${dur}</td>
445
+ <td class="pipe-hist-result" title="${escapeHtml(h.result || '')}">${resultPreview}${(h.result || '').length > 120 ? '...' : ''}</td>
446
+ </tr>`;
447
+ }).join('');
448
+ tableHtml = `<div class="pipe-hist-table-wrap"><table class="pipe-hist-table">
449
+ <thead><tr><th>시간</th><th>분류</th><th>소요</th><th>결과 요약</th></tr></thead>
450
+ <tbody>${rows}</tbody>
451
+ </table></div>`;
452
+ }
453
+
454
+ const summaryHtml = `<div style="display:flex;gap:12px;font-size:0.72rem;color:var(--text-muted);margin-bottom:12px;">
455
+ <span>총 ${data.run_count}회 실행</span>
456
+ </div>`;
457
+
458
+ overlay.innerHTML = `<div class="settings-panel" style="max-width:700px;margin:0;">
459
+ <div class="settings-header">
460
+ <div class="settings-title" style="display:flex;align-items:center;gap:8px;">
461
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
462
+ <span>${escapeHtml(data.name || pipeId)} — 실행 이력</span>
463
+ </div>
464
+ <button class="settings-close" onclick="this.closest('.settings-overlay').remove()">
465
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
466
+ </button>
467
+ </div>
468
+ <div class="settings-body" style="padding:12px 16px;">
469
+ ${summaryHtml}
470
+ ${tableHtml}
471
+ </div>
472
+ <div class="settings-footer">
473
+ <button class="btn btn-sm" onclick="this.closest('.settings-overlay').remove()">닫기</button>
474
+ </div>
475
+ </div>`;
476
+ document.body.appendChild(overlay);
477
+ } catch (err) {
478
+ showToast(err.message || '이력 조회 실패', 'error');
479
+ }
480
+ }
481
+
482
+ // fetchPipelines 후 진화 요약도 갱신
483
+ const _origFetchPipelines = fetchPipelines;
484
+ fetchPipelines = async function() {
485
+ await _origFetchPipelines();
486
+ fetchEvolutionSummary();
487
+ };