fix(studio-save): 응답 껍데기를 벗기는 자리를 코드가 갖고, 계획마다 새 키가 나게 한다

① GET /documents/{id} 는 문서를 data.document 아래에 둔다(studio-v1.yaml:1831). A 가
   B-011 에서 data 를 문서로 보고 견줘 칸이 전부 「다르다」로 나왔다 — 벗기는 자리를
   코드가 갖고 있지 않아서 난 일이라 unwrap_document 를 둔다. 모양이 다르면 조용히
   넘기지 않고 거절한다. 잘못된 층을 견주는 것보다 멈추는 편이 낫다.

   그리고 updatedAt 을 서버가 붙이는 칸으로 옮겼다. 매 저장마다 「서버가 더 줬다」로
   나오면 그 칸을 아무도 안 읽게 된다. variantIds 는 안 옮겼다 — 계약이 입력으로 받는
   칸인데 이 어댑터가 안 보내므로 보여야 한다.

② CR-007 때문에 같은 키를 두 번 못 쓴다. 런 식별자를 계획 파일에 적고 키에 섞는다.
   같은 계획을 두 번 보내면 같은 키, 계획을 새로 만들면 다른 키다. 보낼 때 만들면
   재시도가 곧 중복 생성이 되므로 만드는 자리는 계획을 짜는 순간 하나뿐이다.
   --run-id 를 안 주면 예전 키 모양 그대로라 옛 계획을 안 깬다.

