Config schema, example config and validating loader
config/schema.json mirrors design §5 (terminals, executors, projects with routing, retry, timeouts). agentd/config.py loads YAML, validates with jsonschema when available or a small built-in draft-07 subset, adds cross-reference checks, and maps token_key to the environment variable injected by agent-access exec.
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
"""plane-agentd: Plane-driven dispatcher for CLI agents (see docs/design.md)."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent.parent
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"""Load and validate the agentd YAML configuration.
|
||||||
|
|
||||||
|
The config only ever holds Vault *key names* (`token_key`). The token itself is read from the environment
|
||||||
|
variable of the same name, which `agent-access.py exec --secret-env KEY=KEY` injects into the process."""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from . import ROOT
|
||||||
|
|
||||||
|
SCHEMA_PATH = ROOT / "config" / "schema.json"
|
||||||
|
DEFAULT_PATHS = (ROOT / "config" / "agentd.local.yaml", ROOT / "config" / "agentd.yaml")
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- minimal JSON-schema validator (fallback)
|
||||||
|
_TYPES = {"object": dict, "array": list, "string": str, "boolean": bool, "null": type(None)}
|
||||||
|
|
||||||
|
|
||||||
|
def _type_ok(value, name):
|
||||||
|
if name == "integer":
|
||||||
|
return isinstance(value, int) and not isinstance(value, bool)
|
||||||
|
if name == "number":
|
||||||
|
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
||||||
|
return isinstance(value, _TYPES[name])
|
||||||
|
|
||||||
|
|
||||||
|
def mini_validate(value, schema, root=None, path="$"):
|
||||||
|
"""Subset of draft-07: $ref (local), type, enum, required, properties, additionalProperties,
|
||||||
|
items, minItems, minProperties, minimum. Returns a list of error strings."""
|
||||||
|
root = root or schema
|
||||||
|
if "$ref" in schema:
|
||||||
|
node = root
|
||||||
|
for part in schema["$ref"].lstrip("#/").split("/"):
|
||||||
|
node = node[part]
|
||||||
|
return mini_validate(value, node, root, path)
|
||||||
|
errs = []
|
||||||
|
types = schema.get("type")
|
||||||
|
if types:
|
||||||
|
types = [types] if isinstance(types, str) else types
|
||||||
|
if not any(_type_ok(value, t) for t in types):
|
||||||
|
return ["%s: expected %s, got %s" % (path, "/".join(types), type(value).__name__)]
|
||||||
|
if "enum" in schema and value not in schema["enum"]:
|
||||||
|
errs.append("%s: %r not one of %s" % (path, value, schema["enum"]))
|
||||||
|
if "minimum" in schema and _type_ok(value, "number") and value < schema["minimum"]:
|
||||||
|
errs.append("%s: %r < minimum %s" % (path, value, schema["minimum"]))
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key in schema.get("required", []):
|
||||||
|
if key not in value:
|
||||||
|
errs.append("%s: missing required '%s'" % (path, key))
|
||||||
|
if len(value) < schema.get("minProperties", 0):
|
||||||
|
errs.append("%s: needs at least %d entries" % (path, schema["minProperties"]))
|
||||||
|
props = schema.get("properties", {})
|
||||||
|
extra = schema.get("additionalProperties", True)
|
||||||
|
for key, sub in value.items():
|
||||||
|
if key in props:
|
||||||
|
errs += mini_validate(sub, props[key], root, "%s.%s" % (path, key))
|
||||||
|
elif extra is False:
|
||||||
|
errs.append("%s: unknown key '%s'" % (path, key))
|
||||||
|
elif isinstance(extra, dict):
|
||||||
|
errs += mini_validate(sub, extra, root, "%s.%s" % (path, key))
|
||||||
|
if isinstance(value, list):
|
||||||
|
if len(value) < schema.get("minItems", 0):
|
||||||
|
errs.append("%s: needs at least %d items" % (path, schema["minItems"]))
|
||||||
|
if "items" in schema:
|
||||||
|
for i, item in enumerate(value):
|
||||||
|
errs += mini_validate(item, schema["items"], root, "%s[%d]" % (path, i))
|
||||||
|
return errs
|
||||||
|
|
||||||
|
|
||||||
|
def schema_errors(data, schema):
|
||||||
|
try:
|
||||||
|
import jsonschema
|
||||||
|
except ImportError:
|
||||||
|
return mini_validate(data, schema)
|
||||||
|
validator = jsonschema.Draft7Validator(schema)
|
||||||
|
return ["$%s: %s" % ("".join(".%s" % p if isinstance(p, str) else "[%d]" % p for p in e.absolute_path), e.message)
|
||||||
|
for e in validator.iter_errors(data)]
|
||||||
|
|
||||||
|
|
||||||
|
def semantic_errors(data):
|
||||||
|
"""Cross-references the schema cannot express."""
|
||||||
|
errs = []
|
||||||
|
terms, execs = data.get("terminals", {}), data.get("executors", {})
|
||||||
|
for tname, t in terms.items():
|
||||||
|
for ex in t.get("executors", []):
|
||||||
|
if ex not in execs:
|
||||||
|
errs.append("terminals.%s: unknown executor '%s'" % (tname, ex))
|
||||||
|
if t.get("transport") == "warpgate-ssh":
|
||||||
|
if t.get("os") != "windows" or t.get("launch") != "schtasks":
|
||||||
|
errs.append("terminals.%s: warpgate-ssh currently supports only os=windows, launch=schtasks" % tname)
|
||||||
|
if t.get("target") != "win10":
|
||||||
|
errs.append("terminals.%s: warpgate-ssh helper scripts only reach target 'win10' for now" % tname)
|
||||||
|
if t.get("transport") == "local" and t.get("os") != "linux":
|
||||||
|
errs.append("terminals.%s: local transport supports only os=linux" % tname)
|
||||||
|
for ename, e in execs.items():
|
||||||
|
adapter = e.get("adapter", ename)
|
||||||
|
if adapter not in ("codex", "claude-code", "shell"):
|
||||||
|
errs.append("executors.%s: no adapter '%s' (set 'adapter')" % (ename, adapter))
|
||||||
|
cli = data.get("agentd", {}).get("cli_terminal")
|
||||||
|
if cli and cli not in terms:
|
||||||
|
errs.append("agentd.cli_terminal: unknown terminal '%s'" % cli)
|
||||||
|
for pname, p in data.get("projects", {}).items():
|
||||||
|
routing = p.get("routing", {})
|
||||||
|
for user, tname in routing.get("by_assignee", {}).items():
|
||||||
|
if tname not in terms:
|
||||||
|
errs.append("projects.%s.routing.by_assignee.%s: unknown terminal '%s'" % (pname, user, tname))
|
||||||
|
for tname, ex in routing.get("default_executor", {}).items():
|
||||||
|
if tname not in terms:
|
||||||
|
errs.append("projects.%s.routing.default_executor: unknown terminal '%s'" % (pname, tname))
|
||||||
|
elif ex not in terms[tname].get("executors", []):
|
||||||
|
errs.append("projects.%s.routing.default_executor.%s: '%s' not in that terminal's executors" % (pname, tname, ex))
|
||||||
|
for label, route in routing.get("by_label", {}).items():
|
||||||
|
if "executor" in route and route["executor"] not in execs:
|
||||||
|
errs.append("projects.%s.routing.by_label.%s: unknown executor '%s'" % (pname, label, route["executor"]))
|
||||||
|
backoff = p.get("retry", {}).get("backoff_minutes")
|
||||||
|
if backoff is not None and not backoff:
|
||||||
|
errs.append("projects.%s.retry.backoff_minutes: empty" % pname)
|
||||||
|
return errs
|
||||||
|
|
||||||
|
|
||||||
|
def validate(data, schema=None):
|
||||||
|
if schema is None:
|
||||||
|
schema = json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
|
||||||
|
errs = schema_errors(data, schema)
|
||||||
|
return errs or semantic_errors(data)
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
def __init__(self, data, path=None):
|
||||||
|
self.data = data
|
||||||
|
self.path = path
|
||||||
|
self.plane = data["plane"]
|
||||||
|
self.terminals = data["terminals"]
|
||||||
|
self.executors = data["executors"]
|
||||||
|
self.projects = data["projects"]
|
||||||
|
self.agentd = data.get("agentd", {})
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state_dir(self):
|
||||||
|
p = Path(os.path.expanduser(self.agentd.get("state_dir", "state")))
|
||||||
|
return p if p.is_absolute() else ROOT / p
|
||||||
|
|
||||||
|
def helpers_dir(self, terminal):
|
||||||
|
d = self.terminals[terminal].get("helpers_dir") or self.agentd.get("helpers_dir") or str(ROOT / "tools")
|
||||||
|
return Path(os.path.expanduser(d))
|
||||||
|
|
||||||
|
def token_env(self, terminal):
|
||||||
|
"""Environment variable that carries the terminal's Plane token (== the Vault key name)."""
|
||||||
|
return self.terminals[terminal]["token_key"]
|
||||||
|
|
||||||
|
def token(self, terminal):
|
||||||
|
return os.environ.get(self.token_env(terminal))
|
||||||
|
|
||||||
|
def secret_env_args(self):
|
||||||
|
"""`--secret-env` arguments for agent-access.py exec, one per distinct token key."""
|
||||||
|
keys = sorted({t["token_key"] for t in self.terminals.values()})
|
||||||
|
return ["--secret-env %s=%s" % (k, k) for k in keys]
|
||||||
|
|
||||||
|
def project(self, name):
|
||||||
|
if name not in self.projects:
|
||||||
|
raise ConfigError("unknown project %s (have %s)" % (name, ", ".join(self.projects)))
|
||||||
|
return self.projects[name]
|
||||||
|
|
||||||
|
|
||||||
|
def load(path=None):
|
||||||
|
if path is None:
|
||||||
|
env = os.environ.get("AGENTD_CONFIG")
|
||||||
|
candidates = [Path(env)] if env else list(DEFAULT_PATHS)
|
||||||
|
path = next((p for p in candidates if p.exists()), None)
|
||||||
|
if path is None:
|
||||||
|
raise ConfigError("no config found (tried %s); copy config/agentd.example.yaml" % ", ".join(map(str, candidates)))
|
||||||
|
path = Path(path)
|
||||||
|
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||||
|
errs = validate(data)
|
||||||
|
if errs:
|
||||||
|
raise ConfigError("%s is invalid:\n " % path + "\n ".join(errs))
|
||||||
|
return Config(data, path)
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
# plane-agentd example configuration (design.md §5). Values that are secrets are *Vault key names* only;
|
||||||
|
# agentd reads the token from the environment variable of the same name, injected by
|
||||||
|
# agent-access.py exec --secret-env PLANE_WINDOW10_API_TOKEN=PLANE_WINDOW10_API_TOKEN ...
|
||||||
|
# Copy to config/agentd.local.yaml (git-ignored) or point AGENTD_CONFIG at your file.
|
||||||
|
plane: {base_url: https://plane.kappiee.com, workspace: kappiee}
|
||||||
|
vault: {client: ~/.local/share/server-access-client/agent-access.py, config: https://nas.tail9531b.ts.net:8443/identity/agent.json}
|
||||||
|
|
||||||
|
agentd:
|
||||||
|
state_dir: state # relative to the repo root; JSON state, logs, heartbeat, local run dirs
|
||||||
|
helpers_dir: /home/ubuntu/Documents/wjj/project/gwent-design/tools # win10-ps.sh / win10-put.sh
|
||||||
|
interval: 120 # seconds between ticks in --loop mode
|
||||||
|
max_rounds_per_thread: 8 # rebuild the executor thread (with a handoff summary) after this many rounds
|
||||||
|
comment_max_chars: 6000
|
||||||
|
cli_terminal: oregon # identity used by `agentd new/comment/state/assign`
|
||||||
|
|
||||||
|
terminals:
|
||||||
|
win10:
|
||||||
|
transport: warpgate-ssh
|
||||||
|
target: win10
|
||||||
|
os: windows
|
||||||
|
launch: schtasks
|
||||||
|
repo: 'C:\Users\Administrator\Documents\GwentAssistantProject\gwent'
|
||||||
|
run_root: 'C:\Users\Administrator\Documents\GwentAssistantProject\gwent\runtime\agentd'
|
||||||
|
bins: {codex: 'C:\Users\Administrator\AppData\Local\Programs\OpenAI\Codex\bin\codex.exe'}
|
||||||
|
plane_user: window10
|
||||||
|
token_key: PLANE_WINDOW10_API_TOKEN
|
||||||
|
concurrency: 1
|
||||||
|
busy_processes: [codex] # do not start while a codex.exe we did not start is running
|
||||||
|
capabilities: [android, game]
|
||||||
|
executors: [codex]
|
||||||
|
oregon:
|
||||||
|
transport: local
|
||||||
|
os: linux
|
||||||
|
launch: subprocess
|
||||||
|
repo: /home/ubuntu/Documents/wjj/project/gwent
|
||||||
|
plane_user: oregon
|
||||||
|
token_key: PLANE_OREGON_API_TOKEN
|
||||||
|
concurrency: 2
|
||||||
|
bins: {claude: /home/ubuntu/.local/bin/claude} # cron has a minimal PATH
|
||||||
|
executors: [claude-code, codex, shell]
|
||||||
|
|
||||||
|
executors:
|
||||||
|
codex:
|
||||||
|
adapter: codex
|
||||||
|
run: "codex exec --json -C '{repo}' -s workspace-write -m {model} -c 'model_reasoning_effort=\"{effort}\"' {tier} -o '{out}' -"
|
||||||
|
resume: "codex exec resume {thread} --json -m {model} -c 'model_reasoning_effort=\"{effort}\"' {tier} -o '{out}' -"
|
||||||
|
tier_arg: "-c 'service_tier=\"{tier}\"'"
|
||||||
|
thread_from: events.jsonl:thread.started.thread_id
|
||||||
|
last_message: '{out}'
|
||||||
|
models: [gpt-6.1-sol, gpt-6-astra]
|
||||||
|
default_effort: medium
|
||||||
|
claude-code:
|
||||||
|
adapter: claude-code
|
||||||
|
run: "claude -p --model {model} --output-format json --permission-mode {perm}"
|
||||||
|
resume: "claude -p --resume {thread} --model {model} --output-format json --permission-mode {perm}"
|
||||||
|
permission_mode: acceptEdits
|
||||||
|
thread_from: json:session_id
|
||||||
|
last_message: json:result
|
||||||
|
models: [sonnet, opus, haiku]
|
||||||
|
shell:
|
||||||
|
adapter: shell
|
||||||
|
run: '{script}'
|
||||||
|
resume: false
|
||||||
|
|
||||||
|
projects:
|
||||||
|
GWENT:
|
||||||
|
id: 79cb5a60-aa4f-4138-9864-1dd70426e665
|
||||||
|
key_prefix: GW
|
||||||
|
webhook_secret_key: PLANE_WEBHOOK_GWENT
|
||||||
|
states: {backlog: Backlog, todo: Todo, running: In Progress, waiting: Waiting, review: Review, done: Done}
|
||||||
|
reviewer: oregon # Plane user that gets [review] items
|
||||||
|
answerer: oregon # Plane user that gets [question]/[blocked] and failures (default: reviewer)
|
||||||
|
protocol_file: /home/ubuntu/Documents/wjj/project/gwent/design/board-protocol.md
|
||||||
|
routing:
|
||||||
|
by_assignee: {window10: win10, oregon: oregon}
|
||||||
|
by_label:
|
||||||
|
'model:astra': {model: gpt-6-astra, effort: high}
|
||||||
|
'model:sol': {model: gpt-6.1-sol, tier: fast}
|
||||||
|
'model:opus': {model: opus}
|
||||||
|
'exec:claude': {executor: claude-code}
|
||||||
|
'needs-game': {serialize_group: game}
|
||||||
|
default_executor: {win10: codex, oregon: claude-code}
|
||||||
|
retry:
|
||||||
|
transient_max: 3
|
||||||
|
backoff_minutes: [2, 5, 10]
|
||||||
|
# transient_patterns / env_patterns default to the built-in lists in agentd/core.py
|
||||||
|
timeout_hours: 4
|
||||||
|
max_rounds_per_thread: 8
|
||||||
|
notify: {ntfy_topic: gwent, 'on': [waiting, review, failed]} # phase 3, not used yet; 'on' must be quoted (YAML 1.1 boolean)
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
{
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"title": "plane-agentd configuration",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["plane", "terminals", "executors", "projects"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"plane": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["base_url", "workspace"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"base_url": {"type": "string"},
|
||||||
|
"workspace": {"type": "string"},
|
||||||
|
"timeout": {"type": "integer", "minimum": 1}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vault": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"client": {"type": "string"},
|
||||||
|
"config": {"type": "string"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"agentd": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"state_dir": {"type": "string"},
|
||||||
|
"helpers_dir": {"type": "string"},
|
||||||
|
"interval": {"type": "integer", "minimum": 10},
|
||||||
|
"max_rounds_per_thread": {"type": "integer", "minimum": 1},
|
||||||
|
"comment_max_chars": {"type": "integer", "minimum": 500},
|
||||||
|
"cli_terminal": {"type": "string"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"terminals": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/definitions/terminal"}},
|
||||||
|
"executors": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/definitions/executor"}},
|
||||||
|
"projects": {"type": "object", "minProperties": 1, "additionalProperties": {"$ref": "#/definitions/project"}}
|
||||||
|
},
|
||||||
|
"definitions": {
|
||||||
|
"strings": {"type": "array", "items": {"type": "string"}},
|
||||||
|
"terminal": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["transport", "os", "launch", "repo", "plane_user", "token_key", "executors"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"transport": {"enum": ["local", "warpgate-ssh"]},
|
||||||
|
"target": {"type": "string"},
|
||||||
|
"os": {"enum": ["linux", "windows"]},
|
||||||
|
"launch": {"enum": ["subprocess", "nohup", "schtasks"]},
|
||||||
|
"repo": {"type": "string"},
|
||||||
|
"plane_user": {"type": "string"},
|
||||||
|
"token_key": {"type": "string"},
|
||||||
|
"concurrency": {"type": "integer", "minimum": 1},
|
||||||
|
"capabilities": {"$ref": "#/definitions/strings"},
|
||||||
|
"executors": {"type": "array", "items": {"type": "string"}, "minItems": 1},
|
||||||
|
"bins": {"type": "object", "additionalProperties": {"type": "string"}},
|
||||||
|
"run_root": {"type": "string"},
|
||||||
|
"busy_processes": {"$ref": "#/definitions/strings"},
|
||||||
|
"helpers_dir": {"type": "string"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"executor": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["run"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"adapter": {"enum": ["codex", "claude-code", "shell"]},
|
||||||
|
"run": {"type": "string"},
|
||||||
|
"resume": {"type": ["string", "boolean"]},
|
||||||
|
"thread_from": {"type": "string"},
|
||||||
|
"last_message": {"type": "string"},
|
||||||
|
"models": {"$ref": "#/definitions/strings"},
|
||||||
|
"default_effort": {"type": "string"},
|
||||||
|
"permission_mode": {"type": "string"},
|
||||||
|
"tier_arg": {"type": "string"},
|
||||||
|
"script": {"type": "string"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"route": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"executor": {"type": "string"},
|
||||||
|
"model": {"type": "string"},
|
||||||
|
"effort": {"type": "string"},
|
||||||
|
"tier": {"type": "string"},
|
||||||
|
"serialize_group": {"type": "string"},
|
||||||
|
"script": {"type": "string"},
|
||||||
|
"timeout_hours": {"type": "number", "minimum": 0}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"project": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id", "states", "reviewer", "routing"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"id": {"type": "string"},
|
||||||
|
"key_prefix": {"type": "string"},
|
||||||
|
"webhook_secret_key": {"type": "string"},
|
||||||
|
"states": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["todo", "running", "waiting", "review", "done"],
|
||||||
|
"additionalProperties": {"type": "string"}
|
||||||
|
},
|
||||||
|
"reviewer": {"type": "string"},
|
||||||
|
"answerer": {"type": "string"},
|
||||||
|
"protocol_file": {"type": "string"},
|
||||||
|
"intro": {"type": "string"},
|
||||||
|
"routing": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["by_assignee"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"by_assignee": {"type": "object", "minProperties": 1, "additionalProperties": {"type": "string"}},
|
||||||
|
"by_label": {"type": "object", "additionalProperties": {"$ref": "#/definitions/route"}},
|
||||||
|
"default_executor": {"type": "object", "additionalProperties": {"type": "string"}}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"retry": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"transient_max": {"type": "integer", "minimum": 0},
|
||||||
|
"backoff_minutes": {"type": "array", "items": {"type": "number", "minimum": 0}, "minItems": 1},
|
||||||
|
"transient_patterns": {"$ref": "#/definitions/strings"},
|
||||||
|
"env_patterns": {"$ref": "#/definitions/strings"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"timeout_hours": {"type": "number", "minimum": 0},
|
||||||
|
"max_rounds_per_thread": {"type": "integer", "minimum": 1},
|
||||||
|
"notify": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"ntfy_topic": {"type": "string"},
|
||||||
|
"on": {"$ref": "#/definitions/strings"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user