From 32a369f33501f82fe2509f370caa3d4edd86b105 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Thu, 10 Sep 2026 11:13:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(scripts):=20Studio=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EC=96=B4=EB=8C=91=ED=84=B0=20=E2=80=94=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EB=A5=BC=20=EC=BD=94=EB=93=9C=EA=B0=80=20=EB=A7=89=EA=B3=A0=20?= =?UTF-8?q?=EB=AC=B4=EC=9D=B8=20=EC=A0=80=EC=9E=A5=EC=9D=80=20=EA=BA=BC=20?= =?UTF-8?q?=EB=91=94=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버 계약은 tech-log-backend @ a000f87 의 openapi 와 컨트롤러에서 읽었다. Idempotency-Key 는 필수이고 200자 이하다. expectedVersion 은 스키마가 minimum 1 로 못 박으므로 빈 값은 「검사 안 함」이 아니라 「반드시 충돌」이다. 멱등 키를 기록 경로에서 유도한다. 프런트는 호출마다 새 uuid 를 만들어서 재시도가 문서를 둘 만든다. 만들기는 경로만으로 키를 만들어 몇 번을 다시 시도해도 문서가 하나이고, 저장은 보낼 내용을 키에 넣는다 — 경로만 쓰면 두 번째 저장이 첫 결과로 조용히 재생된다. 게시 요청을 만드는 코드가 없고, 계획이 게시 경로를 가리키면 거절한다. 지금까지 게시를 막고 있던 것은 스킬 문서의 문장 하나였다. 무인 저장은 꺼져 있다. 서버 권한이 studio:read·studio:write 둘뿐이라 저장 계정이 게시도 할 수 있다. 켜는 것은 studio:publish 가 갈라진 뒤의 판단이지 이 파일의 기본값이 아니다. 저장 뒤 되읽어 견줄 때 문자열 하나로 판정하지 않는다. 정규화가 지운 차이를 통과로 세면 비교 틀이 결함을 만들어 내거나 지운다. 정규화 뒤에만 같아지는 것은 whitespaceOnly 로, 되읽은 값이 보낸 값의 앞부분인 것은 truncated 로 따로 낸다. 못 보는 칸은 사유와 함께 적는다 — 조용히 건너뛰면 「전부 같다」가 「본 것만 같다」를 가린다. 회귀의 첫 줄은 대조군이다. 손대지 않은 쌍이 「같다」로 나오지 않으면 나머지 「잡았다」가 전부 틀 탓이다. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk --- scripts/studio-save.py | 375 ++++++++++++++++++++++++++++++ scripts/tests/test_studio_save.py | 194 ++++++++++++++++ 2 files changed, 569 insertions(+) create mode 100644 scripts/studio-save.py create mode 100644 scripts/tests/test_studio_save.py diff --git a/scripts/studio-save.py b/scripts/studio-save.py new file mode 100644 index 0000000..91aea80 --- /dev/null +++ b/scripts/studio-save.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +"""Studio 저장 어댑터. 승인된 최종본만 넣고, 저장한 뒤 되읽어 대조한다. + +**게시하지 않는다.** 이 파일에 게시 요청을 만드는 코드가 없고, 만들어 낸 요청이 게시 경로를 +가리키면 `_reject_forbidden_paths` 가 막는다. 스킬 문서의 「게시 버튼을 누르지 않는다」는 +문장 하나로 막던 것을 코드로 옮긴 것이다. + +**무인 저장은 꺼져 있다.** `UNATTENDED_SAVE_ENABLED` 가 `False` 다. 서버 권한이 +`studio:read`·`studio:write` 둘뿐이라 저장할 수 있는 계정은 게시도 할 수 있고, 그래서 +사람이 안 보는 사이에 저장을 돌리지 않는다. 이 값을 `True` 로 바꾸는 것은 서버에 +`studio:publish` 가 갈라져 들어온 뒤의 일이다. + +**자격증명을 이 저장소에 적지 않는다.** 세션 쿠키와 CSRF 토큰은 환경 변수로만 받는다. + +서버 계약 (tech-log-backend @ a000f87 의 src/config/openapi/studio-v1.yaml 과 소스에서 읽었다) + +| 무엇 | 값 | +|---|---| +| 만들기 | `POST /api/v1/studio/documents` → 201 | +| 읽기 | `GET /api/v1/studio/documents/{id}` | +| 저장 | `PUT /api/v1/studio/documents/{id}` → 200 | +| 멱등 키 | `Idempotency-Key` 헤더 **필수**, 200자 이하 (`StudioIdempotency.java:34,77-88`) | +| 재생 여부 | 응답 헤더 `Idempotency-Replayed` | +| 낙관적 락 | `SaveDocumentCommand.expectedVersion` **필수 · minimum 1** | +| 충돌 | `VERSION_CONFLICT` 409, `details.latestDocument` 에 현재 문서 전체 | +| 인증 | 쿠키 `TECHLOG_SESSION` + 변경 요청에 `X-CSRF-TOKEN` | + + python3 scripts/studio-save.py --record <기록.md> --package + python3 scripts/studio-save.py --record <기록.md> --package -o +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# 서버에 studio:publish 가 갈라져 들어오기 전에는 무인 저장을 켜지 않는다. +# 켜는 조건은 사람의 판단이지 이 파일의 기본값이 아니다 (A-studio-change-requests.md CR-001). +UNATTENDED_SAVE_ENABLED = False +UNATTENDED_SAVE_BLOCKED_BY = ( + "CR-001 — 서버 권한이 studio:read·studio:write 둘뿐이라 저장 계정이 게시도 할 수 있다. " + "studio:publish 가 갈라지기 전에는 사람이 보는 앞에서만 저장한다" +) + +BASE_PATH = "/api/v1/studio" +FORBIDDEN_PATH = re.compile(r"/(publish|unpublish)(/|$)") +MAX_KEY_LENGTH = 200 # StudioIdempotency.java:34 +IDEMPOTENCY_HEADER = "Idempotency-Key" +REPLAYED_HEADER = "Idempotency-Replayed" + +# 기록의 `##` 이름 → Studio 입력 칸. studio-form-map.md 가 정본이다 +FIELD_MAP = { + "CASE": {"문제": "problem", "결론": "conclusion", "검증 환경": "environment", + "재현 조건": "reproduction", "본문": "bodyMarkdown"}, + "CONCEPT": {"본문": "bodyMarkdown"}, + "REFERENCE": {"목적": "purpose", "규칙": "rules", "적용 조건": "appliesWhen", + "예외": "exceptions", "예시": "examples"}, + "QUESTION": {"사실": "facts", "가정": "assumptions", "미지수": "unknowns", + "제약": "constraints", "선택지": "options", "다음 검증": "nextVerification"}, + "PROJECT_DECISION": {"근거": "basis", "결정문": "decision", "판단 이유": "rationale", + "영향": "impact"}, +} +BODY_START, BODY_END = "", "" + + +class Refused(Exception): + """어댑터가 스스로 거절한 것. 서버 오류가 아니다.""" + + +def _sha256_file(path: str) -> str: + with open(path, "rb") as fh: + return hashlib.sha256(fh.read()).hexdigest() + + +def _sha256_text(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def idempotency_key(op: str, record_rel: str, payload: dict | None = None) -> str: + """기록 경로에서 유도한 안정 키. + + 프런트는 호출마다 `crypto.randomUUID()` 로 새 키를 만든다(`local-id.ts:8`). 그래서 + 「만들다 타임아웃 → 다시 시도」가 문서를 둘 만든다. 서버의 멱등 기능이 정확히 막으라고 + 만들어진 상황을 키 정책이 빠져나간다. + + - **만들기**는 경로만으로 키를 만든다. 몇 번을 다시 시도해도 같은 키라 문서가 하나다. + - **저장**은 경로와 보낼 내용을 함께 넣는다. 같은 내용의 재시도는 서버가 첫 결과를 + 재생하고, 내용이 바뀌면 새 저장이 된다. 경로만 쓰면 두 번째 저장이 첫 결과로 + 조용히 재생돼 버린다. + """ + base = _sha256_text(record_rel)[:32] + key = f"studio-{op}-{base}" + if payload is not None: + key += "-" + _sha256_text(json.dumps(payload, ensure_ascii=False, sort_keys=True))[:32] + if len(key) > MAX_KEY_LENGTH: # 서버가 200자 초과를 거절한다 + raise Refused(f"멱등 키가 {len(key)}자다. 서버 상한은 {MAX_KEY_LENGTH}자") + return key + + +def _front_matter(text: str) -> dict: + if not text.startswith("---"): + return {} + end = text.find("\n---", 3) + out: dict[str, str] = {} + for line in text[3:end].splitlines(): + m = re.match(r"^([a-zA-Z_]+):\s*(.*)$", line) + if m: + out[m.group(1)] = m.group(2).strip().strip('"') + return out + + +def _sections(text: str) -> dict[str, str]: + a, b = text.find(BODY_START), text.find(BODY_END) + marks = [] + for m in re.finditer(r"^##\s+(.+)$", text, re.M): + if a >= 0 <= b and a < m.start() < b: + continue + marks.append((m.group(1).strip(), m.end())) + out: dict[str, str] = {} + for i, (name, start) in enumerate(marks): + stop = marks[i + 1][1] - len(f"## {marks[i + 1][0]}") if i + 1 < len(marks) else len(text) + chunk = text[start:stop] + if name == "본문": + chunk = chunk.replace(BODY_START, "").replace(BODY_END, "") + out[name] = chunk.strip() + return out + + +def _summary(text: str) -> str: + m = re.search(r"^#\s+.+$", text, re.M) + if not m: + return "" + after = re.split(r"^##\s", text[m.end():], maxsplit=1, flags=re.M)[0] + for para in (p.strip() for p in after.split("\n\n")): + if para and not para.startswith("