python3 -m unittest discover -s scripts/tests — Ran 291 · OK (skipped=13)
PIPELINE CONTRACT: PASS

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q4vKjQo9KKBBokzxqXLCfk
This commit is contained in:
DongHyeonka
2026-09-11 13:18:30 +09:00
co-authored by Claude Opus 5
parent 1be1c8ea6d
commit d473609e0a
2 changed files with 148 additions and 7 deletions
+80
View File
@@ -7,10 +7,18 @@ import importlib.util
import json
import os
import subprocess
import sys
import tempfile
import unittest
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 런 식별자 회귀가 실제로 계획을 내 보려고 쓰는 입력. 관문이 전부 exit 0 인 프로젝트다
DECISION_RECORD = os.path.join(
"docs", "n+1liner", "tech-log-studio", "jpa-feed-query-performance", "decision",
"decision-keyset-for-feed-pagination.md")
PACKAGE = "/home/donghyeon/workspace/chat-gpt-container/success/reports/"\
"B-B011-plans/package.json"
PROJECT_ID = "8ff68a93-0cf6-4d91-a973-40e9f3d3e99d"
_spec = importlib.util.spec_from_file_location(
"studio_save", os.path.join(ROOT, "scripts", "studio-save.py"))
ss = importlib.util.module_from_spec(_spec)
@@ -902,6 +910,78 @@ class HarnessTestPlanTest(unittest.TestCase):
q = {"kind": "QUESTION", "facts": [{"id": "a", "text": "t", "order": 0}]}
self.assertNotIn("itemIdBehaviourUnmeasured", ss.compare_saved(q, dict(q)))
def test_the_envelope_is_unwrapped_before_comparing(self):
"""`GET /documents/{id}` 는 문서를 `data.document` 아래에 둔다
(`studio-v1.yaml:1831`). A 가 `data` 를 문서로 보고 견줘 칸이 전부 「다르다」로
나왔다 — **껍데기를 벗기는 자리를 코드가 갖고 있지 않아서 난 일이다.**"""
doc = {"kind": "PROJECT_DECISION", "title": "t", "statement": "s"}
env = {"success": True, "meta": {},
"data": {"document": doc, "currentPublication": None,
"currentValidation": None, "latestPreview": None,
"dependencyRevision": {}, "nextAction": "VALIDATE"}}
got, detail = ss.unwrap_document(env)
self.assertEqual(doc, got)
self.assertTrue(ss.is_private(detail)[0])
# 벗기면 같고, 안 벗기면 전부 다르다
self.assertTrue(ss.compare_saved(doc, got)["same"])
self.assertFalse(ss.compare_saved(doc, env["data"])["same"])
# `data` 만 줘도 받는다
self.assertEqual(doc, ss.unwrap_document(env["data"])[0])
def test_an_unexpected_envelope_is_refused_not_compared(self):
"""모양이 다르면 조용히 넘기지 않는다. 잘못된 층을 견주는 것보다 멈추는 편이 낫다 —
그 실수는 「전부 다르다」로 크게 나와 줄 때만 눈에 띈다."""
for bad in ({"success": True, "data": {"nextAction": "X"}},
{"success": True, "data": {"document": "문자열이다"}},
"객체가 아니다"):
with self.subTest(bad=str(bad)[:32]):
with self.assertRaises(ss.Refused):
ss.unwrap_document(bad)
def test_server_assigned_fields_are_not_reported_as_unexpected(self):
"""`updatedAt` 은 서버가 붙인다. 매 저장마다 「서버가 더 줬다」로 나오면
그 칸을 아무도 안 읽게 된다. **`variantIds` 는 다르다** — 계약이 입력으로 받는데
이 어댑터가 안 보내는 칸이라 보여야 한다."""
sent = {"kind": "CASE", "title": "t"}
got = {**sent, "id": "x", "version": 2, "updatedAt": "2026-09-11T00:00:00Z",
"variantIds": []}
self.assertEqual(["variantIds"], ss.compare_saved(sent, got)["unexpected"])
def test_a_new_plan_gets_a_new_key_but_one_plan_keeps_its_own(self):
"""`CR-007` 때문에 같은 키를 두 번 못 쓴다. 그래서 계획을 새로 만들면 새 키여야
한다 — 그런데 **보낼 때 만들면 재시도가 곧 중복 생성이 된다.**
런 식별자를 계획에 적어 그 둘을 함께 얻는다."""
rel = "docs/p/t/case/x.md"
a = ss.idempotency_key("create", rel, None, "run-aaa")
b = ss.idempotency_key("create", rel, None, "run-bbb")
self.assertNotEqual(a, b) # 계획이 새로 만들어지면 새 키
self.assertEqual(a, ss.idempotency_key("create", rel, None, "run-aaa"))
# 같은 계획을 두 번 보내는 것은 같은 값을 두 번 쓰는 것이다
self.assertLessEqual(len(a), ss.MAX_KEY_LENGTH)
# 런 식별자를 안 주면 예전 모양 그대로다 — 옛 계획을 깨지 않는다
self.assertEqual(ss.idempotency_key("create", rel),
ss.idempotency_key("create", rel, None, None))
def test_the_plan_records_its_run_id(self):
"""**보낼 때 만들지 않는다.** 계획 파일에 적혀 있어야 같은 계획이 같은 키를 쓴다."""
if not os.path.isfile(PACKAGE):
# 묶음은 worktree 밖에 있다. 없으면 **안 돌았다고 적고 넘어간다** —
# 조용히 통과시키면 이 회귀가 초록인 채로 아무것도 안 재게 된다
self.skipTest(f"묶음이 없어 계획을 못 낸다: {PACKAGE}")
with tempfile.TemporaryDirectory() as d:
out = os.path.join(d, "plan.json")
r = subprocess.run(
[sys.executable, "scripts/studio-save.py", "--record", DECISION_RECORD,
"--package", PACKAGE, "--harness-test", "--project-id", PROJECT_ID,
"--run-id", "run-fixed-for-the-test", "-o", out],
cwd=ROOT, capture_output=True, text=True)
self.assertEqual(0, r.returncode, r.stderr)
plan = json.load(open(out, encoding="utf-8"))
self.assertEqual("run-fixed-for-the-test", plan["runId"])
key = next(s["headers"]["Idempotency-Key"] for s in plan["requests"]
if s["op"] == "create")
self.assertTrue(key.endswith(ss._sha256_text("run-fixed-for-the-test")[:12]))
def test_a_harness_plan_still_has_no_publish_path(self):
steps = ss.plan_requests("docs/p/t/case/x.md", dict(SENT), None, None,
harness_test=True)