Files

467 lines
18 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Append-only acceptance / supersession EVENT log (P1-G, finding #14).
문제: report 는 불변 SNAPSHOT 이지만 CompletionRecord 상태(Submitted → Accepted /
Changes-Requested / Blocked)는 "바뀐다"고만 서술돼 있어서, 별도의 수락 이벤트나
supersedes 모델이 없었다. 결과: 어느 스냅샷이 최신 시도인지·무엇이 수락됐는지 알 수
없고, 태그 검색이 이미 대체된(superseded) 낡은 결정을 그대로 노출한다.
해법: report 스냅샷은 그대로 불변으로 두고, 상태 변화를 append-only EVENT 로 옮긴다.
- 각 리뷰 결정(수락/변경요청/차단)은 이 원장에 한 줄(JSON) 이벤트로 append 된다.
- 리포트는 절대 수정되지 않는다(불변 스냅샷 + append-only 이벤트).
- "이 workflow/role 의 최신 수락 리포트는?" / "이 리포트는 대체됐나?" 를 이벤트로 질의.
원장 위치: <state_dir>/acceptance-events.jsonl (한 줄 = JSON 이벤트)
이벤트 필드:
{
"acceptance-event-id": "ae-<UTCstamp>-<hex>",
"report-id": "<결정 대상 report-id>",
"decision": "accepted" | "changes-requested" | "blocked",
"accepted-report-id": "<accepted 시 = report-id>",
"rejected-report-id": "<changes-requested/blocked 시 = report-id>",
"supersedes-report-id": "<선택: 이 리포트가 대체하는 이전 report-id>",
"workflow-id": "<질의 필터용>",
"role-id": "<질의 필터용>",
"effective-at": "2026-07-10T12:00:00Z"
}
CLI:
state_engine.py review-artifact --workflow WF --report PATH --decision accepted --reviewer ROLE
[--supersedes PRIOR-RID] # 권장/신뢰 경로(id+sha+권한+self-review 검증)
acceptance_log.py append --report-id RID --decision accepted --workflow WF --reviewer ROLE
[--supersedes PRIOR-RID] # 위 API로 위임하는 호환 entrypoint
acceptance_log.py latest-accepted --workflow WF [--role ROLE] # 최신 수락 report-id 출력
acceptance_log.py is-superseded --report-id RID # yes/no 출력
acceptance_log.py excluded # 대체/거부된 report-id 목록
Import-safe API (재사용):
read_events() -> list[dict]
validate(event) -> list[str] # 위반 사유(빈 리스트 = 통과), 예외 없음
latest_accepted(workflow, role) -> str|None
is_superseded(report_id) -> bool
excluded_report_ids() -> set[str] # report_tags 가 기본 제외할 대상(대체 ∪ 거부)
query(kind, **kwargs) # 위 질의들의 디스패처
강건성 계약(fail-safe):
- 절대 파이프라인을 크래시시키지 않는다. 질의/읽기는 어떤 예외에도 안전값으로 degrade.
- workspace 미설정/파일 부재 -> 질의는 안전값, append/review는 fail-closed.
"""
import json
import os
import sys
import uuid
from datetime import datetime, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
DECISIONS = ("accepted", "changes-requested", "blocked")
REJECTING = ("changes-requested", "blocked")
def _log(msg):
try:
sys.stderr.write(f"[acceptance_log] {msg}\n")
except Exception:
pass
def _events_path(create=False):
"""<state_dir>/acceptance-events.jsonl. workspace 미해석이면 None(조용히 degrade)."""
try:
import _workspace as W # noqa: E402
sd = W.state_dir()
except Exception as e: # WorkspaceNotSetError 포함
_log(f"workspace 미해석 — 이벤트 로그 스킵: {e}")
return None
try:
if create:
os.makedirs(sd, exist_ok=True)
except Exception as e:
_log(f"state_dir 생성 실패: {e}")
return None
return os.path.join(sd, "acceptance-events.jsonl")
def _now():
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def _resolve_report_path(report_id, workflow=None):
"""report-id -> completion-records/<wf>/<report-id>.report.yaml 실존 경로(없으면 None).
finding P0-4c: 수락 이벤트가 실존 report 만 참조하도록 하는 데 쓴다(ghost acceptance 차단)."""
import glob
try:
import _workspace as W # noqa: E402
cr = W.records_dir()
except Exception:
return None
rid = str(report_id)
fname = rid if rid.endswith(".report.yaml") else f"{rid}.report.yaml"
if workflow:
p = os.path.join(cr, str(workflow), fname)
if os.path.exists(p):
return p
hits = glob.glob(os.path.join(cr, "**", fname), recursive=True)
return hits[0] if hits else None
# ---------------------------------------------------------------- read / write
def read_events():
"""원장의 모든 이벤트를 파일(append) 순서로 반환. 어떤 예외에도 [] 로 degrade."""
p = _events_path(create=False)
if not p or not os.path.exists(p):
return []
out = []
try:
with open(p, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
if isinstance(obj, dict):
out.append(obj)
except Exception:
continue # malformed 한 줄은 건너뛴다(원장을 무너뜨리지 않음)
except Exception as e:
_log(f"원장 읽기 실패: {e}")
return []
return out
def validate(event):
"""이벤트 구조 검증. 위반 사유 문자열 리스트 반환(빈 리스트 = 통과). 예외를 던지지 않는다."""
errs = []
try:
if not isinstance(event, dict):
return ["event 는 dict 여야 한다"]
if not event.get("report-id"):
errs.append("report-id 누락")
dec = event.get("decision")
if dec not in DECISIONS:
errs.append(f"decision 은 {DECISIONS} 중 하나여야 한다 (got: {dec!r})")
if not event.get("acceptance-event-id"):
errs.append("acceptance-event-id 누락")
if not event.get("effective-at"):
errs.append("effective-at 누락")
except Exception as e: # 방어적 — validate 는 결코 크래시하지 않는다
return [f"validate 내부 오류: {e}"]
return errs
def build_event(report_id, decision, workflow=None, role=None, supersedes=None,
accepted_report_id=None, rejected_report_id=None, effective_at=None,
report_sha256=None, artifact_kind=None, producer_role_id=None,
reviewer=None, authorization=None):
"""이벤트 dict 를 만든다(파일 기록은 하지 않음). accepted/rejected 는 미지정 시 파생."""
dec = (decision or "").strip().lower()
ev = {
"acceptance-event-id": f"ae-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}-{uuid.uuid4().hex[:8]}",
"report-id": report_id,
"decision": dec,
"effective-at": effective_at or _now(),
}
if workflow:
ev["workflow-id"] = workflow
if role:
ev["role-id"] = role
if supersedes:
ev["supersedes-report-id"] = supersedes
if report_sha256:
ev["report-sha256"] = report_sha256
ev["artifact-sha256"] = report_sha256
if artifact_kind:
ev["artifact-kind"] = artifact_kind
if producer_role_id:
ev["producer-role-id"] = producer_role_id
if reviewer:
ev["reviewer"] = reviewer
if authorization:
ev["authorization"] = authorization
# accepted/rejected 파생(명시 override 우선)
if accepted_report_id:
ev["accepted-report-id"] = accepted_report_id
elif dec == "accepted":
ev["accepted-report-id"] = report_id
if rejected_report_id:
ev["rejected-report-id"] = rejected_report_id
elif dec in REJECTING:
ev["rejected-report-id"] = report_id
return ev
def append_event(event):
"""이벤트를 원장에 append. 성공 True / degrade False. 절대 크래시하지 않는다."""
p = _events_path(create=True)
if not p:
return False
try:
with open(p, "a", encoding="utf-8") as fh:
try:
import fcntl
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
except Exception:
pass
fh.write(json.dumps(event, ensure_ascii=False) + "\n")
fh.flush()
os.fsync(fh.fileno())
try:
import fcntl
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
except Exception:
pass
return True
except Exception as e:
_log(f"이벤트 append 실패: {e}")
return False
# ---------------------------------------------------------------- queries
def effective_decision(workflow_id, artifact_id, artifact_sha256=None):
"""Return the latest effective decision for one immutable revision.
An accepted event without the requested sha is not valid for a sha-bound
query. A later superseding revision invalidates the old artifact even if
its historical accepted event remains in the append-only ledger.
"""
result = None
for ev in read_events():
if ev.get("workflow-id") != workflow_id:
continue
if ev.get("supersedes-report-id") == artifact_id:
result = "Superseded"
continue
rid = ev.get("report-id")
if rid != artifact_id:
continue
if artifact_sha256 is not None:
event_sha = ev.get("artifact-sha256") or ev.get("report-sha256")
if event_sha != artifact_sha256:
continue
result = {
"accepted": "Accepted",
"changes-requested": "ChangesRequested",
"blocked": "Blocked",
}.get(ev.get("decision"))
return result
def is_effectively_accepted(workflow_id, artifact_id, artifact_sha256=None):
return effective_decision(workflow_id, artifact_id, artifact_sha256) == "Accepted"
def latest_accepted(workflow=None, role=None):
"""Latest still-effective accepted report id (not stale/rejected/superseded)."""
result = None
for ev in read_events():
if ev.get("decision") != "accepted":
continue
if workflow is not None and ev.get("workflow-id") != workflow:
continue
if role is not None and ev.get("role-id") != role:
continue
rid = ev.get("accepted-report-id") or ev.get("report-id")
sha = ev.get("artifact-sha256") or ev.get("report-sha256")
if effective_decision(ev.get("workflow-id"), rid, sha) == "Accepted":
result = rid
return result
def latest_accepted_artifact(workflow, *, producer_role=None, artifact_kind=None):
"""Return the latest effective acceptance event by artifact identity.
``role-id`` is the reviewer in canonical events. Handoff consumers must join
on ``producer-role-id`` and ``artifact-kind`` instead.
"""
result = None
for ev in read_events():
if ev.get("decision") != "accepted" or ev.get("workflow-id") != workflow:
continue
if producer_role is not None and str(ev.get("producer-role-id") or "").upper() != str(producer_role).upper():
continue
if artifact_kind is not None and ev.get("artifact-kind") != artifact_kind:
continue
rid = ev.get("accepted-report-id") or ev.get("report-id")
sha = ev.get("artifact-sha256") or ev.get("report-sha256")
if effective_decision(workflow, rid, sha) == "Accepted":
result = ev
return result
def _latest_decision_by_report(events):
latest = {}
for ev in events:
rid = ev.get("report-id")
if rid:
latest[rid] = ev.get("decision")
return latest
def superseded_report_ids():
"""어떤 이벤트에서 supersedes-report-id 로 지목된(=새 스냅샷이 대체한) report-id 집합."""
out = set()
for ev in read_events():
sup = ev.get("supersedes-report-id")
if sup:
out.add(sup)
return out
def is_superseded(report_id):
"""report_id 가 더 새로운 스냅샷에 의해 대체됐는가."""
if not report_id:
return False
return report_id in superseded_report_ids()
def excluded_report_ids():
"""peer 검색에서 기본 제외할 report-id 집합 = 대체됨(superseded) 거부됨(rejected).
거부됨 = 그 리포트의 최신 자기 결정이 changes-requested/blocked 인 경우.
(같은 report-id 가 나중에 accepted 되면 제외하지 않는다 — 최신 결정 우선.)"""
events = read_events()
excluded = set()
for ev in events:
sup = ev.get("supersedes-report-id")
if sup:
excluded.add(sup)
for rid, dec in _latest_decision_by_report(events).items():
if dec in REJECTING:
excluded.add(rid)
excluded.discard(None)
excluded.discard("")
return excluded
def is_current(report_id):
"""report_id 가 현재 유효(대체/거부되지 않음)한가."""
if not report_id:
return True # 식별 불가 -> 필터하지 않음(과잉 제외 방지)
return report_id not in excluded_report_ids()
def query(kind, **kwargs):
"""질의 디스패처(재사용용)."""
if kind == "latest-accepted":
return latest_accepted(kwargs.get("workflow"), kwargs.get("role"))
if kind == "latest-accepted-artifact":
return latest_accepted_artifact(
kwargs.get("workflow"), producer_role=kwargs.get("producer_role"),
artifact_kind=kwargs.get("artifact_kind"),
)
if kind == "is-superseded":
return is_superseded(kwargs.get("report_id"))
if kind == "is-current":
return is_current(kwargs.get("report_id"))
if kind == "excluded":
return excluded_report_ids()
if kind == "events":
return read_events()
if kind == "effective-decision":
return effective_decision(kwargs.get("workflow"), kwargs.get("artifact_id"),
kwargs.get("artifact_sha256"))
raise ValueError(f"unknown query kind: {kind}")
# ---------------------------------------------------------------- CLI
def _argval(args, flag):
return args[args.index(flag) + 1] if flag in args and args.index(flag) + 1 < len(args) else None
def main():
args = sys.argv[1:]
if not args:
sys.stderr.write(__doc__)
return 1
cmd = args[0]
if cmd == "append":
report_id = _argval(args, "--report-id")
decision = _argval(args, "--decision")
if not report_id or not decision:
sys.stderr.write("usage: acceptance_log.py append --report-id RID --decision "
"accepted|changes-requested|blocked [--workflow WF] [--role ROLE] "
"[--supersedes PRIOR-RID] [--report-sha256 HEX]\n")
return 2
# Compatibility entrypoint now delegates to the state engine's
# authorization path. A caller-supplied --role string is never itself
# proof of reviewer authority.
import _workspace as W # noqa: E402
W.require_workspace("acceptance_log")
wf_arg = _argval(args, "--workflow")
reviewer = _argval(args, "--reviewer")
if not wf_arg or not reviewer:
sys.stderr.write(
"[acceptance_log] BLOCK: append는 --workflow와 --reviewer가 필수다. "
"권장 명령: state_engine.py review-artifact --workflow WF --report PATH "
"--decision DECISION --reviewer ROLE\n")
return 2
rpath = _resolve_report_path(report_id, wf_arg)
if not rpath:
sys.stderr.write(
f"[acceptance_log] BLOCK: report-id '{report_id}' 에 해당하는 report 파일이 없다 — "
"존재하지 않는 report 를 수락/거부할 수 없다(ghost acceptance 차단, P0-4c).\n")
return 2
try:
import state_engine as SE # noqa: E402
ok, result = SE.review_artifact(
wf_arg, rpath, decision, reviewer,
supersedes=_argval(args, "--supersedes"),
)
except Exception as exc:
ok, result = False, str(exc)
if not ok:
sys.stderr.write(f"[acceptance_log] BLOCK: {result}\n")
return 2
print(result["acceptance-event-id"])
return 0
if cmd == "latest-accepted":
rid = latest_accepted(_argval(args, "--workflow"), _argval(args, "--role"))
if rid:
print(rid)
return 0
if cmd == "is-superseded":
rid = _argval(args, "--report-id")
print("yes" if is_superseded(rid) else "no")
return 0
if cmd == "excluded":
for rid in sorted(excluded_report_ids()):
print(rid)
return 0
if cmd == "effective-decision":
value = effective_decision(
_argval(args, "--workflow"), _argval(args, "--artifact-id"),
_argval(args, "--artifact-sha256"),
)
if value:
print(value)
return 0
if cmd == "events":
for ev in read_events():
print(json.dumps(ev, ensure_ascii=False))
return 0
sys.stderr.write(__doc__)
return 1
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as e: # 최종 안전망 — 어떤 경우에도 크래시로 파이프라인을 막지 않는다
_log(f"unexpected: {e}")
# Mutation/query ambiguity must fail closed. Read APIs themselves keep
# returning safe empty values; an unexpected CLI error is never success.
sys.exit(2)