#!/usr/bin/env python3 """Create a collision-safe technical-doc-flow run directory.""" from __future__ import annotations import argparse import ctypes import errno import os import re import shutil import sys import tempfile from datetime import date from pathlib import Path from typing import Any from harness_common import ( DEFAULT_CONTRACT_PATH, InputError, atomic_write_bytes, atomic_write_json, atomic_write_text, decode_utf8, DEFAULT_RULES_PATH, json_text, load_rules, snapshot_file, sha256_file, sha256_text, utc_now, ) from lint_document import mask_raw_html_blocks, parse_headings, scan_markdown_visibility ROUTES = ("auto", "light", "standard", "deep") KINDS = ("explanation", "decision", "how-to", "reference") MODES = ("write", "revise", "review") AT_FDCWD = -100 RENAME_NOREPLACE = 1 class RunDestinationOccupied(FileExistsError): """Raised when another actor publishes the selected run id first.""" def publish_directory_noreplace(source: Path, destination: Path) -> None: """Atomically publish *source* without replacing any destination entry.""" if not sys.platform.startswith("linux"): raise InputError( "atomic no-clobber run publish는 현재 Linux에서만 지원됩니다." ) try: renameat2 = ctypes.CDLL(None, use_errno=True).renameat2 except AttributeError as exc: raise InputError( "이 시스템에는 atomic no-clobber run publish에 필요한 renameat2가 없습니다." ) from exc renameat2.argtypes = [ ctypes.c_int, ctypes.c_char_p, ctypes.c_int, ctypes.c_char_p, ctypes.c_uint, ] renameat2.restype = ctypes.c_int result = renameat2( AT_FDCWD, os.fsencode(source), AT_FDCWD, os.fsencode(destination), RENAME_NOREPLACE, ) if result == 0: return error_number = ctypes.get_errno() if error_number in {errno.EEXIST, errno.ENOTEMPTY}: raise RunDestinationOccupied(str(destination)) unsupported_errors = {errno.EINVAL, errno.ENOSYS} if hasattr(errno, "EOPNOTSUPP"): unsupported_errors.add(errno.EOPNOTSUPP) if hasattr(errno, "ENOTSUP"): unsupported_errors.add(errno.ENOTSUP) if error_number in unsupported_errors: raise InputError( "이 파일시스템은 atomic no-clobber run publish를 지원하지 않습니다." ) raise OSError(error_number, os.strerror(error_number), str(destination)) def count_headings(markdown: str) -> int: _, reader_visible, _, _ = scan_markdown_visibility(markdown) return len(parse_headings(mask_raw_html_blocks(reader_visible))) def measure_route_inputs(texts: list[str], source_count: int) -> dict[str, int]: """Measure every UTF-8 input that can increase document complexity.""" return { "total_chars": sum(len(text) for text in texts), "source_count": source_count, "total_headings": sum(count_headings(text) for text in texts), } def choose_route( requested: str, *, mode: str, has_draft: bool, metrics: dict[str, int], rules: dict[str, Any], ) -> tuple[str, str]: chars = metrics["total_chars"] source_count = metrics["source_count"] headings = metrics["total_headings"] metric_text = f"chars={chars}, sources={source_count}, headings={headings}" if requested != "auto": return requested, f"사용자가 {requested} 경로를 명시했습니다({metric_text})." route_rules = rules["thresholds"]["route"] deep = route_rules["deep"] if ( chars >= int(deep["min_input_chars"]) or source_count >= int(deep["min_sources"]) or headings >= int(deep["min_headings"]) ): return ( "deep", f"입력 규모가 deep 임계에 도달했습니다({metric_text}).", ) light = route_rules["light"] light_allowed = has_draft if light.get("requires_existing_draft", True) else True if ( mode != "write" and light_allowed and chars <= int(light["max_input_chars"]) and source_count <= int(light["max_sources"]) and headings <= int(light["max_headings"]) ): return ( "light", f"기존 draft가 있고 light 임계 안입니다({metric_text}).", ) return ( "standard", f"새 문서는 최소 standard이며 현재 deep 임계 미만입니다({metric_text}).", ) def reserve_run(workspace: Path, day: str) -> tuple[str, Path, Path]: for sequence in range(1, 10000): run_id = f"{day}-{sequence:03d}" final_path = workspace / run_id reservation = workspace / f".{run_id}.reserve" if os.path.lexists(final_path): continue try: descriptor = os.open(reservation, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) except FileExistsError: continue with os.fdopen(descriptor, "w", encoding="ascii") as stream: stream.write(str(os.getpid())) if os.path.lexists(final_path): reservation.unlink(missing_ok=True) continue return run_id, final_path, reservation raise InputError(f"{day} 날짜에 사용 가능한 run sequence가 없습니다.") def create_run(args: argparse.Namespace) -> tuple[Path, str, str]: rules_path = (Path(args.rules) if args.rules else DEFAULT_RULES_PATH).expanduser().resolve( strict=True ) rules_sha256 = sha256_file(rules_path) rules = load_rules(rules_path) if sha256_file(rules_path) != rules_sha256: raise InputError("quality rules가 읽는 동안 변경되었습니다.") contract_path = DEFAULT_CONTRACT_PATH.resolve(strict=True) contract_sha256 = sha256_file(contract_path) brief_path = Path(args.brief) brief_inventory, brief_bytes = snapshot_file( brief_path, "brief", args.brief, item_id="brief" ) brief_text = decode_utf8(brief_bytes, "brief") draft_inventory: dict[str, Any] | None = None draft_bytes: bytes | None = None draft_text: str | None = None if args.draft: draft_inventory, draft_bytes = snapshot_file( Path(args.draft), "draft", args.draft, item_id="draft" ) draft_text = decode_utf8(draft_bytes, "draft") source_inventories: list[dict[str, Any]] = [] source_texts: list[str] = [] for index, value in enumerate(args.source, start=1): inventory, source_bytes = snapshot_file( Path(value), "source", value, item_id=f"source-{index:03d}" ) source_inventories.append(inventory) source_texts.append(decode_utf8(source_bytes, f"source-{index:03d}")) mode = args.mode or ("revise" if draft_inventory else "write") if mode == "revise" and draft_inventory is None: raise InputError("revise 모드는 --draft 파일이 필요합니다.") if mode in {"revise", "review"} and draft_inventory is None: raise InputError(f"{mode} 모드는 --draft 파일이 필요합니다.") if draft_text is None: input_text = brief_text else: input_text = ( "\n" f"{brief_text}" + ("" if brief_text.endswith("\n") else "\n") + "\n\n" + "\n" + draft_text + ("" if draft_text.endswith("\n") else "\n") + "\n" ) route_texts = [brief_text, *([draft_text] if draft_text is not None else []), *source_texts] route_metrics = measure_route_inputs(route_texts, len(source_inventories)) route, route_reason = choose_route( args.route, mode=mode, has_draft=draft_inventory is not None, metrics=route_metrics, rules=rules, ) workspace = Path(args.workspace).expanduser().resolve() if workspace.exists() and not workspace.is_dir(): raise InputError(f"workspace가 디렉터리가 아닙니다: {workspace}") try: workspace.mkdir(parents=True, exist_ok=True) except (OSError, TypeError, ValueError, KeyError) as exc: raise InputError(f"workspace를 만들 수 없습니다: {workspace}: {exc}") from exc day = args.date or date.today().isoformat() try: parsed_day = date.fromisoformat(day) except ValueError as exc: raise InputError("--date 값은 유효한 YYYY-MM-DD 날짜여야 합니다.") from exc if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", day) or parsed_day.isoformat() != day: raise InputError("--date 값은 YYYY-MM-DD 형식이어야 합니다.") sources = { "schema_version": "1.0", "brief": brief_inventory, "draft": draft_inventory, "sources": source_inventories, } sources_manifest_sha256 = sha256_text(json_text(sources)) while True: run_id, final_path, reservation = reserve_run(workspace, day) stage: Path | None = None try: stage = Path(tempfile.mkdtemp(prefix=f".{run_id}-", dir=workspace)) now = utc_now() manifest = { "schema_version": "1.0", "run_id": run_id, "created_at": now, "updated_at": now, "mode": mode, "document_kind": args.kind, "kind_reason": args.kind_reason or "오케스트레이터가 사용자 목적을 바탕으로 --kind를 명시했습니다.", "audience": args.audience, "route_requested": args.route, "route_hint": route, "route_reason": route_reason, "route_metrics": route_metrics, "status": "initialized", "error": None, "contract_sha256": contract_sha256, "rules_version": rules["rules_version"], "rules_sha256": rules_sha256, "omissions": [], "inputs": { "brief": brief_inventory, "draft": draft_inventory, "source_count": len(source_inventories), "brief_sha256": brief_inventory["sha256"], "draft_sha256": draft_inventory["sha256"] if draft_inventory else None, "sources_manifest_sha256": sources_manifest_sha256, "input_sha256": sha256_text(input_text), }, "history": [ { "at": now, "from": None, "to": "initialized", "reason": "init_run", "error": None, } ], } atomic_write_json(stage / "00_run.json", manifest) atomic_write_text(stage / "01_input.md", input_text) atomic_write_json(stage / "01_sources.json", sources) if mode == "review": if draft_bytes is None: raise InputError("review 모드의 immutable 07_draft.md snapshot이 없습니다.") atomic_write_bytes(stage / "07_draft.md", draft_bytes) publish_directory_noreplace(stage, final_path) except RunDestinationOccupied: if stage is not None: shutil.rmtree(stage, ignore_errors=True) continue except BaseException: if stage is not None: shutil.rmtree(stage, ignore_errors=True) raise finally: reservation.unlink(missing_ok=True) return final_path, route, mode def parser() -> argparse.ArgumentParser: value = argparse.ArgumentParser(description=__doc__) value.add_argument("--brief", required=True, help="요청 brief Markdown/text 파일") value.add_argument("--draft", help="수정할 기존 Markdown draft") value.add_argument("--source", nargs="+", action="extend", default=[], help="참고 source 파일") value.add_argument("--audience", help="주 독자 설명") value.add_argument("--kind", choices=KINDS, required=True) value.add_argument("--kind-reason") value.add_argument("--route", choices=ROUTES, default="auto") value.add_argument("--mode", choices=MODES) value.add_argument("--workspace", default="_workspace") value.add_argument("--rules", help=argparse.SUPPRESS) value.add_argument("--date", help=argparse.SUPPRESS) return value def main(argv: list[str] | None = None) -> int: try: args = parser().parse_args(argv) run_path, route, mode = create_run(args) except InputError as exc: print(f"input error: {exc}", file=sys.stderr) return 2 except OSError as exc: print(f"input error: 실행 디렉터리를 만들 수 없습니다: {exc}", file=sys.stderr) return 2 print(f"{run_path}\troute={route}\tmode={mode}") return 0 if __name__ == "__main__": raise SystemExit(main())