okstra 0.97.0 → 0.98.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 (75) hide show
  1. package/README.kr.md +4 -3
  2. package/README.md +4 -3
  3. package/docs/kr/architecture.md +2 -2
  4. package/docs/kr/cli.md +2 -0
  5. package/docs/kr/container.md +122 -0
  6. package/docs/project-structure-overview.md +12 -2
  7. package/docs/superpowers/plans/2026-06-21-okstra-container-local-user-test.md +714 -0
  8. package/docs/superpowers/specs/2026-06-21-okstra-container-local-user-test-design.md +125 -0
  9. package/package.json +1 -1
  10. package/runtime/BUILD.json +2 -2
  11. package/runtime/agents/workers/antigravity-worker.md +2 -18
  12. package/runtime/agents/workers/claude-worker.md +10 -33
  13. package/runtime/agents/workers/codex-worker.md +2 -18
  14. package/runtime/agents/workers/report-writer-worker.md +2 -10
  15. package/runtime/bin/lib/okstra/interactive.sh +10 -2
  16. package/runtime/bin/lib/okstra-ctl/cmd-tail.sh +5 -2
  17. package/runtime/prompts/profiles/_clarification-recommendation.md +1 -0
  18. package/runtime/prompts/profiles/_coding-conventions-preflight.md +1 -1
  19. package/runtime/prompts/profiles/_common-contract.md +3 -3
  20. package/runtime/prompts/profiles/_coverage-critic.md +17 -0
  21. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  22. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  23. package/runtime/prompts/profiles/_implementation-self-check.md +2 -1
  24. package/runtime/prompts/profiles/_implementation-verifier.md +3 -3
  25. package/runtime/prompts/profiles/_stage-discipline.md +5 -8
  26. package/runtime/prompts/profiles/error-analysis.md +5 -5
  27. package/runtime/prompts/profiles/final-verification.md +4 -4
  28. package/runtime/prompts/profiles/implementation-planning.md +7 -7
  29. package/runtime/prompts/profiles/implementation.md +5 -5
  30. package/runtime/prompts/profiles/improvement-discovery.md +6 -6
  31. package/runtime/prompts/profiles/release-handoff.md +3 -3
  32. package/runtime/prompts/profiles/requirements-discovery.md +4 -4
  33. package/runtime/prompts/wizard/prompts.ko.json +0 -1
  34. package/runtime/python/okstra_ctl/__init__.py +4 -1
  35. package/runtime/python/okstra_ctl/container.py +970 -0
  36. package/runtime/python/okstra_ctl/container_registry.py +93 -0
  37. package/runtime/python/okstra_ctl/error_zip.py +4 -4
  38. package/runtime/python/okstra_ctl/handoff.py +7 -1
  39. package/runtime/python/okstra_ctl/ids.py +40 -1
  40. package/runtime/python/okstra_ctl/listing.py +3 -5
  41. package/runtime/python/okstra_ctl/log_report.py +102 -0
  42. package/runtime/python/okstra_ctl/pane_reclaim.py +5 -4
  43. package/runtime/python/okstra_ctl/paths.py +40 -0
  44. package/runtime/python/okstra_ctl/plan_run_root.py +78 -0
  45. package/runtime/python/okstra_ctl/reconcile.py +4 -2
  46. package/runtime/python/okstra_ctl/resolve_task_key.py +54 -0
  47. package/runtime/python/okstra_ctl/run.py +48 -30
  48. package/runtime/python/okstra_ctl/stage_integrate.py +8 -2
  49. package/runtime/python/okstra_ctl/stage_targets.py +79 -0
  50. package/runtime/python/okstra_ctl/time_report.py +200 -0
  51. package/runtime/python/okstra_ctl/tmux.py +67 -0
  52. package/runtime/python/okstra_ctl/wizard.py +35 -20
  53. package/runtime/python/okstra_project/__init__.py +2 -0
  54. package/runtime/python/okstra_project/state.py +50 -2
  55. package/runtime/skills/okstra-brief/SKILL.md +17 -7
  56. package/runtime/skills/okstra-container/SKILL.md +169 -0
  57. package/runtime/skills/okstra-inspect/SKILL.md +64 -178
  58. package/runtime/skills/okstra-memory/SKILL.md +8 -6
  59. package/runtime/skills/okstra-run/SKILL.md +4 -4
  60. package/runtime/skills/okstra-schedule/SKILL.md +9 -16
  61. package/runtime/skills/okstra-setup/SKILL.md +10 -9
  62. package/runtime/templates/reports/brief.template.md +1 -1
  63. package/runtime/templates/reports/final-report.template.md +1 -1
  64. package/runtime/templates/reports/settings.template.json +3 -1
  65. package/runtime/validators/validate-implementation-plan-stages.py +11 -3
  66. package/src/cli-registry.mjs +28 -0
  67. package/src/commands/inspect/container.mjs +27 -0
  68. package/src/commands/inspect/log-report.mjs +26 -0
  69. package/src/commands/inspect/resolve-task-key.mjs +26 -0
  70. package/src/commands/inspect/task-list.mjs +26 -15
  71. package/src/commands/inspect/time-report.mjs +26 -0
  72. package/src/commands/lifecycle/check-project.mjs +7 -1
  73. package/src/commands/lifecycle/doctor.mjs +16 -0
  74. package/src/lib/skill-catalog.mjs +1 -0
  75. package/runtime/agents/TODO.md +0 -226
