359 lines
13 KiB
Python
359 lines
13 KiB
Python
#!/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 = (
|
|
"<!-- technical-doc-flow:brief:start -->\n"
|
|
f"{brief_text}"
|
|
+ ("" if brief_text.endswith("\n") else "\n")
|
|
+ "<!-- technical-doc-flow:brief:end -->\n\n"
|
|
+ "<!-- technical-doc-flow:draft:start -->\n"
|
|
+ draft_text
|
|
+ ("" if draft_text.endswith("\n") else "\n")
|
|
+ "<!-- technical-doc-flow:draft:end -->\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())
|