SPEC ensure_attachment {key, file, filename}, пауза 30 с, по состоянию не подтверждается; queue._check_setup: формат file, файл загружен («файл <sha> не загружен: POST /files»), filename без / и \, без кавычек и управляющих символов (R-2); claim, довыдача и GET /agent/commands/{id} дописывают size (null без файла), в базе params без size, retry проходит (решения 17–18).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
8.2 KiB
Python
175 lines
8.2 KiB
Python
"""Настройки хаба: пары ключ → JSON в таблице setting. Любая правка поднимает settings_version,
|
||
агент перечитывает настройки, когда видит новую версию в ответе claim."""
|
||
import json
|
||
import os
|
||
import secrets
|
||
|
||
DEFAULT_PACING = {"ensure_issue": 120, "ensure_fields": 120, "ensure_comment": 30, "ensure_status": 30,
|
||
"ensure_link": 30, "ensure_sprint": 30, "ensure_attachment": 30, "refresh": 2}
|
||
|
||
DEFAULTS = {
|
||
"base_jql": "project = AURORA AND labels = AWPCA",
|
||
"project_key": "AURORA",
|
||
"pacing": DEFAULT_PACING,
|
||
"backup_keep": 30,
|
||
"auto_refresh_minutes": 60,
|
||
"min_agent_version": 1,
|
||
"port": 8790,
|
||
"settings_version": 1,
|
||
"initialized": False,
|
||
"issue_types": {},
|
||
"epic_name_field_id": None,
|
||
"field_ids": {},
|
||
"epic_link": {},
|
||
"priority_map": {},
|
||
"priority_value_key": "name",
|
||
"labels": [],
|
||
"status_labels": {},
|
||
"kind_labels": {},
|
||
"comment_marker": "",
|
||
"transitions_map": {},
|
||
"link_types": {},
|
||
"board_id": None,
|
||
"relay_dir": None,
|
||
}
|
||
|
||
# Что получает агент (без токенов и служебных ключей хаба)
|
||
AGENT_KEYS = ["base_jql", "project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link",
|
||
"priority_map", "priority_value_key", "labels", "status_labels", "kind_labels", "comment_marker",
|
||
"transitions_map", "link_types", "board_id", "pacing", "min_agent_version", "settings_version"]
|
||
|
||
PROTECTED = {"settings_version", "schema_version", "tokens"}
|
||
|
||
|
||
def _is_int(v) -> bool:
|
||
return isinstance(v, int) and not isinstance(v, bool)
|
||
|
||
|
||
def validate(key, value) -> None:
|
||
"""Проверить значение настройки до записи; ValueError — на 422 в API.
|
||
pacing — объект {тип команды: целое >= 0} только с известными типами команд;
|
||
backup_keep, auto_refresh_minutes, min_agent_version — целые >= 0; port — целое 1..65535;
|
||
relay_dir — null или абсолютный путь строкой;
|
||
остальные ключи — по типу значения по умолчанию (объект, список, строка, флаг), если оно не None;
|
||
неизвестные ключи и ключи с умолчанием None принимаются как есть."""
|
||
if key == "pacing":
|
||
if not isinstance(value, dict):
|
||
raise ValueError("pacing — объект {тип команды: пауза в секундах}")
|
||
for ctype, pause in value.items():
|
||
if ctype not in DEFAULT_PACING:
|
||
raise ValueError(f"pacing: неизвестный тип команды «{ctype}»")
|
||
if not _is_int(pause) or pause < 0:
|
||
raise ValueError(f"pacing.{ctype}: ожидается целое >= 0")
|
||
return
|
||
if key == "port":
|
||
if not _is_int(value) or not 1 <= value <= 65535:
|
||
raise ValueError("port — целое 1..65535")
|
||
return
|
||
if key in ("backup_keep", "auto_refresh_minutes", "min_agent_version"):
|
||
if not _is_int(value) or value < 0:
|
||
raise ValueError(f"{key} — целое >= 0")
|
||
return
|
||
if key == "relay_dir":
|
||
if value is not None and (not isinstance(value, str) or not os.path.isabs(value)):
|
||
raise ValueError("relay_dir — абсолютный путь папки реле или null")
|
||
return
|
||
default = DEFAULTS.get(key)
|
||
if default is not None and type(value) is not type(default):
|
||
names = {dict: "объект", list: "список", str: "строка", bool: "true/false", int: "целое"}
|
||
raise ValueError(f"{key} — {names.get(type(default), type(default).__name__)}")
|
||
|
||
|
||
def ensure_defaults(conn) -> None:
|
||
for key, value in DEFAULTS.items():
|
||
conn.execute("INSERT OR IGNORE INTO setting(key, value) VALUES (?, ?)", (key, _dump(value)))
|
||
if get(conn, "tokens") is None:
|
||
conn.execute("INSERT INTO setting(key, value) VALUES ('tokens', ?)",
|
||
(_dump({"agent": secrets.token_urlsafe(24), "client": secrets.token_urlsafe(24)}),))
|
||
|
||
|
||
def _dump(value) -> str:
|
||
return json.dumps(value, ensure_ascii=False)
|
||
|
||
|
||
def get(conn, key, default=None):
|
||
row = conn.execute("SELECT value FROM setting WHERE key = ?", (key,)).fetchone()
|
||
return default if row is None else json.loads(row["value"])
|
||
|
||
|
||
def get_all(conn, hide_tokens=True) -> dict:
|
||
out = {r["key"]: json.loads(r["value"]) for r in conn.execute("SELECT key, value FROM setting ORDER BY key")}
|
||
if hide_tokens:
|
||
out.pop("tokens", None)
|
||
return out
|
||
|
||
|
||
def set_(conn, key, value, bump=True) -> int:
|
||
"""Записать настройку; bump=True поднимает settings_version. Защищённые ключи снаружи не меняются."""
|
||
if key in PROTECTED:
|
||
raise ValueError(f"настройка «{key}» не редактируется")
|
||
conn.execute("INSERT INTO setting(key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||
(key, _dump(value)))
|
||
if bump:
|
||
version = get(conn, "settings_version", 1) + 1
|
||
conn.execute("UPDATE setting SET value = ? WHERE key = 'settings_version'", (_dump(version),))
|
||
return version
|
||
return get(conn, "settings_version", 1)
|
||
|
||
|
||
def for_agent(conn) -> dict:
|
||
return {key: get(conn, key) for key in AGENT_KEYS}
|
||
|
||
|
||
def tokens(conn) -> dict:
|
||
return get(conn, "tokens")
|
||
|
||
|
||
def pacing(conn) -> dict:
|
||
return {**DEFAULT_PACING, **(get(conn, "pacing") or {})}
|
||
|
||
|
||
def pacing_from_config(p: dict) -> dict:
|
||
"""Старая форма pacing (issue_seconds / comment_seconds) → паузы по типам команд."""
|
||
issue, comment = p.get("issue_seconds", 120), p.get("comment_seconds", 30)
|
||
return {"ensure_issue": issue, "ensure_fields": issue, "ensure_comment": comment, "ensure_status": comment,
|
||
"ensure_link": comment, "ensure_sprint": comment, "ensure_attachment": comment, "refresh": 2}
|
||
|
||
|
||
def _issue_types(cfg: dict) -> dict:
|
||
"""issue_types из ключей конфига issue_type_<тип>[_id]: task и epic — всегда, bug — только если в конфиге есть
|
||
issue_type_bug или issue_type_bug_id (bug-and-attachments, data-model «Настройки»)."""
|
||
out = {t: {"name": cfg.get(f"issue_type_{t}"), "id": cfg.get(f"issue_type_{t}_id")} for t in ("task", "epic")}
|
||
if "issue_type_bug" in cfg or "issue_type_bug_id" in cfg:
|
||
out["bug"] = {"name": cfg.get("issue_type_bug"), "id": cfg.get("issue_type_bug_id")}
|
||
return out
|
||
|
||
|
||
def init_from_config(conn, cfg: dict, force=False) -> list[str]:
|
||
"""Перенести из config.example.json всё, что относится к проекту Jira. Учётные данные и адрес Jira
|
||
(auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают."""
|
||
if get(conn, "initialized") and not force:
|
||
raise RuntimeError("хаб уже инициализирован; повторный init — с --force")
|
||
project = cfg["project_key"]
|
||
values = {
|
||
"project_key": project,
|
||
"base_jql": f"project = {project} AND labels = AWPCA",
|
||
"issue_types": _issue_types(cfg),
|
||
"epic_name_field_id": cfg.get("epic_name_field_id"),
|
||
"field_ids": cfg.get("fields", {}),
|
||
"epic_link": cfg.get("epic_link", {}),
|
||
"priority_map": cfg.get("priority_map", {}),
|
||
"priority_value_key": cfg.get("priority_value_key", "name"),
|
||
"labels": cfg.get("labels", []),
|
||
"status_labels": cfg.get("status_labels", {}),
|
||
"kind_labels": cfg.get("kind_labels", {}),
|
||
"comment_marker": cfg.get("comment_marker", ""),
|
||
"transitions_map": cfg.get("transitions", {}).get("map", {}),
|
||
"link_types": cfg.get("link_types", {}),
|
||
"board_id": cfg.get("sprints", {}).get("board_id"),
|
||
"pacing": pacing_from_config(cfg.get("pacing", {})),
|
||
}
|
||
for key, value in values.items():
|
||
set_(conn, key, value, bump=False)
|
||
set_(conn, "initialized", True)
|
||
return sorted(values)
|