@@ -0,0 +1,970 @@
1
+ """container compose 의 env 합성 + up argv 순수 함수.
2
+
3
+ 이 task 는 docker 실행 없는 순수 함수만 담는다(서비스 목록/라벨은 인자로 받아
4
+ generator 가 순수하게 유지된다). 실제 compose 파일 파싱·override 파일 쓰기·docker
5
+ 실행은 후속 task/e2e 로 미룬다.
6
+
7
+ env 우선순위(spec §6): compose 가 `--env-file <worktree .env>` 다음에
8
+ `--env-file <override>` 를 읽어 중복 키는 override 가 이긴다.
9
+
10
+ 라벨 SSOT(spec §7): 모든 컨테이너는 `okstra.task-key`/`okstra.project-name`/
11
+ `okstra.run-trace` 라벨을 달아야 후속 status/down 이 라벨 질의로 그룹을 찾는다.
12
+ `docker compose up` 에는 `--label` 플래그가 없으므로(compose v5 `up --help` +
13
+ docker compose 문서로 확인), 라벨은 `-f <override.yml>` override 파일이 각 서비스
14
+ 밑에 `labels:` 를 합쳐 주입한다.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import re
22
+ import shlex
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ from pathlib import Path
27
+ from typing import Callable, Iterable
28
+
29
+ from .stage_targets import PrepareError # 단일 PrepareError 타입 재노출(run.py 와 공유)
30
+
31
+ OKSTRA_LABEL_KEYS = ("okstra.task-key", "okstra.project-name", "okstra.run-trace")
32
+
33
+ COMPOSE_FILENAME = "docker-compose.yml"
34
+
35
+ # spec §5/§10 — 에러 트리거 화이트리스트. 이 정규식 중 하나라도 라인에 걸려야
36
+ # 2단계(LLM analyze)로 넘어간다. 건강한 컨테이너는 어느 것도 안 걸려 토큰 0.
37
+ ERROR_PATTERNS = (
38
+ re.compile(r"\bERROR\b"),
39
+ re.compile(r"\bFATAL\b"),
40
+ re.compile(r"\bCRITICAL\b"),
41
+ re.compile(r"\bPANIC\b"),
42
+ re.compile(r"\b\w+(?:Error|Exception)\b"),
43
+ re.compile(r"^Traceback \(most recent call last\):"),
44
+ re.compile(r"\b(?:exit(?:ed)?|exit code|status)\s+(?:code\s+)?[1-9]\d*\b", re.I),
45
+ re.compile(r"\bUnhandled\b", re.I),
46
+ )
47
+
48
+ # error_signature 정규화: 변동성 노이즈(타임스탬프/pid/라인참조/hex 주소)만 깎고,
49
+ # ERROR_PATTERNS 가 *탐지하는* 의미 있는 코드(HTTP status, exit/signal code)는 보존한다.
50
+ # 블랭킷 `\d+` 치환은 distinct 에러(500 vs 404, exit 1 vs 137)를 한 시그니처로
51
+ # collapse 시켜 디바운스가 영영 분석을 막으므로 쓰지 않는다(spec §10 signal vs noise).
52
+ _NOISE_SUBS = (
53
+ (re.compile(
54
+ r"\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:Z|[+-]\d{2}:?\d{2})?"
55
+ ), "<ts>"),
56
+ (re.compile(r"\b0x[0-9a-fA-F]+\b"), "<hex>"),
57
+ (re.compile(r"\b(pid|tid|ppid)=\d+", re.I), r"\1=<n>"),
58
+ (re.compile(r":\d+:"), ":<n>:"),
59
+ )
60
+ _WS_RE = re.compile(r"\s+")
61
+
62
+
63
+ def merge_env(base_env: dict, override: dict) -> dict:
64
+ """base_env 위에 override 를 얹는다. 동일 키는 override 가 이긴다."""
65
+ return {**base_env, **override}
66
+
67
+
68
+ def build_label_override_yaml(services: list[str], labels: dict) -> str:
69
+ """각 서비스 밑에 `labels:` 를 주입하는 compose override yaml 본문을 낸다.
70
+
71
+ docker compose 가 `-f` 로 합칠 때 이 본문이 컨테이너 라벨을 단다. 디스크 쓰기는
72
+ 호출자(후속 task) 몫이며 이 함수는 문자열만 반환한다.
73
+ """
74
+ lines = ["services:"]
75
+ for service in services:
76
+ lines.append(f" {service}:")
77
+ lines.append(" labels:")
78
+ for key, value in labels.items():
79
+ lines.append(f" {key}: {_yaml_quote(value)}")
80
+ return "\n".join(lines) + "\n"
81
+
82
+
83
+ def _yaml_quote(value: str) -> str:
84
+ """라벨 값을 YAML double-quoted scalar 로 감싼다.
85
+
86
+ task-key 는 raw 보존(slugify 안 함)이라 ` #`(주석), `{`/`[`(flow), `&`/`*`
87
+ (anchor/alias), `:` 같은 indicator 문자가 들어올 수 있다. 무인용으로 쓰면
88
+ override 파일이 깨져 `up` 이 중단되거나 라벨이 silently 잘린다 — double-quote
89
+ 안에서 `\\`·`"` 만 이스케이프하면 모든 indicator 가 평문으로 안전해진다."""
90
+ escaped = str(value).replace("\\", "\\\\").replace('"', '\\"')
91
+ return f'"{escaped}"'
92
+
93
+
94
+ def build_compose_up_argv(
95
+ *,
96
+ project_name: str,
97
+ compose_path: str,
98
+ worktree_env_path: str,
99
+ override_env_path: str,
100
+ label_override_path: str,
101
+ ) -> list[str]:
102
+ """`docker compose up -d` argv 를 낸다.
103
+
104
+ - `-f` 는 base compose → label override 순서다. `-f` 가 하나라도 명시되면
105
+ docker compose 는 cwd 의 docker-compose.yml 자동탐색을 끄므로, 베이스 파일을
106
+ 반드시 첫 `-f` 로 직접 넘겨야 한다(누락 시 라벨 override 만 머지돼 'no image'
107
+ 로 실패). label override 가 뒤에 와서 base 위에 `labels:` 만 덧입힌다.
108
+ - `--env-file` 은 worktree → override 순서라 override 가 나중(우선)에 온다.
109
+ worktree `.env` 가 없으면 그 플래그를 통째로 뺀다 — docker compose v2 는
110
+ 존재하지 않는 명시적 `--env-file` 을 hard error 로 처리하므로(implicit `.env`
111
+ 와 달리) 빈 경로를 넣으면 `.env` 없는 평범한 프로젝트가 전부 up 실패한다.
112
+ - 라벨은 `-f <label_override_path>` 로 배선한다(`up --label` 미지원).
113
+ """
114
+ argv = [
115
+ "docker", "compose",
116
+ "-p", project_name,
117
+ "-f", compose_path,
118
+ "-f", label_override_path,
119
+ ]
120
+ if worktree_env_path:
121
+ argv += ["--env-file", worktree_env_path]
122
+ argv += ["--env-file", override_env_path, "up", "-d"]
123
+ return argv
124
+
125
+
126
+ def scan_log_chunk(text: str, patterns: Iterable[re.Pattern]) -> list[str]:
127
+ """ERROR_PATTERNS 중 하나라도 걸리는 라인만 순서대로 반환(순수, docker 무의존)."""
128
+ return [
129
+ line
130
+ for line in text.splitlines()
131
+ if line and any(p.search(line) for p in patterns)
132
+ ]
133
+
134
+
135
+ def error_signature(line: str) -> str:
136
+ """디바운스 키: 변동성 노이즈만 깎고 의미 있는 코드는 보존한 라인.
137
+
138
+ 타임스탬프/pid/라인참조/hex 주소는 placeholder 로 정규화하지만 HTTP status·
139
+ exit/signal code 같은 판별자는 그대로 둔다. 같은 에러가 timestamp·pid 만
140
+ 달리해 반복되면 동일 키 → analyze 1회(spec §10 디바운스), 그러나 500 vs 404
141
+ 처럼 의미가 다르면 별도 키 → 각각 분석된다.
142
+ """
143
+ s = line
144
+ for pattern, repl in _NOISE_SUBS:
145
+ s = pattern.sub(repl, s)
146
+ return _WS_RE.sub(" ", s).strip()
147
+
148
+
149
+ def watcher_step(
150
+ *,
151
+ log_chunk: str,
152
+ seen_signatures: set,
153
+ findings_path: Path,
154
+ analyze: Callable[[str], object],
155
+ ) -> int:
156
+ """log_chunk 를 1단계 스캔 → 새 시그니처마다 analyze 호출 + findings append.
157
+
158
+ 이미 본 시그니처는 디바운스(skip). 새 분석 건수를 반환한다. analyze 는 주입이라
159
+ 유닛 테스트는 스텁으로 LLM 없이 행위를 검증한다.
160
+ """
161
+ new_count = 0
162
+ for line in scan_log_chunk(log_chunk, ERROR_PATTERNS):
163
+ sig = error_signature(line)
164
+ if sig in seen_signatures:
165
+ continue
166
+ seen_signatures.add(sig)
167
+ # 탐지 자체를 analyze 보다 먼저 영속화한다 — analyze(=pane spawn) 가 실패해도
168
+ # 에러 라인은 findings 에 남는다. analyze 예외가 장기 watcher 루프를 죽이지
169
+ # 않도록 격리하고, 실패 사실도 findings 에 기록한다(silent 중단 방지).
170
+ _append_finding(Path(findings_path), sig, line)
171
+ try:
172
+ analyze(line)
173
+ except Exception as exc: # noqa: BLE001 — best-effort 외부 spawn, 루프 보존이 우선
174
+ _append_finding(Path(findings_path), sig, f"[analyze 실패] {exc}")
175
+ new_count += 1
176
+ return new_count
177
+
178
+
179
+ def _append_finding(findings_path: Path, signature: str, line: str) -> None:
180
+ findings_path.parent.mkdir(parents=True, exist_ok=True)
181
+ block = f"## {signature}\n\n```\n{line}\n```\n\n"
182
+ with findings_path.open("a", encoding="utf-8") as fh:
183
+ fh.write(block)
184
+
185
+
186
+ # spec §9 — watcher AI 는 "탐지·보고만, 자동 치유 제외". 기본 analyze 가 기동하는
187
+ # 에이전트는 findings 파일 append 외 어떤 코드/설정 Edit/Write 도 금지한다. 이
188
+ # 계약 문구를 에이전트 프롬프트에 그대로 넣어 enforcement 를 명시한다.
189
+ WATCHER_READONLY_CONTRACT = (
190
+ "You are a READ-ONLY container log watcher. You MUST NOT Edit or Write any "
191
+ "code or config file. Your ONLY permitted write is appending an analysis "
192
+ "note to the findings file. Detect and report; never auto-fix."
193
+ )
194
+
195
+
196
+ def default_analyze(findings_path: Path, *, session_pane: str | None = None):
197
+ """프로덕션 기본 analyze 팩토리: read-only 계약을 건 에이전트를 pane 으로 기동.
198
+
199
+ codex/antigravity-worker 와 동일한 split_container_pane 메커니즘을 쓰되
200
+ WATCHER_READONLY_CONTRACT 를 프롬프트에 박아 코드/설정 수정 권한을 차단한다.
201
+ 라이브 spawn 세부(에이전트 CLI·모델·권한 스코프)는 아직 미구현이라 e2e 영역으로
202
+ 둔다 — session_pane 이 없으면 split_container_pane 이 guard 로 None 을 돌려주어
203
+ 실제 spawn 없이 no-op 이 된다(에러 탐지·findings 기록은 watcher_step 이 담당).
204
+ 프롬프트를 셸 명령으로 그대로 실행하지 않도록, 자동 `$TMUX_PANE` fallback 은
205
+ 두지 않는다 — 자연어 프롬프트가 pane 명령으로 새지 않게 하는 안전장치다.
206
+ """
207
+ from okstra_ctl import tmux
208
+
209
+ def analyze(window: str) -> str | None:
210
+ prompt = f"{WATCHER_READONLY_CONTRACT}\n\nError window:\n{window}\n"
211
+ return tmux.split_container_pane(
212
+ session_pane=session_pane,
213
+ cwd=str(Path(findings_path).parent),
214
+ command=prompt,
215
+ title="okstra-watcher",
216
+ scope_value=str(findings_path),
217
+ kind="watcher",
218
+ )
219
+
220
+ return analyze
221
+
222
+
223
+ def build_watcher_pane_command(
224
+ *, python_exe: str, module_root: str, project_name: str,
225
+ service: str, findings_path: str, scan_interval_seconds: float,
226
+ ) -> str:
227
+ """watcher pane 이 실행할 셸 명령 문자열.
228
+
229
+ `python -m okstra_ctl.container watch` 를 PYTHONPATH 와 함께 박는다 — tmux pane
230
+ 의 환경 상속에 의존하면 PYTHONPATH 가 비어 import 가 깨지므로, 현재 프로세스의
231
+ module-root 를 명시적으로 넘긴다. 모든 인자는 shlex 로 인용해 경로에 공백/메타
232
+ 문자가 있어도 안전하다."""
233
+ return " ".join([
234
+ f"PYTHONPATH={shlex.quote(module_root)}",
235
+ shlex.quote(python_exe), "-m", "okstra_ctl.container", "watch",
236
+ "--project-name", shlex.quote(project_name),
237
+ "--service", shlex.quote(service),
238
+ "--findings", shlex.quote(findings_path),
239
+ "--scan-interval", str(scan_interval_seconds),
240
+ ])
241
+
242
+
243
+ def run_service_watcher(
244
+ *, project_name: str, service: str, findings_path: str,
245
+ scan_interval_seconds: float,
246
+ ) -> None:
247
+ """`container watch` 서브커맨드 본체 — 한 서비스의 감시 루프를 돈다.
248
+
249
+ stage ① 경량 스캔(ERROR 패턴) → 매칭 시 findings append + stage ② analyze.
250
+ 사용자가 pane/세션을 종료(stop-watcher/down)할 때까지 지속한다."""
251
+ analyze = default_analyze(Path(findings_path))
252
+ run_watcher_loop(
253
+ service=service, project_name=project_name,
254
+ findings_path=Path(findings_path),
255
+ scan_interval_seconds=scan_interval_seconds, analyze=analyze,
256
+ )
257
+
258
+
259
+ def run_watcher_loop(
260
+ *,
261
+ service: str,
262
+ project_name: str,
263
+ findings_path: Path,
264
+ scan_interval_seconds: float,
265
+ analyze: Callable[[str], object],
266
+ ) -> None:
267
+ """`docker compose -p <project> logs --since <last>` 증분 fetch + sleep 루프.
268
+
269
+ 사용자 종료(stop-watcher/down) 까지 지속. 토큰은 ERROR_PATTERNS 가 걸릴 때만
270
+ 쓴다. 루프는 얇게 — 매 tick 의 판단은 watcher_step 에 위임한다.
271
+ """
272
+ seen: set = set()
273
+ # 첫 tick 은 --since 없이 컨테이너 기동 이후 backlog 전체를 본다. `--since 0s`
274
+ # 는 'now 이후'를 의미해 기동 중 찍힌 startup-failure 에러를 영구히 놓친다.
275
+ since: str | None = None
276
+ while True:
277
+ tick_start = time.monotonic()
278
+ chunk = _fetch_log_chunk(service=service, project_name=project_name, since=since)
279
+ watcher_step(
280
+ log_chunk=chunk, seen_signatures=seen,
281
+ findings_path=Path(findings_path), analyze=analyze,
282
+ )
283
+ time.sleep(scan_interval_seconds)
284
+ # 다음 윈도우는 '직전 fetch 이후 실제 경과시간'(sleep + analyze 포함)을
285
+ # 덮는다. 고정 interval 윈도우는 analyze 가 interval 보다 오래 걸리면 그
286
+ # 사이 찍힌 에러 라인을 영구히 놓친다. +1s 오버랩의 중복은 디바운스가 흡수.
287
+ since = f"{int(time.monotonic() - tick_start) + 1}s"
288
+
289
+
290
+ def _fetch_log_chunk(*, service: str, project_name: str, since: str | None) -> str:
291
+ argv = ["docker", "compose", "-p", project_name, "logs", "--no-color"]
292
+ if since: # 첫 tick(None)은 --since 생략 → 기동 backlog 전체
293
+ argv += ["--since", since]
294
+ argv.append(service)
295
+ result = subprocess.run(argv, capture_output=True, text=True, check=False)
296
+ return result.stdout or ""
297
+
298
+
299
+ # --------------------------------------------------------------------------- #
300
+ # compose 파싱 — docker 가 정본(canonical). 손수 정규식 파서는 라벨 SSOT 를
301
+ # silent 하게 깨므로 쓰지 않는다(inline comment / quoted name / 4-space 들여쓰기
302
+ # 오파싱 → 라벨 미부착 → status/down 라벨 쿼리가 빈 결과 → 컨테이너 leak).
303
+ # `docker compose config` 는 parse 일 뿐(run 아님)이라 up 전에 호출 가능하다.
304
+ # --------------------------------------------------------------------------- #
305
+
306
+ _SERVICES_BLOCK_RE = re.compile(r"^services:\s*$", re.M)
307
+
308
+
309
+ def compose_has_services_block(compose_text: str) -> bool:
310
+ """top-level `services:` 아래에 들여쓰기된 내용이 한 줄이라도 있으면 True.
311
+
312
+ docker 파싱 결과가 0개일 때 '빈 compose(정상 0개)' 인지 '오파싱(loud abort)'
313
+ 인지 가르는 ground-truth 다. YAML 의미 파싱이 아니라 블록 존재만 본다."""
314
+ lines = compose_text.splitlines()
315
+ for i, line in enumerate(lines):
316
+ if not _SERVICES_BLOCK_RE.match(line):
317
+ continue
318
+ for nxt in lines[i + 1:]:
319
+ if not nxt.strip() or nxt.lstrip().startswith("#"):
320
+ continue
321
+ return nxt.startswith((" ", "\t")) # 들여쓰기된 첫 콘텐츠 줄
322
+ return False
323
+
324
+
325
+ def _compose_config_argv(
326
+ compose_path: Path, env_files: Iterable[str], tail: list[str],
327
+ ) -> list[str]:
328
+ """`docker compose [--env-file ...] -f <compose> config <tail>` argv.
329
+
330
+ `--env-file` 과 cwd 는 up 과 반드시 동일해야 한다 — config 가 변수
331
+ interpolation 을 다른 env 로 해소하면 bind-mount escape 탐지/서비스 파싱이
332
+ 실제 배포와 다른 값으로 계산된다."""
333
+ argv = ["docker", "compose"]
334
+ for env_file in env_files:
335
+ argv += ["--env-file", env_file]
336
+ argv += ["-f", str(compose_path), "config", *tail]
337
+ return argv
338
+
339
+
340
+ def resolve_compose_services(
341
+ compose_path: Path, env_files: Iterable[str] = (), cwd: str | None = None,
342
+ ) -> list[str]:
343
+ """`docker compose -f <compose> config --services` 로 서비스 목록을 얻는다.
344
+
345
+ docker 가 정본 파서다. services 블록이 비어있지 않은데 0개가 나오면(docker
346
+ 부재/파싱 실패 포함) 라벨 없는 컨테이너를 띄우느니 PrepareError 로 loud 하게
347
+ 중단한다 — 라벨 SSOT 를 silent 하게 깨지 않는다. `--env-file`/cwd 는 up 과
348
+ 동일하게 넘겨 interpolation 결과를 일치시킨다."""
349
+ out = _docker_run(
350
+ _compose_config_argv(compose_path, env_files, ["--services"]), cwd=cwd)
351
+ services = [s.strip() for s in out.splitlines() if s.strip()]
352
+ if not services and compose_has_services_block(
353
+ compose_path.read_text(encoding="utf-8")):
354
+ raise PrepareError(
355
+ f"container up: {compose_path.name} 에 services 가 정의돼 있으나 "
356
+ "`docker compose config --services` 가 빈 목록을 반환했습니다 "
357
+ "(docker 부재 또는 compose 파싱 실패). 라벨 미부착 컨테이너 leak 을 "
358
+ "막기 위해 중단합니다 — docker 가용성과 compose 문법을 확인하세요.")
359
+ return services
360
+
361
+
362
+ def scan_escaping_host_paths(
363
+ compose_path: Path, worktree_root: Path,
364
+ env_files: Iterable[str] = (), cwd: str | None = None,
365
+ ) -> list[str]:
366
+ """worktree 밖(절대경로 or `../`)을 가리키는 bind mount host 경로를 모은다.
367
+
368
+ spec §6: worktree 를 벗어나는 마운트는 경고 후 진행(중단 아님). docker 가
369
+ 정규화한 config(`--format json`)의 volumes 를 보므로 short/long-form 모두
370
+ 커버한다. docker 실패 시 빈 목록(경고만 누락, 진행에는 영향 없음). `--env-file`/
371
+ cwd 는 up 과 동일하게 넘겨 `${VAR}` 마운트 경로가 실제 배포와 일치하게 한다."""
372
+ out = _docker_run(
373
+ _compose_config_argv(compose_path, env_files, ["--format", "json"]), cwd=cwd)
374
+ try:
375
+ config = json.loads(out) if out.strip() else {}
376
+ except json.JSONDecodeError:
377
+ return []
378
+ escaping: list[str] = []
379
+ for svc in (config.get("services") or {}).values():
380
+ for vol in (svc.get("volumes") or []):
381
+ source = vol.get("source") if isinstance(vol, dict) else None
382
+ if isinstance(source, str) and _is_escaping_host_path(source):
383
+ escaping.append(source)
384
+ return escaping
385
+
386
+
387
+ def _is_escaping_host_path(source: str) -> bool:
388
+ """named volume(슬래시 없음)은 제외, 절대경로/`../` host 경로만 escape."""
389
+ if source.startswith("/") or source.startswith("..") or "/../" in source:
390
+ return True
391
+ return False
392
+
393
+
394
+ # --------------------------------------------------------------------------- #
395
+ # docker 얇은 래퍼(daemon 필요 — 게이트 로직과 분리해 유닛 테스트 가능하게)
396
+ # --------------------------------------------------------------------------- #
397
+
398
+ def _compose_ps(project_name: str, cwd: str, include_stopped: bool = False) -> list[dict]:
399
+ argv = ["docker", "compose", "-p", project_name, "ps"]
400
+ if include_stopped:
401
+ argv.append("-a")
402
+ argv += ["--format", "json"]
403
+ result = subprocess.run(argv, capture_output=True, text=True, check=False, cwd=cwd)
404
+ rows: list[dict] = []
405
+ for line in (result.stdout or "").splitlines():
406
+ line = line.strip()
407
+ if not line:
408
+ continue
409
+ try:
410
+ rows.append(json.loads(line))
411
+ except json.JSONDecodeError:
412
+ continue
413
+ return rows
414
+
415
+
416
+ def _docker_run(argv: list[str], cwd: str | None = None) -> str:
417
+ result = subprocess.run(argv, capture_output=True, text=True, check=False, cwd=cwd)
418
+ return result.stdout or ""
419
+
420
+
421
+ def query_containers_by_label(project_name: str) -> list[dict]:
422
+ """라벨 SSOT(spec §11): `okstra.project-name` 으로 컨테이너를 질의한다.
423
+
424
+ registry 가 아니라 docker 라벨이 컨테이너 존재의 기준값이다."""
425
+ out = _docker_run([
426
+ "docker", "ps", "-a", "--filter",
427
+ f"label=okstra.project-name={project_name}",
428
+ "--format", "{{.ID}}\t{{.Names}}\t{{.State}}",
429
+ ])
430
+ rows: list[dict] = []
431
+ for line in out.splitlines():
432
+ parts = line.split("\t")
433
+ if len(parts) >= 3:
434
+ rows.append({"id": parts[0], "name": parts[1], "state": parts[2]})
435
+ return rows
436
+
437
+
438
+ def _service_state(row: dict) -> str:
439
+ return (row.get("State") or row.get("state") or "").lower()
440
+
441
+
442
+ def _service_health(row: dict) -> str:
443
+ return (row.get("Health") or row.get("health") or "").lower()
444
+
445
+
446
+ def poll_healthcheck(
447
+ *, project_name: str, cwd: str, services: list[str],
448
+ healthcheck_timeout_seconds: int, healthcheck_interval_seconds: int,
449
+ sleep: Callable[[float], None] = time.sleep,
450
+ ) -> list[str]:
451
+ """healthy(또는 healthcheck 미정의 시 running)까지 폴링. 미기동 서비스명 반환.
452
+
453
+ healthcheck 가 정의된 서비스는 Health=healthy 를, 없는 서비스는 State=running
454
+ 을 성공 조건으로 본다(spec §4 step6). `deploy.replicas`>1 이면 `ps` 가 한
455
+ 서비스에 여러 컨테이너 행을 내므로, 서비스명별로 묶어 **모든** 레플리카가
456
+ ready 일 때만 통과시킨다(한 행으로 collapse 하면 건강한 레플리카가 비정상
457
+ 형제를 가린다). 빈 리스트면 전부 기동.
458
+
459
+ `-a`(include_stopped)로 폴링해 크래시-종료한 컨테이너 행까지 본다 —
460
+ `_started_services` 와 같은 가시성. 안 그러면 멀티-레플리카에서 죽은 레플리카가
461
+ running-only ps 에 행이 없어, 살아있는 형제만으로 `all(ready)` 가 통과해 죽은
462
+ 배포를 healthy 로 오인한다(exited 행은 _container_ready 가 False 를 돌려준다)."""
463
+ deadline = time.monotonic() + healthcheck_timeout_seconds
464
+ pending = set(services)
465
+ while pending and time.monotonic() < deadline:
466
+ by_service = _group_ps_rows_by_service(
467
+ _compose_ps(project_name, cwd, include_stopped=True))
468
+ for svc in list(pending):
469
+ rows = by_service.get(svc)
470
+ if rows and all(_container_ready(r) for r in rows):
471
+ pending.discard(svc)
472
+ if pending:
473
+ sleep(healthcheck_interval_seconds)
474
+ return sorted(pending)
475
+
476
+
477
+ def _group_ps_rows_by_service(rows: list[dict]) -> dict[str, list[dict]]:
478
+ out: dict[str, list[dict]] = {}
479
+ for row in rows:
480
+ key = row.get("Service") or row.get("Name")
481
+ if key:
482
+ out.setdefault(key, []).append(row)
483
+ return out
484
+
485
+
486
+ def _container_ready(row: dict) -> bool:
487
+ health = _service_health(row)
488
+ if health:
489
+ return health == "healthy"
490
+ return _service_state(row) == "running"
491
+
492
+
493
+ # --------------------------------------------------------------------------- #
494
+ # up 흐름 (provision_container_group) — 단계별 헬퍼로 분해
495
+ # --------------------------------------------------------------------------- #
496
+
497
+ def _split_task_key(task_key: str) -> tuple[str, str, str]:
498
+ parts = task_key.split(":")
499
+ if len(parts) != 3 or not all(parts):
500
+ raise PrepareError(
501
+ f"container: task-key 형식은 '<project-id>:<task-group>:<task-id>' 이어야 "
502
+ f"합니다(받음: {task_key!r})")
503
+ return parts[0], parts[1], parts[2]
504
+
505
+
506
+ def _resolve_up_inputs(project_root: Path, task_key: str) -> dict:
507
+ """task-key 로 up 에 필요한 입력(worktree/stage_map/done_rows/anchor)을 모은다."""
508
+ from . import worktree_registry as _reg
509
+ from .consumers import read_consumers, backfill_done_from_carry
510
+ from .plan_run_root import resolve_plan_run_root_by_task_key
511
+
512
+ project_id, group, task_id = _split_task_key(task_key)
513
+ entry = _reg.lookup(project_id, group, task_id)
514
+ if entry is None or not entry.worktree_path:
515
+ raise PrepareError(
516
+ f"container up: task-key {task_key} 의 task worktree 가 registry 에 "
517
+ "없습니다. 먼저 해당 task 의 phase 를 한 번 실행해 worktree 를 만드세요.")
518
+ plan = resolve_plan_run_root_by_task_key(
519
+ project_root=project_root, task_group=group, task_id=task_id)
520
+ # carry sidecar 가 stage 완료의 SSOT 다 — final-verification 과 동일하게 누락된
521
+ # done 행을 carry 에서 복구한 뒤 읽어, 두 경로가 같은 디스크 상태에서 갈라지지
522
+ # 않게 한다(crash/부분쓰기로 consumers.jsonl done 행만 유실된 경우 대비).
523
+ backfill_done_from_carry(plan.run_root)
524
+ done_rows = [r for r in read_consumers(plan.run_root) if r.get("status") == "done"]
525
+ stage_map = _plan_stage_map(plan.approved_plan_path)
526
+ anchor = _reg.get_implementation_base(project_id, group, task_id) or ""
527
+ return {
528
+ "project_id": project_id, "task_group": group, "task_id": task_id,
529
+ "worktree_path": entry.worktree_path, "stage_map": stage_map,
530
+ "done_rows": done_rows, "anchor_base": anchor,
531
+ }
532
+
533
+
534
+ def _plan_stage_map(approved_plan_path: str) -> list[dict]:
535
+ """approved-plan(implementation-planning final-report)에서 전체 stage 목록 파싱.
536
+
537
+ done 행만으로 합성하면 whole-task 완료 게이트(stage N not done)가 vacuous 해진다
538
+ — 게이트가 같은 done 집합을 기준으로 검사하기 때문. final-verification 과
539
+ 동일한 파서(`_parse_stage_map_into_ctx`)를 써 계획에 선언된 미완 stage 를
540
+ container up 도 거부하게 한다(stage 1 만 done 인 3-stage task 를 '완성'으로
541
+ 배포하지 않음). 빈/손상 Stage Map 거부는 `_parse_stage_map_into_ctx` 가
542
+ 공통으로 수행한다."""
543
+ from .run import _parse_stage_map_into_ctx
544
+ return _parse_stage_map_into_ctx(approved_plan_path)
545
+
546
+
547
+ def _verify_compose_present(worktree_root: Path) -> Path:
548
+ compose = worktree_root / COMPOSE_FILENAME
549
+ if not compose.is_file():
550
+ raise PrepareError(
551
+ f"container up: worktree 루트에 {COMPOSE_FILENAME} 가 없습니다 "
552
+ f"({compose}). okstra 는 compose 파일을 생성하지 않습니다 — "
553
+ "프로젝트에 직접 추가한 뒤 다시 시도하세요.")
554
+ return compose
555
+
556
+
557
+ def _synthesize_env_override(worktree_root: Path, cp: dict) -> tuple[str, str]:
558
+ """worktree `.env`(okstra-owned 입력) → env.override 합성. (worktree_env, override) 반환.
559
+
560
+ spec §6 artifact-home: 입력 `.env` 는 raw project root 가 아니라 worktree
561
+ 트리에서 해소한다. override 는 container_dir 밑에 쓴다."""
562
+ worktree_env = worktree_root / ".env"
563
+ base = _read_env_file(worktree_env)
564
+ override = merge_env(base, {})
565
+ cp["container_dir"].mkdir(parents=True, exist_ok=True)
566
+ cp["env_override"].write_text(
567
+ "".join(f"{k}={v}\n" for k, v in override.items()), encoding="utf-8")
568
+ # 파일이 없으면 빈 경로를 반환해 up argv 가 `--env-file <missing>` 을 넣지
569
+ # 않게 한다(존재하는 override 는 항상 위에서 써 두므로 그 쪽은 안전).
570
+ worktree_env_arg = str(worktree_env) if worktree_env.is_file() else ""
571
+ return worktree_env_arg, str(cp["env_override"])
572
+
573
+
574
+ def _read_env_file(path: Path) -> dict:
575
+ out: dict[str, str] = {}
576
+ if not path.is_file():
577
+ return out
578
+ for line in path.read_text(encoding="utf-8").splitlines():
579
+ line = line.strip()
580
+ if not line or line.startswith("#") or "=" not in line:
581
+ continue
582
+ k, v = line.split("=", 1)
583
+ out[k.strip()] = v.strip()
584
+ return out
585
+
586
+
587
+ def _write_label_override(cp: dict, services: list[str], labels: dict) -> str:
588
+ cp["container_dir"].mkdir(parents=True, exist_ok=True)
589
+ path = cp["container_dir"] / "labels.override.yml"
590
+ path.write_text(build_label_override_yaml(services, labels), encoding="utf-8")
591
+ return str(path)
592
+
593
+
594
+ def provision_container_group(
595
+ *, project_root, task_key: str,
596
+ healthcheck_timeout_seconds: int = 120,
597
+ healthcheck_interval_seconds: int = 3,
598
+ scan_interval_seconds: float = 5.0,
599
+ ) -> dict:
600
+ """skill / bin okstra / okstra.sh 가 수렴하는 container up 단일 진입점."""
601
+ from . import paths
602
+ from .ids import compose_project_name, build_container_session_name
603
+
604
+ project_root = Path(project_root)
605
+ inputs = _resolve_up_inputs(project_root, task_key)
606
+ worktree = Path(_integrate_source_tree(inputs))
607
+ compose = _verify_compose_present(worktree)
608
+ cp = paths.container_paths(project_root, inputs["task_group"], inputs["task_id"])
609
+ project_name = compose_project_name(
610
+ inputs["project_id"], inputs["task_group"], inputs["task_id"])
611
+ session_name = build_container_session_name(
612
+ inputs["project_id"], inputs["task_group"], inputs["task_id"])
613
+ worktree_env, override_env = _synthesize_env_override(worktree, cp)
614
+ env_files = [p for p in (worktree_env, override_env) if p]
615
+ warnings = scan_escaping_host_paths(compose, worktree, env_files, str(worktree))
616
+ services = resolve_compose_services(compose, env_files, str(worktree))
617
+ started = _compose_up(
618
+ cp=cp, worktree=worktree, compose=compose, project_name=project_name,
619
+ task_key=task_key, session_name=session_name, services=services,
620
+ worktree_env=worktree_env, override_env=override_env)
621
+ if not started:
622
+ # ps 가 빈 출력(데몬 일시 오류)이거나 모든 서비스가 미생성이면 started 가
623
+ # 비는데, 빈 리스트는 poll_healthcheck 를 vacuous 통과시켜 검증 없이 성공으로
624
+ # 보고된다. fail-open 대신 raise 하되, `up -d` 후 컨테이너가 떠 있을 수도
625
+ # 있으므로(transient ps 실패) deploy-state 를 먼저 써 `down --all` 이 회수
626
+ # 가능하게 한다 — 실제 기동분을 모르니 full services 를 best-effort 로 기록.
627
+ _write_deploy_state(
628
+ cp, project_name=project_name, task_key=task_key, services=services,
629
+ warnings=warnings, watch={"enabled": False, "note": "기동 컨테이너 없음"})
630
+ raise PrepareError(
631
+ f"container up: `up -d` 후 기동된 컨테이너가 없습니다(`docker compose -p "
632
+ f"{project_name} ps -a` 로 확인). compose 정의·활성 profile 을 점검한 뒤 "
633
+ f"`okstra container down --task-key {task_key}` 로 정리하고 다시 시도하세요.")
634
+ failed = poll_healthcheck(
635
+ project_name=project_name, cwd=str(worktree), services=started,
636
+ healthcheck_timeout_seconds=healthcheck_timeout_seconds,
637
+ healthcheck_interval_seconds=healthcheck_interval_seconds)
638
+ if failed:
639
+ # 컨테이너는 이미 떠 있다(unhealthy/느림) — deploy-state 를 먼저 써 둬야
640
+ # `down --all` 스윕(deploy-state.json 있는 task 만 발견)이 회수할 수 있다.
641
+ _write_deploy_state(
642
+ cp, project_name=project_name, task_key=task_key, services=started,
643
+ warnings=warnings, watch={"enabled": False, "note": "healthcheck 미통과"},
644
+ healthcheckFailed=failed)
645
+ raise PrepareError(
646
+ f"container up: 다음 서비스가 시간 내 기동하지 못했습니다: {', '.join(failed)} "
647
+ f"(timeout={healthcheck_timeout_seconds}s). `docker compose -p "
648
+ f"{project_name} logs` 로 원인을 확인한 뒤 `okstra container down "
649
+ f"--task-key {task_key}` 로 정리하세요.")
650
+ # watcher/deploy-state 는 실제 기동된 started 만 대상으로 한다 — full services
651
+ # 를 쓰면 profiles 미기동 서비스에 컨테이너 없는 tail/watcher pane 과 registry
652
+ # 항목이 phantom 으로 생겨 배포 수명 동안 누수된다.
653
+ watch = _spawn_watchers(cp=cp, worktree=worktree, project_name=project_name,
654
+ services=started, session_name=session_name,
655
+ project_root=project_root, inputs=inputs,
656
+ scan_interval_seconds=scan_interval_seconds)
657
+ _write_deploy_state(cp, project_name=project_name, task_key=task_key,
658
+ services=started, warnings=warnings, watch=watch)
659
+ return {"projectName": project_name, "services": started,
660
+ "warnings": warnings, "watch": watch}
661
+
662
+
663
+ def _integrate_source_tree(inputs: dict) -> str:
664
+ """stage 통합(teardown=False)으로 소스트리를 확정하고 worktree 경로를 반환."""
665
+ from .stage_targets import resolve_and_integrate_whole_task
666
+
667
+ resolve_and_integrate_whole_task(
668
+ teardown=False, project_id=inputs["project_id"],
669
+ task_group=inputs["task_group"], task_id=inputs["task_id"],
670
+ task_worktree_path=inputs["worktree_path"], stage_map=inputs["stage_map"],
671
+ done_rows=inputs["done_rows"], anchor_base=inputs["anchor_base"])
672
+ return inputs["worktree_path"]
673
+
674
+
675
+ def _compose_up(
676
+ *, cp: dict, worktree: Path, compose: Path, project_name: str, task_key: str,
677
+ session_name: str, services: list[str], worktree_env: str, override_env: str,
678
+ ) -> list[str]:
679
+ """label override 주입 → compose up → 실제 기동된 서비스명 반환.
680
+
681
+ 반환은 `up -d` 가 실제로 생성한 서비스만(=ps 행 존재) 추린 목록이다 — compose
682
+ `profiles:` 로 비활성화돼 기동되지 않은 서비스를 healthcheck 대기 대상에서
683
+ 빼, 영영 pending 으로 남아 오탐 timeout 을 내지 않게 한다."""
684
+ labels = {
685
+ "okstra.task-key": task_key,
686
+ "okstra.project-name": project_name,
687
+ # run-trace 는 이 기능의 trace 식별자(container session name)로 채워
688
+ # project-name 과 distinct 하게 둔다(M1).
689
+ "okstra.run-trace": session_name,
690
+ }
691
+ label_path = _write_label_override(cp, services, labels)
692
+ argv = build_compose_up_argv(
693
+ project_name=project_name, compose_path=str(compose),
694
+ worktree_env_path=worktree_env, override_env_path=override_env,
695
+ label_override_path=label_path)
696
+ result = subprocess.run(argv, cwd=str(worktree), check=False)
697
+ if result.returncode != 0:
698
+ raise PrepareError(
699
+ f"container up: `docker compose up` 가 실패했습니다(exit "
700
+ f"{result.returncode}). `docker compose -p {project_name} logs` 로 "
701
+ "원인을 확인하세요.")
702
+ return _started_services(project_name, str(worktree), services)
703
+
704
+
705
+ def _started_services(project_name: str, cwd: str, services: list[str]) -> list[str]:
706
+ """`up -d` 로 실제 생성된(running 또는 exited) 서비스만 입력 순서대로 추린다.
707
+
708
+ `-a` 로 종료된 컨테이너까지 본다 — 부팅 직후 크래시-종료한 서비스를 profiles
709
+ 로 아예 안 뜬 서비스와 구분하려는 것. 전자는 ps 행이 남아 healthcheck 대기
710
+ 대상에 포함되고 running 이 아니라 timeout 으로 실패한다(죽은 배포를 성공으로
711
+ 오인 차단). 후자는 `-a` 에도 행이 없어 제외된다."""
712
+ created = set(_group_ps_rows_by_service(
713
+ _compose_ps(project_name, cwd, include_stopped=True)))
714
+ return [s for s in services if s in created]
715
+
716
+
717
+ def _spawn_watchers(
718
+ *, cp: dict, worktree: Path, project_name: str, services: list[str],
719
+ session_name: str, project_root: Path, inputs: dict, scan_interval_seconds: float,
720
+ ) -> dict:
721
+ """tmux 가 있으면 detached 세션 + 서비스별 tail/watcher pane. 없으면 skip.
722
+
723
+ pane split 실패(pane 한도/세션 외부 종료 등)는 watcher 만 degrade 시키고 raise
724
+ 하지 않는다 — 컨테이너는 이미 떠 있으므로 호출자가 deploy-state 를 반드시 써서
725
+ `down --all` 스윕에서 보이게 해야 한다."""
726
+ from . import tmux
727
+
728
+ if not tmux.tmux_available():
729
+ return {"enabled": False, "note": "감시 비활성(tmux 없음)"}
730
+ cp["watchers_dir"].mkdir(parents=True, exist_ok=True)
731
+ started: list[str] = []
732
+ try:
733
+ # holder pane 은 기본 셸로 띄워 오래 살린다(`true` 는 split 전에 세션을
734
+ # 무너뜨림). container 태그를 붙여 down/stop-watcher 의 스코프 reap 로
735
+ # tail/watcher pane 과 함께 회수되게 한다(마지막 pane 회수 시 세션 종료).
736
+ pane = tmux.new_detached_session(session_name, str(worktree))
737
+ tmux.tag_container_pane(pane, str(cp["container_dir"]))
738
+ for svc in services:
739
+ _spawn_service_panes(
740
+ pane=pane, cp=cp, worktree=worktree, project_name=project_name,
741
+ svc=svc, session_name=session_name, project_root=project_root,
742
+ inputs=inputs, scan_interval_seconds=scan_interval_seconds)
743
+ started.append(svc)
744
+ except (RuntimeError, OSError, subprocess.SubprocessError) as exc:
745
+ # watcher spawn 실패(tmux split/RuntimeError, registry flock/디스크 OSError,
746
+ # tmux timeout/SubprocessError)는 watcher 만 degrade 시키고 raise 하지
747
+ # 않는다 — 호출자가 deploy-state 를 반드시 써 컨테이너 leak 을 막는다.
748
+ return {"enabled": True, "session": session_name, "services": started,
749
+ "partial": True, "note": f"watcher 부분 실패: {exc}"}
750
+ return {"enabled": True, "session": session_name, "services": started}
751
+
752
+
753
+ def _spawn_service_panes(
754
+ *, pane: str, cp: dict, worktree: Path, project_name: str, svc: str,
755
+ session_name: str, project_root: Path, inputs: dict, scan_interval_seconds: float,
756
+ ) -> None:
757
+ """한 서비스의 tail pane + watcher loop pane 을 split 하고 registry 에 등록."""
758
+ from . import tmux, container_registry
759
+
760
+ findings = cp["watchers_dir"] / f"{svc}.findings.md"
761
+ tmux.split_container_pane(
762
+ session_pane=pane, cwd=str(worktree),
763
+ command=f"docker compose -p {project_name} logs -f {svc}",
764
+ title=f"tail:{svc}", scope_value=str(cp["container_dir"]), kind="tail")
765
+ wpane = tmux.split_container_pane(
766
+ session_pane=pane, cwd=str(worktree),
767
+ command=build_watcher_pane_command(
768
+ python_exe=sys.executable,
769
+ module_root=str(Path(__file__).resolve().parent.parent),
770
+ project_name=project_name, service=svc, findings_path=str(findings),
771
+ scan_interval_seconds=scan_interval_seconds),
772
+ title=f"watch:{svc}", scope_value=str(cp["container_dir"]), kind="watcher")
773
+ container_registry.reserve(
774
+ project_root, inputs["task_group"], inputs["task_id"], svc,
775
+ session_name=session_name, pane_id=wpane or "", findings_path=str(findings))
776
+
777
+
778
+ def _write_deploy_state(cp: dict, **state) -> None:
779
+ cp["container_dir"].mkdir(parents=True, exist_ok=True)
780
+ cp["deploy_state"].write_text(
781
+ json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
782
+
783
+
784
+ # --------------------------------------------------------------------------- #
785
+ # status / logs / stop-watcher / down
786
+ # --------------------------------------------------------------------------- #
787
+
788
+ def status_container_group(*, project_root, task_key: str) -> dict:
789
+ """라벨 쿼리(컨테이너 존재 SSOT) + registry(보조 watcher 메타) 합본."""
790
+ from . import container_registry
791
+ from .ids import compose_project_name
792
+
793
+ project_id, group, task_id = _split_task_key(task_key)
794
+ project_name = compose_project_name(project_id, group, task_id)
795
+ containers = query_containers_by_label(project_name)
796
+ registry = container_registry.lookup(Path(project_root), group, task_id)
797
+ return {"projectName": project_name, "containers": containers,
798
+ "watchers": registry.get("services", {})}
799
+
800
+
801
+ def logs_container_group(*, project_root, task_key: str, service: str = "") -> dict:
802
+ """deploy-state + watcher findings 경로를 안내(로그 자체는 tmux pane 에 있음)."""
803
+ from . import paths, container_registry
804
+
805
+ project_id, group, task_id = _split_task_key(task_key)
806
+ cp = paths.container_paths(Path(project_root), group, task_id)
807
+ services = container_registry.lookup(Path(project_root), group, task_id).get(
808
+ "services", {})
809
+ if service:
810
+ services = {service: services[service]} if service in services else {}
811
+ return {"watchersDir": str(cp["watchers_dir"]), "watchers": services}
812
+
813
+
814
+ def stop_watcher_group(*, project_root, task_key: str) -> dict:
815
+ """watcher/tail pane(@okstra_container_run) 만 회수하고 컨테이너는 살려둔다."""
816
+ project_id, group, task_id = _split_task_key(task_key)
817
+ reaped = _reap_container_panes(Path(project_root), group, task_id)
818
+ return {"reapedPanes": reaped, "note": "컨테이너는 유지됨(감시만 종료)"}
819
+
820
+
821
+ def down_container_group(*, project_root, task_key: str = "", all_groups: bool = False) -> dict:
822
+ """라벨 쿼리로 컨테이너 down + tmux 실측으로 orphan watcher 회수.
823
+
824
+ registry 는 보조 인덱스라 누락돼도 'watcher 없음'을 의미하지 않는다 — tmux 를
825
+ 직접 스캔(@okstra_container_run)해 orphan pane 까지 reap 한다(spec §11).
826
+ pane 회수는 항상 현재 project-root 스코프로 한정한다 — 동시 세션이 흔한
827
+ 레포라 다른 task/프로젝트의 watcher pane 을 절대 죽이지 않는다(I2)."""
828
+ from . import paths
829
+ from .ids import compose_project_name
830
+
831
+ project_root = Path(project_root)
832
+ results = []
833
+ for project_id, group, task_id in _down_targets(project_root, task_key, all_groups):
834
+ project_name = compose_project_name(project_id, group, task_id)
835
+ # `docker rm -f` 는 컨테이너만 지우고 compose 가 만든 네트워크를 누수시킨다.
836
+ # compose 정본 teardown 으로 프로젝트(라벨 com.docker.compose.project=<name>)
837
+ # 단위 컨테이너+네트워크를 회수한다. `-v` 는 의도적으로 빼서 compose 에
838
+ # 선언된 named volume(예: DB 데이터)을 보존한다 — down 한 번에 사용자
839
+ # 영속 데이터를 파괴하지 않는다.
840
+ _docker_run(["docker", "compose", "-p", project_name, "down",
841
+ "--remove-orphans"])
842
+ reaped = _reap_container_panes(project_root, group, task_id)
843
+ results.append({"projectName": project_name, "reapedPanes": reaped})
844
+ # registry drift 로 등록 안 된 orphan pane 회수는 `--all`(이 프로젝트 전체)
845
+ # 때만, 그것도 **이 project-root 의 .okstra/ prefix** 로만 스코프한다. 단일
846
+ # down 은 위 per-task 회수로 끝낸다(글로벌 sweep 금지 — 동시 세션 간섭 차단).
847
+ orphans = _reap_project_orphan_panes(project_root) if all_groups else []
848
+ return {"downed": results, "orphanPanesReaped": orphans}
849
+
850
+
851
+ def _reap_project_orphan_panes(project_root: Path) -> list[str]:
852
+ prefix = str((project_root / ".okstra").resolve())
853
+ return _kill_panes_by_tag(
854
+ lambda tag: tag == prefix or tag.startswith(prefix + os.sep))
855
+
856
+
857
+ def _down_targets(project_root, task_key: str, all_groups: bool) -> list[tuple[str, str, str]]:
858
+ if all_groups:
859
+ return _discover_container_task_keys(Path(project_root))
860
+ if not task_key:
861
+ raise PrepareError("container down: task-key 또는 --all 중 하나가 필요합니다.")
862
+ return [_split_task_key(task_key)]
863
+
864
+
865
+ def _discover_container_task_keys(project_root: Path) -> list[tuple[str, str, str]]:
866
+ """프로젝트 안에서 container deploy-state 가 있는 task-key 들을 찾는다."""
867
+ from okstra_project.dirs import project_json_path, tasks_root as _tasks_root
868
+
869
+ project_id = _read_project_id(project_json_path(project_root))
870
+ tasks_root = _tasks_root(project_root)
871
+ out: list[tuple[str, str, str]] = []
872
+ if not tasks_root.is_dir():
873
+ return out
874
+ for group_dir in tasks_root.iterdir():
875
+ for task_dir in (group_dir.iterdir() if group_dir.is_dir() else []):
876
+ if (task_dir / "container" / "deploy-state.json").is_file():
877
+ out.append((project_id, group_dir.name, task_dir.name))
878
+ return out
879
+
880
+
881
+ def _read_project_id(project_json: Path) -> str:
882
+ if not project_json.is_file():
883
+ return ""
884
+ try:
885
+ return json.loads(project_json.read_text(encoding="utf-8")).get("projectId", "")
886
+ except (OSError, json.JSONDecodeError):
887
+ return ""
888
+
889
+
890
+ def _reap_container_panes(project_root: Path, group: str, task_id: str) -> list[str]:
891
+ """이 task 의 container_dir 태그를 가진 pane 만 회수한다(task 스코프)."""
892
+ from . import paths, container_registry
893
+ cp = paths.container_paths(project_root, group, task_id)
894
+ container_dir = str(cp["container_dir"].resolve())
895
+ container_registry.clear(project_root, group, task_id)
896
+ # tail/watcher pane 은 container_dir 로, analyze pane 은 그 하위 findings 경로로
897
+ # 태그된다 — 둘 다 회수하되 prefix 가 task 의 container_dir 라 스코프는 유지된다.
898
+ return _kill_panes_by_tag(
899
+ lambda tag: tag == container_dir or tag.startswith(container_dir + os.sep))
900
+
901
+
902
+ def _kill_panes_by_tag(match: Callable[[str], bool]) -> list[str]:
903
+ """`@okstra_container_run` 태그값이 ``match`` 를 통과하는 pane 만 tmux 로 kill.
904
+
905
+ 태그값은 해당 task 의 container_dir 절대경로다. 무조건 글로벌 sweep 은 동시
906
+ 세션 간섭을 일으키므로 호출자가 항상 스코프 predicate 를 준다(I2)."""
907
+ from . import tmux
908
+ if not tmux.tmux_available():
909
+ return []
910
+ result = tmux.run_tmux(
911
+ ["list-panes", "-a", "-F", "#{pane_id}\t#{@okstra_container_run}"])
912
+ if result.returncode != 0:
913
+ return []
914
+ killed: list[str] = []
915
+ for line in result.stdout.splitlines():
916
+ parts = line.split("\t")
917
+ if len(parts) == 2 and parts[0] and parts[1] and match(parts[1]):
918
+ tmux.kill_pane(parts[0])
919
+ killed.append(parts[0])
920
+ return killed
921
+
922
+
923
+ # --------------------------------------------------------------------------- #
924
+ # argparse 디스패치(recap.py 패턴)
925
+ # --------------------------------------------------------------------------- #
926
+
927
+ def main(argv: list[str] | None = None) -> int:
928
+ parser = argparse.ArgumentParser(prog="okstra container")
929
+ sub = parser.add_subparsers(dest="command", required=True)
930
+ for name in ("up", "status", "logs", "stop-watcher", "down"):
931
+ sp = sub.add_parser(name)
932
+ sp.add_argument("--project-root", required=True)
933
+ sp.add_argument("--task-key", default="")
934
+ if name == "down":
935
+ sp.add_argument("--all", action="store_true")
936
+ if name == "logs":
937
+ sp.add_argument("--service", default="")
938
+ watch = sub.add_parser("watch") # watcher pane 내부에서 도는 장기 루프
939
+ watch.add_argument("--project-name", required=True)
940
+ watch.add_argument("--service", required=True)
941
+ watch.add_argument("--findings", required=True)
942
+ watch.add_argument("--scan-interval", type=float, default=5.0)
943
+ args = parser.parse_args(argv)
944
+ if args.command == "watch":
945
+ run_service_watcher(
946
+ project_name=args.project_name, service=args.service,
947
+ findings_path=args.findings, scan_interval_seconds=args.scan_interval)
948
+ return 0
949
+ result = _dispatch(args)
950
+ print(json.dumps(result, ensure_ascii=False, indent=2))
951
+ return 0
952
+
953
+
954
+ def _dispatch(args) -> dict:
955
+ pr = args.project_root
956
+ if args.command == "up":
957
+ return provision_container_group(project_root=pr, task_key=args.task_key)
958
+ if args.command == "status":
959
+ return status_container_group(project_root=pr, task_key=args.task_key)
960
+ if args.command == "logs":
961
+ return logs_container_group(
962
+ project_root=pr, task_key=args.task_key, service=args.service)
963
+ if args.command == "stop-watcher":
964
+ return stop_watcher_group(project_root=pr, task_key=args.task_key)
965
+ return down_container_group(
966
+ project_root=pr, task_key=args.task_key, all_groups=args.all)
967
+
968
+
969
+ if __name__ == "__main__":
970
+ raise SystemExit(main(sys.argv[1:]))