init: company-haness 설계
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
#!/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)
|
||||
Reference in New Issue
Block a user