591 lines
21 KiB
Python
591 lines
21 KiB
Python
"""Локальный Git-watcher заданий RX без LLM и доступа к SDR."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
from dataclasses import asdict, dataclass
|
||
from datetime import datetime, timezone
|
||
from enum import Enum
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
from typing import Callable, Protocol
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
DEFAULT_TASK_PATH = "docs/handoff/NEXT_RX_TASK.md"
|
||
DEFAULT_STATE_PATH = ROOT / ".local/rx_agent_state.json"
|
||
DEFAULT_OUTBOX = ROOT / ".local/rx_agent_outbox"
|
||
REQUIRED_FIELDS = (
|
||
"TASK_ID",
|
||
"TASK_TITLE",
|
||
"SOURCE_BRANCH",
|
||
"EXPECTED_HEAD",
|
||
"REQUIRES_APPROVAL",
|
||
"HARDWARE_ACCESS",
|
||
"RF_RX",
|
||
"RF_TX",
|
||
"ALLOW_COMMIT",
|
||
"ALLOW_PUSH",
|
||
)
|
||
OPTIONAL_FIELDS = ("RISK_LEVEL",)
|
||
HEADER_FIELDS = REQUIRED_FIELDS + OPTIONAL_FIELDS
|
||
TASK_ID_PATTERN = re.compile(r"^[A-Z0-9][A-Z0-9_-]{2,63}$")
|
||
BRANCH_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
|
||
REMOTE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
|
||
HEAD_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class TaskDefinition:
|
||
"""Проверенный машинно читаемый заголовок задания."""
|
||
|
||
task_id: str
|
||
task_title: str
|
||
source_branch: str
|
||
expected_head: str
|
||
requires_approval: bool
|
||
hardware_access: bool
|
||
rf_rx: bool
|
||
rf_tx: bool
|
||
allow_commit: bool
|
||
allow_push: str
|
||
risk_level: str
|
||
|
||
@property
|
||
def safety_level(self) -> str:
|
||
if self.rf_tx or self.risk_level == "RED":
|
||
return "RED"
|
||
if self.hardware_access or self.rf_rx or self.risk_level == "YELLOW":
|
||
return "YELLOW"
|
||
return "GREEN"
|
||
|
||
|
||
@dataclass
|
||
class WatcherState:
|
||
"""Локальное состояние машины, которое не должно попадать в Git."""
|
||
|
||
last_seen_task_id: str | None = None
|
||
last_completed_task_id: str | None = None
|
||
deferred_task_id: str | None = None
|
||
running: bool = False
|
||
pid: int | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PreparedResult:
|
||
task_id: str
|
||
task_status: str
|
||
source_head: str
|
||
started_at: str
|
||
finished_at: str
|
||
summary: str
|
||
checks: list[str]
|
||
hardware_actions: list[str]
|
||
rf_tx: str
|
||
files_created: list[str]
|
||
errors: list[str]
|
||
next_action: str
|
||
|
||
|
||
class ApprovalDecision(Enum):
|
||
APPROVE = "APPROVE"
|
||
DEFER = "DEFER"
|
||
STOP = "STOP"
|
||
BLOCKED = "BLOCKED"
|
||
|
||
|
||
class TaskExecutor(Protocol):
|
||
"""Интерфейс исполнителя, не зависящий от конкретного LLM-клиента."""
|
||
|
||
def prepare(
|
||
self,
|
||
task: TaskDefinition,
|
||
task_text: str,
|
||
source_head: str,
|
||
*,
|
||
dry_run: bool,
|
||
) -> tuple[Path, Path, Path]:
|
||
"""Подготовить локальный outbox без запуска агента."""
|
||
...
|
||
|
||
|
||
def _yes_no(value: str, field: str) -> bool:
|
||
normalized = value.upper()
|
||
if normalized not in {"YES", "NO"}:
|
||
raise ValueError(f"{field} must be YES or NO")
|
||
return normalized == "YES"
|
||
|
||
|
||
def parse_task(text: str) -> TaskDefinition:
|
||
"""Разобрать простой key-value заголовок и отклонить неоднозначность."""
|
||
values: dict[str, str] = {}
|
||
for raw_line in text.splitlines():
|
||
line = raw_line.strip()
|
||
if not line or line == "---":
|
||
continue
|
||
if line.startswith("#") or line.startswith("```"):
|
||
if values:
|
||
break
|
||
continue
|
||
if "=" not in line:
|
||
if values:
|
||
break
|
||
continue
|
||
key, value = (part.strip() for part in line.split("=", 1))
|
||
if key not in HEADER_FIELDS:
|
||
if values:
|
||
break
|
||
continue
|
||
if key in values:
|
||
raise ValueError(f"duplicate task header field: {key}")
|
||
if not value:
|
||
raise ValueError(f"empty task header field: {key}")
|
||
values[key] = value
|
||
|
||
missing = [field for field in REQUIRED_FIELDS if field not in values]
|
||
if missing:
|
||
raise ValueError(f"missing task header fields: {', '.join(missing)}")
|
||
task_id = values["TASK_ID"]
|
||
branch = values["SOURCE_BRANCH"]
|
||
expected_head = values["EXPECTED_HEAD"].lower()
|
||
if not TASK_ID_PATTERN.fullmatch(task_id):
|
||
raise ValueError("invalid TASK_ID")
|
||
if not BRANCH_PATTERN.fullmatch(branch) or ".." in branch or branch.endswith("/"):
|
||
raise ValueError("invalid SOURCE_BRANCH")
|
||
if not HEAD_PATTERN.fullmatch(expected_head):
|
||
raise ValueError("EXPECTED_HEAD must be a full lowercase Git hash")
|
||
allow_push = values["ALLOW_PUSH"].upper()
|
||
if allow_push not in {"NO", "RX_RESULT_ONLY"}:
|
||
raise ValueError("ALLOW_PUSH must be NO or RX_RESULT_ONLY")
|
||
risk_level = values.get("RISK_LEVEL", "").upper()
|
||
if risk_level and risk_level not in {"GREEN", "YELLOW", "RED"}:
|
||
raise ValueError("RISK_LEVEL must be GREEN, YELLOW or RED")
|
||
if not risk_level:
|
||
risk_level = (
|
||
"RED" if _yes_no(values["RF_TX"], "RF_TX") else
|
||
"YELLOW" if (
|
||
_yes_no(values["HARDWARE_ACCESS"], "HARDWARE_ACCESS")
|
||
or _yes_no(values["RF_RX"], "RF_RX")
|
||
) else "GREEN"
|
||
)
|
||
task = TaskDefinition(
|
||
task_id=task_id,
|
||
task_title=values["TASK_TITLE"],
|
||
source_branch=branch,
|
||
expected_head=expected_head,
|
||
requires_approval=_yes_no(values["REQUIRES_APPROVAL"], "REQUIRES_APPROVAL"),
|
||
hardware_access=_yes_no(values["HARDWARE_ACCESS"], "HARDWARE_ACCESS"),
|
||
rf_rx=_yes_no(values["RF_RX"], "RF_RX"),
|
||
rf_tx=_yes_no(values["RF_TX"], "RF_TX"),
|
||
allow_commit=_yes_no(values["ALLOW_COMMIT"], "ALLOW_COMMIT"),
|
||
allow_push=allow_push,
|
||
risk_level=risk_level,
|
||
)
|
||
if task.rf_tx and not task.hardware_access:
|
||
raise ValueError("RF_TX=YES requires HARDWARE_ACCESS=YES")
|
||
if task.rf_tx and task.risk_level != "RED":
|
||
raise ValueError("RF_TX=YES requires RISK_LEVEL=RED")
|
||
if (task.hardware_access or task.rf_rx) and task.risk_level == "GREEN":
|
||
raise ValueError("hardware access cannot use RISK_LEVEL=GREEN")
|
||
return task
|
||
|
||
|
||
def load_state(path: Path) -> WatcherState:
|
||
"""Прочитать локальное состояние; отсутствие файла означает первый запуск."""
|
||
if not path.exists():
|
||
return WatcherState()
|
||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||
allowed = set(WatcherState.__dataclass_fields__)
|
||
if not isinstance(raw, dict) or any(key not in allowed for key in raw):
|
||
raise ValueError("invalid watcher state")
|
||
return WatcherState(**raw)
|
||
|
||
|
||
def save_state(path: Path, state: WatcherState) -> None:
|
||
"""Атомарно сохранить состояние рядом с целевым файлом."""
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||
temporary.write_text(json.dumps(asdict(state), indent=2) + "\n", encoding="utf-8")
|
||
os.replace(temporary, path)
|
||
|
||
|
||
def is_new_task(task: TaskDefinition, state: WatcherState) -> bool:
|
||
"""Подавить задание, которое уже показывалось или было завершено."""
|
||
return task.task_id not in {state.last_seen_task_id, state.last_completed_task_id}
|
||
|
||
|
||
def task_summary(task: TaskDefinition) -> str:
|
||
return (
|
||
"NEW TASK DETECTED\n\n"
|
||
f"TASK ID = {task.task_id}\n"
|
||
f"TITLE = {task.task_title}\n"
|
||
f"SAFETY LEVEL = {task.safety_level}\n"
|
||
f"HARDWARE ACCESS = {'YES' if task.hardware_access else 'NO'}\n"
|
||
f"RF RX = {'YES' if task.rf_rx else 'NO'}\n"
|
||
f"RF TX = {'YES' if task.rf_tx else 'NO'}"
|
||
)
|
||
|
||
|
||
def request_approval(
|
||
task: TaskDefinition,
|
||
input_function: Callable[[str], str] = input,
|
||
output_function: Callable[[str], None] = print,
|
||
) -> ApprovalDecision:
|
||
"""Запросить A/D/S; для RED нужна вторая точная фраза."""
|
||
output_function(task_summary(task))
|
||
choice = input_function("[A] Approve [D] Defer [S] Stop watcher: ").strip().upper()
|
||
if choice == "D":
|
||
return ApprovalDecision.DEFER
|
||
if choice == "S":
|
||
return ApprovalDecision.STOP
|
||
if choice != "A":
|
||
return ApprovalDecision.BLOCKED
|
||
if task.safety_level == "RED":
|
||
required_phrase = "AUTHORIZE RF TX" if task.rf_tx else f"AUTHORIZE {task.task_id}"
|
||
phrase = input_function(f"Type {required_phrase} to continue: ").strip()
|
||
if phrase != required_phrase:
|
||
return ApprovalDecision.BLOCKED
|
||
return ApprovalDecision.APPROVE
|
||
|
||
|
||
def utc_now() -> str:
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
def write_result(output_directory: Path, result: PreparedResult) -> tuple[Path, Path]:
|
||
"""Записать согласованные Markdown и JSON без публикации."""
|
||
output_directory.mkdir(parents=True, exist_ok=True)
|
||
markdown_path = output_directory / "RX_RESULT.md"
|
||
json_path = output_directory / "RX_RESULT.json"
|
||
markdown = (
|
||
f"TASK_ID = {result.task_id}\n"
|
||
f"TASK_STATUS = {result.task_status}\n"
|
||
f"SOURCE_HEAD = {result.source_head}\n"
|
||
f"STARTED_AT = {result.started_at}\n"
|
||
f"FINISHED_AT = {result.finished_at}\n\n"
|
||
f"SUMMARY =\n{result.summary}\n\n"
|
||
f"CHECKS =\n{json.dumps(result.checks)}\n\n"
|
||
f"HARDWARE_ACTIONS =\n{json.dumps(result.hardware_actions)}\n\n"
|
||
f"RF_TX =\n{result.rf_tx}\n\n"
|
||
f"FILES_CREATED =\n{json.dumps(result.files_created)}\n\n"
|
||
f"ERRORS =\n{json.dumps(result.errors)}\n\n"
|
||
f"NEXT_ACTION =\n{result.next_action}\n"
|
||
)
|
||
markdown_path.write_text(markdown, encoding="utf-8")
|
||
json_path.write_text(json.dumps(asdict(result), indent=2) + "\n", encoding="utf-8")
|
||
return markdown_path, json_path
|
||
|
||
|
||
class ManualExecutor:
|
||
"""Подготовить локальный prompt и каркас результата без запуска агента."""
|
||
|
||
def __init__(self, outbox: Path = DEFAULT_OUTBOX) -> None:
|
||
self.outbox = outbox
|
||
|
||
def prepare(
|
||
self,
|
||
task: TaskDefinition,
|
||
task_text: str,
|
||
source_head: str,
|
||
*,
|
||
dry_run: bool,
|
||
) -> tuple[Path, Path, Path]:
|
||
directory = self.outbox / task.task_id
|
||
directory.mkdir(parents=True, exist_ok=False)
|
||
prompt_path = directory / "TASK.md"
|
||
prompt_path.write_text(task_text, encoding="utf-8")
|
||
result_markdown_path = directory / "RX_RESULT.md"
|
||
result_json_path = directory / "RX_RESULT.json"
|
||
timestamp = utc_now()
|
||
status = "DRY_RUN_PREPARED" if dry_run else "AWAITING_MANUAL_EXECUTION"
|
||
result = PreparedResult(
|
||
task_id=task.task_id,
|
||
task_status=status,
|
||
source_head=source_head,
|
||
started_at=timestamp,
|
||
finished_at=timestamp,
|
||
summary="Task prepared for manual transfer; no agent was invoked.",
|
||
checks=["TASK_HEADER_VALID", "APPROVAL_GATE_PASS"],
|
||
hardware_actions=[],
|
||
rf_tx="NO",
|
||
files_created=[
|
||
str(prompt_path),
|
||
str(result_markdown_path),
|
||
str(result_json_path),
|
||
],
|
||
errors=[],
|
||
next_action="Open TASK.md in the chosen executor after explicit user approval.",
|
||
)
|
||
markdown_path, json_path = write_result(directory, result)
|
||
return prompt_path, markdown_path, json_path
|
||
|
||
|
||
class GitTaskSource:
|
||
"""Прочитать задание непосредственно из remote-tracking reference."""
|
||
|
||
def __init__(
|
||
self,
|
||
repository: Path,
|
||
*,
|
||
remote: str = "origin",
|
||
branch: str = "video-development",
|
||
task_path: str = DEFAULT_TASK_PATH,
|
||
) -> None:
|
||
if not BRANCH_PATTERN.fullmatch(branch) or ".." in branch:
|
||
raise ValueError("invalid branch")
|
||
if not REMOTE_PATTERN.fullmatch(remote):
|
||
raise ValueError("invalid remote")
|
||
self.repository = repository
|
||
self.remote = remote
|
||
self.branch = branch
|
||
self.task_path = task_path
|
||
|
||
def _run(
|
||
self,
|
||
arguments: list[str],
|
||
*,
|
||
check: bool = True,
|
||
) -> subprocess.CompletedProcess[str]:
|
||
return subprocess.run(
|
||
["git", *arguments],
|
||
cwd=self.repository,
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
check=check,
|
||
)
|
||
|
||
def fetch(self) -> None:
|
||
self._run(["fetch", "--quiet", self.remote, self.branch])
|
||
|
||
def read(self) -> tuple[str, str]:
|
||
remote_ref = f"{self.remote}/{self.branch}"
|
||
head = self._run(["rev-parse", remote_ref]).stdout.strip()
|
||
text = self._run(["show", f"{remote_ref}:{self.task_path}"]).stdout
|
||
task = parse_task(text)
|
||
if task.source_branch != self.branch:
|
||
raise ValueError("task SOURCE_BRANCH disagrees with watched branch")
|
||
ancestor = self._run(
|
||
["merge-base", "--is-ancestor", task.expected_head, head],
|
||
check=False,
|
||
)
|
||
if ancestor.returncode != 0:
|
||
raise ValueError("EXPECTED_HEAD is not an ancestor of remote task HEAD")
|
||
return text, head
|
||
|
||
|
||
def handle_task(
|
||
task_text: str,
|
||
source_head: str,
|
||
state: WatcherState,
|
||
executor: TaskExecutor,
|
||
*,
|
||
dry_run: bool,
|
||
input_function: Callable[[str], str] = input,
|
||
output_function: Callable[[str], None] = print,
|
||
) -> tuple[str, WatcherState]:
|
||
"""Обработать одно задание без изменений Git и запуска агента."""
|
||
task = parse_task(task_text)
|
||
if not is_new_task(task, state):
|
||
return "DUPLICATE_SUPPRESSED", state
|
||
decision = request_approval(task, input_function, output_function)
|
||
state.last_seen_task_id = task.task_id
|
||
if decision == ApprovalDecision.DEFER:
|
||
state.deferred_task_id = task.task_id
|
||
return "DEFERRED", state
|
||
if decision == ApprovalDecision.STOP:
|
||
return "STOP", state
|
||
if decision != ApprovalDecision.APPROVE:
|
||
return "APPROVAL_BLOCKED", state
|
||
state.deferred_task_id = None
|
||
paths = executor.prepare(task, task_text, source_head, dry_run=dry_run)
|
||
output_function(f"TASK PREPARED = {paths[0]}")
|
||
return "PREPARED", state
|
||
|
||
|
||
def stop_path_for(state_path: Path) -> Path:
|
||
return state_path.with_suffix(state_path.suffix + ".stop")
|
||
|
||
|
||
def process_exists(pid: int | None) -> bool:
|
||
if pid is None or pid <= 0:
|
||
return False
|
||
try:
|
||
os.kill(pid, 0)
|
||
except OSError:
|
||
return False
|
||
return True
|
||
|
||
|
||
def request_stop(state_path: Path, timeout_s: float) -> bool:
|
||
"""Запросить остановку watcher и дождаться снятия running state."""
|
||
stop_path = stop_path_for(state_path)
|
||
stop_path.parent.mkdir(parents=True, exist_ok=True)
|
||
stop_path.write_text("STOP\n", encoding="ascii")
|
||
deadline = time.monotonic() + timeout_s
|
||
while time.monotonic() < deadline:
|
||
state = load_state(state_path)
|
||
if not state.running or not process_exists(state.pid):
|
||
return True
|
||
time.sleep(0.2)
|
||
return False
|
||
|
||
|
||
def interruptible_input(prompt: str, stop_path: Path) -> str:
|
||
"""Читать консоль Windows, позволяя stop script прервать ожидание."""
|
||
if os.name != "nt":
|
||
return input(prompt)
|
||
import msvcrt
|
||
|
||
print(prompt, end="", flush=True)
|
||
characters: list[str] = []
|
||
while True:
|
||
if stop_path.exists():
|
||
print()
|
||
return "S"
|
||
if not msvcrt.kbhit():
|
||
time.sleep(0.1)
|
||
continue
|
||
character = msvcrt.getwch()
|
||
if character in {"\r", "\n"}:
|
||
print()
|
||
return "".join(characters)
|
||
if character == "\b":
|
||
if characters:
|
||
characters.pop()
|
||
print("\b \b", end="", flush=True)
|
||
continue
|
||
characters.append(character)
|
||
print(character, end="", flush=True)
|
||
|
||
|
||
def mark_completed(state_path: Path, task_id: str) -> None:
|
||
if not TASK_ID_PATTERN.fullmatch(task_id):
|
||
raise ValueError("invalid TASK_ID")
|
||
state = load_state(state_path)
|
||
if state.last_seen_task_id != task_id:
|
||
raise ValueError("only the last seen task may be marked completed")
|
||
state.last_completed_task_id = task_id
|
||
state.deferred_task_id = None
|
||
save_state(state_path, state)
|
||
|
||
|
||
def run_watcher(arguments: argparse.Namespace) -> int:
|
||
state_path = arguments.state_file.resolve()
|
||
if arguments.request_stop:
|
||
stopped = request_stop(state_path, arguments.stop_timeout)
|
||
print("WATCHER STOPPED" if stopped else "WATCHER STOP TIMEOUT")
|
||
return 0 if stopped else 1
|
||
if arguments.mark_completed is not None:
|
||
mark_completed(state_path, arguments.mark_completed)
|
||
print(f"COMPLETED TASK = {arguments.mark_completed}")
|
||
return 0
|
||
if arguments.dry_run and arguments.task_file is None:
|
||
raise ValueError("--dry-run requires --task-file and never fetches Git")
|
||
|
||
state = load_state(state_path)
|
||
if state.running and process_exists(state.pid):
|
||
raise RuntimeError(f"watcher already running with PID {state.pid}")
|
||
state.running = True
|
||
state.pid = os.getpid()
|
||
save_state(state_path, state)
|
||
stop_path = stop_path_for(state_path)
|
||
stop_path.unlink(missing_ok=True)
|
||
executor = ManualExecutor(arguments.outbox.resolve())
|
||
source = GitTaskSource(
|
||
arguments.repository.resolve(),
|
||
remote=arguments.remote,
|
||
branch=arguments.branch,
|
||
)
|
||
if arguments.approval is not None:
|
||
replies = [
|
||
"A" if arguments.approval == "approve" else
|
||
"D" if arguments.approval == "defer" else "S"
|
||
]
|
||
iterator = iter(replies)
|
||
|
||
def automated_input(_prompt: str) -> str:
|
||
return next(iterator, "")
|
||
|
||
input_function = automated_input
|
||
else:
|
||
def console_input(prompt: str) -> str:
|
||
return interruptible_input(prompt, stop_path)
|
||
|
||
input_function = console_input
|
||
|
||
try:
|
||
while not stop_path.exists():
|
||
try:
|
||
if arguments.task_file is not None:
|
||
task_text = arguments.task_file.read_text(encoding="utf-8")
|
||
source_head = parse_task(task_text).expected_head
|
||
else:
|
||
source.fetch()
|
||
task_text, source_head = source.read()
|
||
status, state = handle_task(
|
||
task_text,
|
||
source_head,
|
||
state,
|
||
executor,
|
||
dry_run=arguments.dry_run,
|
||
input_function=input_function,
|
||
)
|
||
save_state(state_path, state)
|
||
if status != "DUPLICATE_SUPPRESSED":
|
||
print(f"WATCHER STATUS = {status}")
|
||
if status == "STOP":
|
||
break
|
||
except (OSError, subprocess.SubprocessError, ValueError) as error:
|
||
print(f"WATCHER ERROR = {error}", file=sys.stderr)
|
||
if arguments.once or arguments.dry_run:
|
||
return 1
|
||
if arguments.once or arguments.dry_run or arguments.task_file is not None:
|
||
break
|
||
stop_path.parent.mkdir(parents=True, exist_ok=True)
|
||
for _ in range(max(1, int(arguments.poll_interval * 10))):
|
||
if stop_path.exists():
|
||
break
|
||
time.sleep(0.1)
|
||
finally:
|
||
state.running = False
|
||
state.pid = None
|
||
save_state(state_path, state)
|
||
stop_path.unlink(missing_ok=True)
|
||
return 0
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("--repository", type=Path, default=ROOT)
|
||
parser.add_argument("--remote", default="origin")
|
||
parser.add_argument("--branch", default="video-development")
|
||
parser.add_argument("--poll-interval", type=float, default=30.0)
|
||
parser.add_argument("--state-file", type=Path, default=DEFAULT_STATE_PATH)
|
||
parser.add_argument("--outbox", type=Path, default=DEFAULT_OUTBOX)
|
||
parser.add_argument("--once", action="store_true")
|
||
parser.add_argument("--dry-run", action="store_true")
|
||
parser.add_argument("--task-file", type=Path)
|
||
parser.add_argument("--approval", choices=("approve", "defer", "stop"))
|
||
parser.add_argument("--request-stop", action="store_true")
|
||
parser.add_argument("--stop-timeout", type=float, default=35.0)
|
||
parser.add_argument("--mark-completed")
|
||
return parser
|
||
|
||
|
||
def main() -> None:
|
||
arguments = build_parser().parse_args()
|
||
if arguments.poll_interval <= 0 or arguments.stop_timeout <= 0:
|
||
raise SystemExit("intervals must be positive")
|
||
raise SystemExit(run_watcher(arguments))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|