claude-controller 0.1.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/LICENSE +21 -0
- package/README.md +216 -0
- package/bin/app-launcher.sh +22 -0
- package/bin/claude-sh +19 -0
- package/bin/controller +37 -0
- package/bin/native-app.py +102 -0
- package/bin/send +185 -0
- package/bin/start +75 -0
- package/config.sh +74 -0
- package/lib/checkpoint.sh +237 -0
- package/lib/executor.sh +183 -0
- package/lib/jobs.sh +333 -0
- package/lib/session.sh +78 -0
- package/lib/worktree.sh +122 -0
- package/package.json +61 -0
- package/postinstall.sh +30 -0
- package/service/controller.sh +503 -0
- package/web/auth.py +46 -0
- package/web/checkpoint.py +175 -0
- package/web/config.py +65 -0
- package/web/handler.py +780 -0
- package/web/jobs.py +228 -0
- package/web/server.py +16 -0
- package/web/static/app.js +2013 -0
- package/web/static/index.html +219 -0
- package/web/static/styles.css +1942 -0
- package/web/utils.py +109 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ============================================================
|
|
3
|
+
# Controller Service Daemon
|
|
4
|
+
# FIFO 파이프에서 JSON 메시지를 수신하여 claude -p 를 디스패치하는
|
|
5
|
+
# 상주(persistent) 서비스 데몬입니다.
|
|
6
|
+
# ============================================================
|
|
7
|
+
set -uo pipefail
|
|
8
|
+
|
|
9
|
+
# ── 의존성 로드 ──────────────────────────────────────────────
|
|
10
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
|
+
source "${SCRIPT_DIR}/../config.sh"
|
|
12
|
+
source "${SCRIPT_DIR}/../lib/jobs.sh"
|
|
13
|
+
source "${SCRIPT_DIR}/../lib/session.sh"
|
|
14
|
+
source "${SCRIPT_DIR}/../lib/executor.sh"
|
|
15
|
+
source "${SCRIPT_DIR}/../lib/worktree.sh"
|
|
16
|
+
source "${SCRIPT_DIR}/../lib/checkpoint.sh"
|
|
17
|
+
|
|
18
|
+
# ── 서비스 로그 ──────────────────────────────────────────────
|
|
19
|
+
SERVICE_LOG="${LOGS_DIR}/service.log"
|
|
20
|
+
|
|
21
|
+
_log() {
|
|
22
|
+
local level="$1"
|
|
23
|
+
shift
|
|
24
|
+
local ts
|
|
25
|
+
ts=$(date '+%Y-%m-%d %H:%M:%S')
|
|
26
|
+
echo "[${ts}] [${level}] $*" >> "$SERVICE_LOG"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
_log_info() { _log "INFO" "$@"; }
|
|
30
|
+
_log_warn() { _log "WARN" "$@"; }
|
|
31
|
+
_log_error() { _log "ERROR" "$@"; }
|
|
32
|
+
|
|
33
|
+
# ── 배너 출력 ────────────────────────────────────────────────
|
|
34
|
+
_print_banner() {
|
|
35
|
+
cat <<EOF
|
|
36
|
+
============================================================
|
|
37
|
+
Controller Service Daemon
|
|
38
|
+
============================================================
|
|
39
|
+
FIFO : ${FIFO_PATH}
|
|
40
|
+
로그 : ${SERVICE_LOG}
|
|
41
|
+
최대 작업 : ${MAX_BACKGROUND_JOBS}
|
|
42
|
+
관리 기준 : Session ID
|
|
43
|
+
------------------------------------------------------------
|
|
44
|
+
[대기 중] FIFO 파이프에서 메시지를 수신합니다...
|
|
45
|
+
============================================================
|
|
46
|
+
EOF
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# ── 정리 (cleanup) ───────────────────────────────────────────
|
|
50
|
+
cleanup() {
|
|
51
|
+
_log_info "서비스 종료 시작 (PID: $$)"
|
|
52
|
+
|
|
53
|
+
# FIFO 제거
|
|
54
|
+
if [[ -p "$FIFO_PATH" ]]; then
|
|
55
|
+
rm -f "$FIFO_PATH"
|
|
56
|
+
_log_info "FIFO 파이프 제거됨: ${FIFO_PATH}"
|
|
57
|
+
fi
|
|
58
|
+
|
|
59
|
+
# PID 파일 제거
|
|
60
|
+
if [[ -f "$PID_FILE" ]]; then
|
|
61
|
+
rm -f "$PID_FILE"
|
|
62
|
+
_log_info "PID 파일 제거됨: ${PID_FILE}"
|
|
63
|
+
fi
|
|
64
|
+
|
|
65
|
+
# 실행 중인 백그라운드 작업 대기 (최대 5초)
|
|
66
|
+
local remaining
|
|
67
|
+
remaining=$(jobs -rp 2>/dev/null | wc -l | tr -d ' ')
|
|
68
|
+
if [[ "$remaining" -gt 0 ]]; then
|
|
69
|
+
_log_info "백그라운드 작업 ${remaining}개 종료 대기 중..."
|
|
70
|
+
wait 2>/dev/null || true
|
|
71
|
+
fi
|
|
72
|
+
|
|
73
|
+
_log_info "서비스 정상 종료됨"
|
|
74
|
+
echo ""
|
|
75
|
+
echo " [종료] 서비스가 정상적으로 종료되었습니다."
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# ── 시그널 핸들러 ────────────────────────────────────────────
|
|
79
|
+
_on_signal() {
|
|
80
|
+
_log_warn "시그널 수신 — 서비스를 종료합니다."
|
|
81
|
+
echo ""
|
|
82
|
+
echo " [시그널] 종료 시그널 수신. 정리 중..."
|
|
83
|
+
cleanup
|
|
84
|
+
exit 0
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
trap _on_signal SIGTERM SIGINT SIGHUP
|
|
88
|
+
|
|
89
|
+
# ── 중복 디스패치 방지 ────────────────────────────────────────
|
|
90
|
+
# 최근 디스패치된 프롬프트 해시와 타임스탬프를 기록
|
|
91
|
+
_LAST_DISPATCH_HASH=""
|
|
92
|
+
_LAST_DISPATCH_TIME=0
|
|
93
|
+
_DEDUP_WINDOW_SEC=3 # 동일 프롬프트를 무시하는 시간 창(초)
|
|
94
|
+
|
|
95
|
+
_prompt_hash() {
|
|
96
|
+
printf '%s' "$1" | md5 2>/dev/null || printf '%s' "$1" | md5sum 2>/dev/null | cut -d' ' -f1
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
# ── dispatch_job: JSON 메시지 파싱 후 claude -p 실행 ────────
|
|
100
|
+
dispatch_job() {
|
|
101
|
+
local json_line="$1"
|
|
102
|
+
|
|
103
|
+
# JSON 유효성 검사
|
|
104
|
+
if ! echo "$json_line" | jq empty 2>/dev/null; then
|
|
105
|
+
_log_error "유효하지 않은 JSON: ${json_line:0:200}"
|
|
106
|
+
return 1
|
|
107
|
+
fi
|
|
108
|
+
|
|
109
|
+
# 필드 추출 (callback은 보안상 제거됨 — eval 인젝션 방지)
|
|
110
|
+
local job_uuid prompt cwd use_worktree repo session_raw images_json reuse_wt
|
|
111
|
+
job_uuid=$(echo "$json_line" | jq -r '.id // empty')
|
|
112
|
+
prompt=$(echo "$json_line" | jq -r '.prompt // empty')
|
|
113
|
+
cwd=$(echo "$json_line" | jq -r '.cwd // empty')
|
|
114
|
+
use_worktree=$(echo "$json_line" | jq -r '.worktree // empty')
|
|
115
|
+
repo=$(echo "$json_line" | jq -r '.repo // empty')
|
|
116
|
+
session_raw=$(echo "$json_line" | jq -r '.session // empty')
|
|
117
|
+
images_json=$(echo "$json_line" | jq -c '.images // empty')
|
|
118
|
+
reuse_wt=$(echo "$json_line" | jq -r '.reuse_worktree // empty')
|
|
119
|
+
|
|
120
|
+
if [[ -z "$prompt" ]]; then
|
|
121
|
+
_log_error "프롬프트가 비어있습니다: ${json_line:0:200}"
|
|
122
|
+
return 1
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
# 고유 ID가 없으면 생성
|
|
126
|
+
if [[ -z "$job_uuid" ]]; then
|
|
127
|
+
job_uuid=$(date '+%s')-$$-$RANDOM
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
# ── 중복 프롬프트 감지 (짧은 시간 내 동일 프롬프트 무시) ──
|
|
131
|
+
local now
|
|
132
|
+
now=$(date '+%s')
|
|
133
|
+
local phash
|
|
134
|
+
phash=$(_prompt_hash "$prompt")
|
|
135
|
+
local elapsed=$(( now - _LAST_DISPATCH_TIME ))
|
|
136
|
+
|
|
137
|
+
if [[ "$phash" == "$_LAST_DISPATCH_HASH" && $elapsed -lt $_DEDUP_WINDOW_SEC ]]; then
|
|
138
|
+
_log_warn "중복 프롬프트 무시 (${elapsed}초 이내 동일 요청): id=${job_uuid}"
|
|
139
|
+
echo " [무시] 중복 요청이 감지되어 건너뜁니다: ${job_uuid}"
|
|
140
|
+
return 0
|
|
141
|
+
fi
|
|
142
|
+
|
|
143
|
+
_LAST_DISPATCH_HASH="$phash"
|
|
144
|
+
_LAST_DISPATCH_TIME="$now"
|
|
145
|
+
|
|
146
|
+
_log_info "작업 수신: id=${job_uuid} prompt='${prompt:0:80}...'"
|
|
147
|
+
|
|
148
|
+
# 동시 작업 수 제한 확인
|
|
149
|
+
local running_count=0
|
|
150
|
+
for meta_file in "${LOGS_DIR}"/job_*.meta; do
|
|
151
|
+
[[ -f "$meta_file" ]] || continue
|
|
152
|
+
local STATUS=""
|
|
153
|
+
STATUS=$(_get_meta_field "$meta_file" "STATUS")
|
|
154
|
+
if [[ "$STATUS" == "running" ]]; then
|
|
155
|
+
(( running_count++ )) || true
|
|
156
|
+
fi
|
|
157
|
+
done
|
|
158
|
+
|
|
159
|
+
if [[ $running_count -ge $MAX_BACKGROUND_JOBS ]]; then
|
|
160
|
+
_log_warn "최대 동시 작업 수(${MAX_BACKGROUND_JOBS}) 도달 — 작업 거부: ${job_uuid}"
|
|
161
|
+
return 1
|
|
162
|
+
fi
|
|
163
|
+
|
|
164
|
+
# Job 등록
|
|
165
|
+
local job_id
|
|
166
|
+
job_id=$(job_register "$prompt")
|
|
167
|
+
|
|
168
|
+
local out_file="${LOGS_DIR}/job_${job_id}.out"
|
|
169
|
+
local meta_file="${LOGS_DIR}/job_${job_id}.meta"
|
|
170
|
+
|
|
171
|
+
# .meta 파일에 UUID 기록 (원자적 append: temp → rename)
|
|
172
|
+
local _tmp="${meta_file}.tmp.$$"
|
|
173
|
+
{ cat "$meta_file"; echo "UUID=${job_uuid}"; } > "$_tmp" && mv -f "$_tmp" "$meta_file"
|
|
174
|
+
|
|
175
|
+
# ── Worktree 결정: 재사용(rewind) > 새로 생성 ──
|
|
176
|
+
local wt_path=""
|
|
177
|
+
local effective_repo="${repo:-$TARGET_REPO}"
|
|
178
|
+
|
|
179
|
+
if [[ -n "$reuse_wt" && -d "$reuse_wt" ]]; then
|
|
180
|
+
# Rewind: 기존 worktree 재사용
|
|
181
|
+
wt_path="$reuse_wt"
|
|
182
|
+
_tmp="${meta_file}.tmp.$$"
|
|
183
|
+
{ cat "$meta_file"; echo "WORKTREE='${wt_path}'"; echo "REWIND=true"; } > "$_tmp" && mv -f "$_tmp" "$meta_file"
|
|
184
|
+
_log_info "Job #${job_id} 기존 워크트리 재사용 (rewind): ${wt_path}"
|
|
185
|
+
elif [[ "$use_worktree" == "true" && -n "$effective_repo" ]]; then
|
|
186
|
+
# 새 worktree 생성
|
|
187
|
+
wt_path=$(worktree_create "$job_id" "$effective_repo" 2>/dev/null)
|
|
188
|
+
if [[ -n "$wt_path" && -d "$wt_path" ]]; then
|
|
189
|
+
_tmp="${meta_file}.tmp.$$"
|
|
190
|
+
{ cat "$meta_file"; echo "WORKTREE='${wt_path}'"; echo "REPO='${effective_repo}'"; } > "$_tmp" && mv -f "$_tmp" "$meta_file"
|
|
191
|
+
_log_info "Job #${job_id} 워크트리 생성됨: ${wt_path}"
|
|
192
|
+
else
|
|
193
|
+
_log_warn "Job #${job_id} 워크트리 생성 실패 — cwd 모드로 실행"
|
|
194
|
+
wt_path=""
|
|
195
|
+
fi
|
|
196
|
+
fi
|
|
197
|
+
|
|
198
|
+
# ── 이미지 파일을 @path 형태로 프롬프트에 추가 ──
|
|
199
|
+
if [[ -n "$images_json" && "$images_json" != "null" ]]; then
|
|
200
|
+
local img_count
|
|
201
|
+
img_count=$(echo "$images_json" | jq -r 'length' 2>/dev/null)
|
|
202
|
+
if [[ "$img_count" -gt 0 ]] 2>/dev/null; then
|
|
203
|
+
local img_refs=""
|
|
204
|
+
local i=0
|
|
205
|
+
while [[ $i -lt $img_count ]]; do
|
|
206
|
+
local img_path
|
|
207
|
+
img_path=$(echo "$images_json" | jq -r ".[$i]" 2>/dev/null)
|
|
208
|
+
if [[ -n "$img_path" && -f "$img_path" ]]; then
|
|
209
|
+
img_refs="${img_refs} @${img_path}"
|
|
210
|
+
_log_info "Job 이미지 첨부: ${img_path}"
|
|
211
|
+
else
|
|
212
|
+
_log_warn "이미지 파일 없음 (건너뜀): ${img_path}"
|
|
213
|
+
fi
|
|
214
|
+
(( i++ )) || true
|
|
215
|
+
done
|
|
216
|
+
if [[ -n "$img_refs" ]]; then
|
|
217
|
+
prompt="${prompt}${img_refs}"
|
|
218
|
+
fi
|
|
219
|
+
fi
|
|
220
|
+
fi
|
|
221
|
+
|
|
222
|
+
# claude -p 인자 구성 (stream-json + verbose로 실시간 추론 스트리밍)
|
|
223
|
+
local args=()
|
|
224
|
+
|
|
225
|
+
# ── 세션 플래그를 -p 보다 앞에 배치 (CLI 파서 호환성) ──
|
|
226
|
+
if [[ -n "$session_raw" ]]; then
|
|
227
|
+
case "$session_raw" in
|
|
228
|
+
resume:*)
|
|
229
|
+
local resume_sid="${session_raw#resume:}"
|
|
230
|
+
args+=(--resume "$resume_sid")
|
|
231
|
+
_log_info "Job 세션 모드: resume (sid=${resume_sid})"
|
|
232
|
+
;;
|
|
233
|
+
fork:*)
|
|
234
|
+
local fork_sid="${session_raw#fork:}"
|
|
235
|
+
# Fork: 이전 세션의 결과를 컨텍스트로 주입하여 새 세션으로 실행
|
|
236
|
+
local prev_result="" prev_prompt_text="" best_jid=0
|
|
237
|
+
for mf in "${LOGS_DIR}"/job_*.meta; do
|
|
238
|
+
[[ -f "$mf" ]] || continue
|
|
239
|
+
local sid
|
|
240
|
+
sid=$(_get_meta_field "$mf" "SESSION_ID")
|
|
241
|
+
if [[ "$sid" == "$fork_sid" ]]; then
|
|
242
|
+
local jid
|
|
243
|
+
jid=$(_get_meta_field "$mf" "JOB_ID")
|
|
244
|
+
# 가장 최신 job_id (가장 큰 숫자)를 선택
|
|
245
|
+
if [[ "$jid" -gt "$best_jid" ]] 2>/dev/null; then
|
|
246
|
+
best_jid="$jid"
|
|
247
|
+
prev_prompt_text=$(_get_meta_field "$mf" "PROMPT")
|
|
248
|
+
local of="${LOGS_DIR}/job_${jid}.out"
|
|
249
|
+
if [[ -f "$of" ]]; then
|
|
250
|
+
prev_result=$(grep '"type":"result"' "$of" | tail -1 | jq -r '.result // empty' 2>/dev/null)
|
|
251
|
+
fi
|
|
252
|
+
fi
|
|
253
|
+
fi
|
|
254
|
+
done
|
|
255
|
+
if [[ -n "$prev_result" ]]; then
|
|
256
|
+
# 이전 결과가 너무 길면 앞부분만 사용 (토큰 제한 방지)
|
|
257
|
+
local max_ctx=8000
|
|
258
|
+
if [[ ${#prev_result} -gt $max_ctx ]]; then
|
|
259
|
+
prev_result="${prev_result:0:$max_ctx}
|
|
260
|
+
... (이전 응답 ${#prev_result}자 중 ${max_ctx}자까지 포함)"
|
|
261
|
+
fi
|
|
262
|
+
prompt="[이전 대화에서 분기 (Fork from session: ${fork_sid:0:8}...)]
|
|
263
|
+
--- 이전 프롬프트 ---
|
|
264
|
+
${prev_prompt_text}
|
|
265
|
+
--- 이전 응답 ---
|
|
266
|
+
${prev_result}
|
|
267
|
+
--- 새로운 지시 (이전 컨텍스트를 참고하여 수행) ---
|
|
268
|
+
${prompt}"
|
|
269
|
+
_log_info "Job 세션 모드: fork (sid=${fork_sid}, job_id=${best_jid}, context=${#prev_result} chars)"
|
|
270
|
+
else
|
|
271
|
+
_log_warn "Job 세션 모드: fork — 이전 세션 결과 없음, 새 세션으로 실행 (sid=${fork_sid})"
|
|
272
|
+
fi
|
|
273
|
+
;;
|
|
274
|
+
continue)
|
|
275
|
+
args+=(--continue)
|
|
276
|
+
_log_info "Job 세션 모드: continue"
|
|
277
|
+
;;
|
|
278
|
+
esac
|
|
279
|
+
fi
|
|
280
|
+
|
|
281
|
+
args+=(-p "$prompt")
|
|
282
|
+
args+=(--output-format stream-json)
|
|
283
|
+
args+=(--verbose)
|
|
284
|
+
|
|
285
|
+
if [[ "${SKIP_PERMISSIONS:-false}" == "true" ]]; then
|
|
286
|
+
args+=(--dangerously-skip-permissions)
|
|
287
|
+
elif [[ -n "${DEFAULT_ALLOWED_TOOLS:-}" ]]; then
|
|
288
|
+
args+=(--allowedTools "$DEFAULT_ALLOWED_TOOLS")
|
|
289
|
+
fi
|
|
290
|
+
|
|
291
|
+
if [[ -n "${DEFAULT_MODEL:-}" ]]; then
|
|
292
|
+
args+=(--model "$DEFAULT_MODEL")
|
|
293
|
+
fi
|
|
294
|
+
|
|
295
|
+
if [[ -n "${APPEND_SYSTEM_PROMPT:-}" ]]; then
|
|
296
|
+
args+=(--append-system-prompt "$APPEND_SYSTEM_PROMPT")
|
|
297
|
+
fi
|
|
298
|
+
|
|
299
|
+
# cwd 결정: worktree > JSON cwd > 글로벌 WORKING_DIR > 현재 디렉토리
|
|
300
|
+
local effective_cwd
|
|
301
|
+
if [[ -n "$wt_path" ]]; then
|
|
302
|
+
effective_cwd="$wt_path"
|
|
303
|
+
else
|
|
304
|
+
effective_cwd="${cwd:-${WORKING_DIR:-$(pwd)}}"
|
|
305
|
+
fi
|
|
306
|
+
|
|
307
|
+
# .meta 파일에 CWD 기록
|
|
308
|
+
echo "CWD='${effective_cwd}'" >> "$meta_file"
|
|
309
|
+
|
|
310
|
+
# 백그라운드 서브쉘에서 실행 (cd로 작업 디렉토리 변경)
|
|
311
|
+
(
|
|
312
|
+
_log_info "Job #${job_id} 실행 시작 (uuid=${job_uuid}, cwd=${effective_cwd}, worktree=${wt_path:-none})"
|
|
313
|
+
|
|
314
|
+
cd "$effective_cwd" 2>/dev/null || true
|
|
315
|
+
|
|
316
|
+
# ── Worktree가 있으면 체크포인트 워처 시작 ──
|
|
317
|
+
if [[ -n "$wt_path" && -d "$wt_path" ]]; then
|
|
318
|
+
checkpoint_watcher_loop "$wt_path" "$job_id" "$meta_file" &
|
|
319
|
+
_log_info "Job #${job_id} 체크포인트 워처 시작됨"
|
|
320
|
+
fi
|
|
321
|
+
|
|
322
|
+
# stream-json 출력을 파일에 쓰면서 session_id를 조기 캡처
|
|
323
|
+
# stdbuf/gstdbuf로 라인 버퍼링 강제 (파일 리디렉션 시 블록 버퍼링 방지)
|
|
324
|
+
if command -v stdbuf &>/dev/null; then
|
|
325
|
+
stdbuf -oL "$CLAUDE_BIN" "${args[@]}" < /dev/null > "$out_file" 2>&1 &
|
|
326
|
+
elif command -v gstdbuf &>/dev/null; then
|
|
327
|
+
gstdbuf -oL "$CLAUDE_BIN" "${args[@]}" < /dev/null > "$out_file" 2>&1 &
|
|
328
|
+
else
|
|
329
|
+
"$CLAUDE_BIN" "${args[@]}" < /dev/null > "$out_file" 2>&1 &
|
|
330
|
+
fi
|
|
331
|
+
local claude_pid=$!
|
|
332
|
+
|
|
333
|
+
# 조기 session_id 캡처: 출력 파일 첫 이벤트에서 session_id 추출
|
|
334
|
+
local _sid_captured=""
|
|
335
|
+
local _sid_wait=0
|
|
336
|
+
while kill -0 "$claude_pid" 2>/dev/null && [[ $_sid_wait -lt 30 ]]; do
|
|
337
|
+
if [[ -f "$out_file" && -s "$out_file" ]]; then
|
|
338
|
+
_sid_captured=$(grep -m1 '"session_id"' "$out_file" 2>/dev/null | head -1 | jq -r '.session_id // empty' 2>/dev/null)
|
|
339
|
+
if [[ -n "$_sid_captured" ]]; then
|
|
340
|
+
job_set_session "$job_id" "$_sid_captured"
|
|
341
|
+
session_save "$_sid_captured" "$prompt"
|
|
342
|
+
_log_info "Job #${job_id} 조기 session_id 캡처: ${_sid_captured:0:8}..."
|
|
343
|
+
break
|
|
344
|
+
fi
|
|
345
|
+
fi
|
|
346
|
+
sleep 1
|
|
347
|
+
(( _sid_wait++ )) || true
|
|
348
|
+
done
|
|
349
|
+
|
|
350
|
+
wait "$claude_pid" 2>/dev/null
|
|
351
|
+
local exit_code=$?
|
|
352
|
+
|
|
353
|
+
# 조기 캡처되지 않았다면 최종 result에서 추출
|
|
354
|
+
if [[ -z "$_sid_captured" && -f "$out_file" ]]; then
|
|
355
|
+
local result_line
|
|
356
|
+
result_line=$(grep '"type":"result"' "$out_file" | tail -1)
|
|
357
|
+
if [[ -n "$result_line" ]]; then
|
|
358
|
+
local sid
|
|
359
|
+
sid=$(echo "$result_line" | jq -r '.session_id // empty' 2>/dev/null)
|
|
360
|
+
[[ -n "$sid" ]] && job_set_session "$job_id" "$sid"
|
|
361
|
+
[[ -n "$sid" ]] && session_save "$sid" "$prompt"
|
|
362
|
+
fi
|
|
363
|
+
fi
|
|
364
|
+
|
|
365
|
+
# 상태 갱신 (워처가 이 변경을 감지하고 최종 커밋 후 종료됨)
|
|
366
|
+
if [[ $exit_code -eq 0 ]]; then
|
|
367
|
+
job_mark_done "$job_id"
|
|
368
|
+
_log_info "Job #${job_id} 완료 (exit=0)"
|
|
369
|
+
else
|
|
370
|
+
job_mark_failed "$job_id"
|
|
371
|
+
_log_error "Job #${job_id} 실패 (exit=${exit_code})"
|
|
372
|
+
fi
|
|
373
|
+
|
|
374
|
+
# 체크포인트 워처 종료 대기
|
|
375
|
+
wait 2>/dev/null || true
|
|
376
|
+
|
|
377
|
+
) &
|
|
378
|
+
|
|
379
|
+
local bg_pid=$!
|
|
380
|
+
job_set_pid "$job_id" "$bg_pid"
|
|
381
|
+
|
|
382
|
+
local wt_label=""
|
|
383
|
+
[[ -n "$wt_path" ]] && wt_label=" [worktree: $(basename "$wt_path")]"
|
|
384
|
+
_log_info "Job #${job_id} 디스패치 완료${wt_label}"
|
|
385
|
+
echo " [디스패치] Job #${job_id} (uuid=${job_uuid})${wt_label} — session_id는 실행 후 자동 할당됩니다."
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
# ── start_service: 데몬 메인 루프 ───────────────────────────
|
|
389
|
+
start_service() {
|
|
390
|
+
# 이미 실행 중인지 확인
|
|
391
|
+
if [[ -f "$PID_FILE" ]]; then
|
|
392
|
+
local existing_pid
|
|
393
|
+
existing_pid=$(cat "$PID_FILE")
|
|
394
|
+
if kill -0 "$existing_pid" 2>/dev/null; then
|
|
395
|
+
echo " [오류] 서비스가 이미 실행 중입니다 (PID: ${existing_pid})"
|
|
396
|
+
echo " 'stop' 명령으로 먼저 종료하세요."
|
|
397
|
+
exit 1
|
|
398
|
+
else
|
|
399
|
+
_log_warn "오래된 PID 파일 발견 (PID: ${existing_pid}). 정리합니다."
|
|
400
|
+
rm -f "$PID_FILE"
|
|
401
|
+
fi
|
|
402
|
+
fi
|
|
403
|
+
|
|
404
|
+
# 디렉토리 보장
|
|
405
|
+
mkdir -p "$LOGS_DIR" "$QUEUE_DIR"
|
|
406
|
+
|
|
407
|
+
# FIFO 생성
|
|
408
|
+
if [[ -p "$FIFO_PATH" ]]; then
|
|
409
|
+
_log_warn "기존 FIFO 파이프 발견. 재사용합니다: ${FIFO_PATH}"
|
|
410
|
+
else
|
|
411
|
+
rm -f "$FIFO_PATH"
|
|
412
|
+
mkfifo "$FIFO_PATH"
|
|
413
|
+
_log_info "FIFO 파이프 생성됨: ${FIFO_PATH}"
|
|
414
|
+
fi
|
|
415
|
+
|
|
416
|
+
# PID 기록
|
|
417
|
+
echo $$ > "$PID_FILE"
|
|
418
|
+
_log_info "서비스 시작 (PID: $$)"
|
|
419
|
+
|
|
420
|
+
# 배너 출력
|
|
421
|
+
_print_banner
|
|
422
|
+
|
|
423
|
+
# ── 메인 수신 루프 ──────────────────────────────────────
|
|
424
|
+
# 외부 while true: FIFO EOF 시 다시 열기
|
|
425
|
+
# 내부 while read: 각 라인을 dispatch_job으로 전달
|
|
426
|
+
while true; do
|
|
427
|
+
while IFS= read -r line; do
|
|
428
|
+
# 빈 줄 무시
|
|
429
|
+
[[ -z "$line" ]] && continue
|
|
430
|
+
# 주석 무시
|
|
431
|
+
[[ "$line" == \#* ]] && continue
|
|
432
|
+
|
|
433
|
+
dispatch_job "$line" || true
|
|
434
|
+
done < "$FIFO_PATH"
|
|
435
|
+
|
|
436
|
+
# FIFO EOF — 모든 writer가 닫힘. 재오픈 대기.
|
|
437
|
+
_log_info "FIFO EOF 감지. 파이프를 다시 엽니다..."
|
|
438
|
+
done
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
# ── stop_service: 외부에서 호출하여 서비스 종료 ──────────────
|
|
442
|
+
stop_service() {
|
|
443
|
+
if [[ ! -f "$PID_FILE" ]]; then
|
|
444
|
+
echo " [오류] 실행 중인 서비스를 찾을 수 없습니다."
|
|
445
|
+
return 1
|
|
446
|
+
fi
|
|
447
|
+
|
|
448
|
+
local pid
|
|
449
|
+
pid=$(cat "$PID_FILE")
|
|
450
|
+
|
|
451
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
452
|
+
echo " [종료] 서비스에 SIGTERM 전송 (PID: ${pid})..."
|
|
453
|
+
kill "$pid"
|
|
454
|
+
# 종료 대기 (최대 10초)
|
|
455
|
+
local waited=0
|
|
456
|
+
while kill -0 "$pid" 2>/dev/null && [[ $waited -lt 10 ]]; do
|
|
457
|
+
sleep 1
|
|
458
|
+
(( waited++ )) || true
|
|
459
|
+
done
|
|
460
|
+
|
|
461
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
462
|
+
echo " [경고] 정상 종료 실패. SIGKILL 전송..."
|
|
463
|
+
kill -9 "$pid" 2>/dev/null
|
|
464
|
+
rm -f "$PID_FILE" "$FIFO_PATH"
|
|
465
|
+
fi
|
|
466
|
+
|
|
467
|
+
echo " [완료] 서비스가 종료되었습니다."
|
|
468
|
+
else
|
|
469
|
+
echo " [정보] 프로세스가 이미 종료되어 있습니다. PID 파일을 정리합니다."
|
|
470
|
+
rm -f "$PID_FILE"
|
|
471
|
+
fi
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
# ── 메인 진입점 ──────────────────────────────────────────────
|
|
475
|
+
case "${1:-start}" in
|
|
476
|
+
start)
|
|
477
|
+
start_service
|
|
478
|
+
;;
|
|
479
|
+
stop)
|
|
480
|
+
stop_service
|
|
481
|
+
;;
|
|
482
|
+
restart)
|
|
483
|
+
stop_service 2>/dev/null || true
|
|
484
|
+
sleep 1
|
|
485
|
+
start_service
|
|
486
|
+
;;
|
|
487
|
+
status)
|
|
488
|
+
if [[ -f "$PID_FILE" ]]; then
|
|
489
|
+
pid=$(cat "$PID_FILE")
|
|
490
|
+
if kill -0 "$pid" 2>/dev/null; then
|
|
491
|
+
echo " [실행 중] PID: ${pid}, FIFO: ${FIFO_PATH}"
|
|
492
|
+
else
|
|
493
|
+
echo " [중지됨] 프로세스 없음 (오래된 PID 파일: ${pid})"
|
|
494
|
+
fi
|
|
495
|
+
else
|
|
496
|
+
echo " [중지됨] 서비스가 실행 중이지 않습니다."
|
|
497
|
+
fi
|
|
498
|
+
;;
|
|
499
|
+
*)
|
|
500
|
+
echo "사용법: $0 {start|stop|restart|status}"
|
|
501
|
+
exit 1
|
|
502
|
+
;;
|
|
503
|
+
esac
|
package/web/auth.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Controller Service — 토큰 기반 인증 모듈
|
|
3
|
+
|
|
4
|
+
서버 시작 시 랜덤 토큰을 생성하여 파일에 저장하고 터미널에 출력한다.
|
|
5
|
+
모든 API 요청은 이 토큰을 Authorization 헤더로 포함해야 한다.
|
|
6
|
+
로컬 머신의 터미널을 볼 수 있는 사람만 토큰을 획득할 수 있으므로
|
|
7
|
+
CSRF, DNS Rebinding 등 원격 공격을 차단한다.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import secrets
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from config import DATA_DIR
|
|
14
|
+
|
|
15
|
+
TOKEN_FILE = DATA_DIR / "auth_token"
|
|
16
|
+
_cached_token: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def generate_token() -> str:
|
|
20
|
+
"""새 토큰을 생성하고 파일에 저장한다."""
|
|
21
|
+
global _cached_token
|
|
22
|
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
token = secrets.token_urlsafe(32)
|
|
24
|
+
TOKEN_FILE.write_text(token, "utf-8")
|
|
25
|
+
# 토큰 파일 권한을 소유자만 읽기/쓰기로 제한
|
|
26
|
+
TOKEN_FILE.chmod(0o600)
|
|
27
|
+
_cached_token = token
|
|
28
|
+
return token
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def get_token() -> str:
|
|
32
|
+
"""현재 토큰을 반환한다. 없으면 생성한다."""
|
|
33
|
+
global _cached_token
|
|
34
|
+
if _cached_token:
|
|
35
|
+
return _cached_token
|
|
36
|
+
if TOKEN_FILE.exists():
|
|
37
|
+
_cached_token = TOKEN_FILE.read_text("utf-8").strip()
|
|
38
|
+
if _cached_token:
|
|
39
|
+
return _cached_token
|
|
40
|
+
return generate_token()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def verify_token(provided: str) -> bool:
|
|
44
|
+
"""제공된 토큰이 유효한지 검증한다. (타이밍 공격 방지를 위해 secrets.compare_digest 사용)"""
|
|
45
|
+
expected = get_token()
|
|
46
|
+
return secrets.compare_digest(provided, expected)
|