Initial commit
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
"""Unified phase model for delegation work items.
|
||||
|
||||
A `Phase` is the single authoritative state of a delegation work item. It
|
||||
replaces the previous mixture of `status` + 5 metadata sub-state fields
|
||||
(activation_state / lifecycle_state / review_state / manager_release_state /
|
||||
review_execution_state) which were tangled and could disagree with each other.
|
||||
|
||||
Design:
|
||||
- One enum value per concrete situation a card can be in.
|
||||
- Pure-function projections derive everything else (kanban column, owner,
|
||||
TaskStatus, runnability, verdict).
|
||||
- A static transition table is enforced at every write.
|
||||
- A **phase-transition hook** mechanism lets other layers (task.status,
|
||||
role_session.status, dispatcher wake signal, ...) subscribe to phase
|
||||
changes and synchronise themselves in one place. This eliminates the
|
||||
bug class where code forgot to update a dependent layer after
|
||||
transitioning a phase, leaving task/session state desynchronised from
|
||||
work-item phase.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Awaitable, Callable, Mapping, Optional
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from opc.core.models import Phase, TaskStatus
|
||||
from opc.layer2_organization.work_item_identity import work_item_turn_type_from_metadata
|
||||
|
||||
__all__ = [
|
||||
"Phase",
|
||||
"TODO_PHASES",
|
||||
"IN_PROGRESS_PHASES",
|
||||
"IN_REVIEW_PHASES",
|
||||
"DONE_PHASES",
|
||||
"TERMINAL_PHASES",
|
||||
"RUNNABLE_PHASES",
|
||||
"WAITING_EXTERNAL_PHASES",
|
||||
"ALLOWED_TRANSITIONS",
|
||||
"InvalidPhaseTransition",
|
||||
"validate_transition",
|
||||
"kanban_column",
|
||||
"is_runnable",
|
||||
"is_terminal",
|
||||
"is_waiting_external",
|
||||
"effective_owner",
|
||||
"verdict",
|
||||
"task_status_for_phase",
|
||||
"phase_for_task_status",
|
||||
"coerce_phase",
|
||||
"is_review_execution_work_item_metadata",
|
||||
"should_hide_work_item_from_company_kanban",
|
||||
"is_resumable_after_claim_release",
|
||||
"is_orphaned",
|
||||
"is_dispatchable",
|
||||
"PhaseTransitionHook",
|
||||
"register_phase_transition_hook",
|
||||
"clear_phase_transition_hooks",
|
||||
"on_phase_transition",
|
||||
]
|
||||
|
||||
|
||||
# ── Phase transition hook mechanism (D2) ─────────────────────────────────
|
||||
#
|
||||
# Other layers (task.status sync, role_session.status sync, dispatcher wake)
|
||||
# subscribe by calling register_phase_transition_hook. The store fires
|
||||
# on_phase_transition after every successful work-item write. Each hook
|
||||
# is invoked with (previous_phase, target_phase, item, store=...). One
|
||||
# hook raising never prevents the others from firing nor prevents the
|
||||
# write itself from succeeding; the goal is "best-effort cross-layer
|
||||
# convergence", not transactional consistency. (The work-item write IS
|
||||
# the source of truth — hooks merely propagate it.)
|
||||
|
||||
PhaseTransitionHook = Callable[..., Awaitable[None]]
|
||||
|
||||
_PHASE_TRANSITION_HOOKS: list[PhaseTransitionHook] = []
|
||||
|
||||
|
||||
def register_phase_transition_hook(hook: PhaseTransitionHook) -> None:
|
||||
"""Register a hook to fire after every work-item phase write.
|
||||
|
||||
Hook signature: ``async def hook(previous: Phase | None, target: Phase,
|
||||
item: DelegationWorkItem, *, store: OPCStore) -> None``.
|
||||
|
||||
Hooks are invoked in registration order. Re-registering the same
|
||||
callable is a no-op (idempotent — important for module-level
|
||||
registration that may run twice under reload/import re-entry).
|
||||
"""
|
||||
if hook not in _PHASE_TRANSITION_HOOKS:
|
||||
_PHASE_TRANSITION_HOOKS.append(hook)
|
||||
|
||||
|
||||
def clear_phase_transition_hooks() -> None:
|
||||
"""Remove all registered hooks. Test-only helper."""
|
||||
_PHASE_TRANSITION_HOOKS.clear()
|
||||
|
||||
|
||||
async def on_phase_transition(
|
||||
previous: Phase | None,
|
||||
target: Phase,
|
||||
item: Any,
|
||||
*,
|
||||
store: Any,
|
||||
) -> None:
|
||||
"""Fire all registered phase-transition hooks.
|
||||
|
||||
Called by the store after a successful work-item write commits. Each
|
||||
hook gets the same arguments; failures in one hook are logged but do
|
||||
not affect other hooks or the upstream write.
|
||||
"""
|
||||
for hook in list(_PHASE_TRANSITION_HOOKS):
|
||||
try:
|
||||
await hook(previous, target, item, store=store)
|
||||
except Exception:
|
||||
logger.opt(exception=True).warning(
|
||||
f"phase transition hook {getattr(hook, '__name__', repr(hook))} "
|
||||
f"failed for {previous} -> {target} on work_item "
|
||||
f"{getattr(item, 'work_item_id', '?')}"
|
||||
)
|
||||
|
||||
|
||||
def _normalized_text(value: Any) -> str:
|
||||
return str(value or "").strip().lower()
|
||||
|
||||
|
||||
def is_review_execution_work_item_metadata(metadata: Mapping[str, Any] | None) -> bool:
|
||||
"""True when the work item is a hidden auxiliary review card."""
|
||||
data = dict(metadata or {})
|
||||
if bool(data.get("review_execution_work_item", False)):
|
||||
return True
|
||||
work_kind = _normalized_text(data.get("work_kind") or work_item_turn_type_from_metadata(data, fallback=""))
|
||||
return work_kind == "review" and bool(str(data.get("review_target_work_item_id", "") or "").strip())
|
||||
|
||||
|
||||
def is_report_execution_work_item_metadata(metadata: Mapping[str, Any] | None) -> bool:
|
||||
"""True when the work item is the hidden auxiliary report-generation card.
|
||||
|
||||
Spawned by ``_apply_done_transition`` after a worker finishes its execute
|
||||
turn but before review begins, so the worker can produce a structured
|
||||
handoff report on its own session before the reviewer sees anything.
|
||||
"""
|
||||
data = dict(metadata or {})
|
||||
if bool(data.get("report_execution_work_item", False)):
|
||||
return True
|
||||
work_kind = _normalized_text(data.get("work_kind") or work_item_turn_type_from_metadata(data, fallback=""))
|
||||
return work_kind == "report" and bool(str(data.get("report_target_work_item_id", "") or "").strip())
|
||||
|
||||
|
||||
def should_hide_work_item_from_company_kanban(metadata: Mapping[str, Any] | None) -> bool:
|
||||
"""True when the kanban UI should not display this work item."""
|
||||
data = dict(metadata or {})
|
||||
return bool(data.get("hidden_from_company_kanban", False))
|
||||
|
||||
|
||||
# ── Set views over phases ────────────────────────────────────────────────
|
||||
|
||||
TODO_PHASES: frozenset[Phase] = frozenset({
|
||||
Phase.QUEUED,
|
||||
Phase.READY,
|
||||
Phase.READY_FOR_REWORK,
|
||||
Phase.WAITING_DEPENDENCIES,
|
||||
})
|
||||
|
||||
IN_PROGRESS_PHASES: frozenset[Phase] = frozenset({
|
||||
Phase.RUNNING,
|
||||
Phase.WAITING_FOR_PEER,
|
||||
Phase.WAITING_FOR_CHILDREN,
|
||||
Phase.PAUSED,
|
||||
Phase.NEEDS_ATTENTION,
|
||||
})
|
||||
|
||||
IN_REVIEW_PHASES: frozenset[Phase] = frozenset({
|
||||
Phase.AWAITING_MANAGER_REVIEW,
|
||||
Phase.AWAITING_HUMAN,
|
||||
})
|
||||
|
||||
DONE_PHASES: frozenset[Phase] = frozenset({
|
||||
Phase.APPROVED,
|
||||
Phase.FAILED,
|
||||
Phase.CANCELLED,
|
||||
})
|
||||
|
||||
TERMINAL_PHASES: frozenset[Phase] = DONE_PHASES
|
||||
|
||||
RUNNABLE_PHASES: frozenset[Phase] = frozenset({
|
||||
Phase.READY,
|
||||
Phase.READY_FOR_REWORK,
|
||||
})
|
||||
|
||||
WAITING_EXTERNAL_PHASES: frozenset[Phase] = frozenset({
|
||||
Phase.WAITING_FOR_PEER,
|
||||
Phase.WAITING_FOR_CHILDREN,
|
||||
Phase.WAITING_DEPENDENCIES,
|
||||
Phase.NEEDS_ATTENTION,
|
||||
})
|
||||
|
||||
|
||||
# ── Allowed transitions (state machine) ──────────────────────────────────
|
||||
|
||||
# Two universal exits every non-terminal phase must have:
|
||||
# - FAILED: abort-on-error path. Any exception in the agent runtime
|
||||
# must be able to land here from any sub-state.
|
||||
# - CANCELLED: user/system cancel path. Same reason.
|
||||
# These are baked into the table below; the invariant tests in
|
||||
# test_phase_state_machine_invariants.py guarantee they stay there.
|
||||
_UNIVERSAL_EXITS: frozenset[Phase] = frozenset({Phase.FAILED, Phase.CANCELLED})
|
||||
|
||||
# Transitions that release a stale claim back to the dispatcher queue,
|
||||
# enabling crash-recovery (Bug C). Whenever an in-flight card's runtime
|
||||
# session dies, the sweeper transitions the card back to READY (or
|
||||
# READY_FOR_REWORK if appropriate), which the dispatcher then re-claims.
|
||||
# This must be wired into every in-flight phase.
|
||||
_RECOVERY_EXITS: frozenset[Phase] = frozenset({Phase.READY})
|
||||
|
||||
|
||||
ALLOWED_TRANSITIONS: dict[Phase, frozenset[Phase]] = {
|
||||
# todo
|
||||
Phase.QUEUED: frozenset({
|
||||
Phase.READY,
|
||||
Phase.WAITING_DEPENDENCIES,
|
||||
}) | _UNIVERSAL_EXITS,
|
||||
Phase.WAITING_DEPENDENCIES: frozenset({
|
||||
Phase.READY,
|
||||
}) | _UNIVERSAL_EXITS,
|
||||
Phase.READY: frozenset({
|
||||
Phase.RUNNING,
|
||||
Phase.WAITING_DEPENDENCIES,
|
||||
}) | _UNIVERSAL_EXITS,
|
||||
Phase.READY_FOR_REWORK: frozenset({
|
||||
Phase.RUNNING,
|
||||
}) | _UNIVERSAL_EXITS,
|
||||
|
||||
# in_progress
|
||||
# RUNNING → APPROVED is the self-completion path used by review
|
||||
# work items (which do not themselves need external review). It is
|
||||
# also a legal escape for any future "auto-approved" work kind.
|
||||
Phase.RUNNING: frozenset({
|
||||
Phase.WAITING_FOR_PEER,
|
||||
Phase.WAITING_FOR_CHILDREN,
|
||||
Phase.PAUSED,
|
||||
Phase.NEEDS_ATTENTION,
|
||||
Phase.AWAITING_MANAGER_REVIEW,
|
||||
Phase.AWAITING_HUMAN,
|
||||
Phase.APPROVED,
|
||||
}) | _UNIVERSAL_EXITS | _RECOVERY_EXITS,
|
||||
Phase.WAITING_FOR_PEER: frozenset({
|
||||
Phase.RUNNING,
|
||||
}) | _UNIVERSAL_EXITS | _RECOVERY_EXITS,
|
||||
Phase.WAITING_FOR_CHILDREN: frozenset({
|
||||
Phase.RUNNING,
|
||||
}) | _UNIVERSAL_EXITS | _RECOVERY_EXITS,
|
||||
Phase.PAUSED: frozenset({
|
||||
Phase.RUNNING,
|
||||
}) | _UNIVERSAL_EXITS | _RECOVERY_EXITS,
|
||||
Phase.NEEDS_ATTENTION: frozenset({
|
||||
Phase.RUNNING,
|
||||
}) | _UNIVERSAL_EXITS | _RECOVERY_EXITS,
|
||||
|
||||
# in_review
|
||||
Phase.AWAITING_MANAGER_REVIEW: frozenset({
|
||||
Phase.APPROVED,
|
||||
Phase.READY_FOR_REWORK,
|
||||
# Escalation path: when the manager-review/rework loop exceeds its
|
||||
# retry budget, the runtime escalates the card to a human decider
|
||||
# instead of bouncing it back to the worker yet again.
|
||||
Phase.AWAITING_HUMAN,
|
||||
}) | _UNIVERSAL_EXITS | _RECOVERY_EXITS,
|
||||
Phase.AWAITING_HUMAN: frozenset({
|
||||
Phase.APPROVED,
|
||||
Phase.READY_FOR_REWORK,
|
||||
}) | _UNIVERSAL_EXITS | _RECOVERY_EXITS,
|
||||
|
||||
# terminal — no outgoing edges
|
||||
Phase.APPROVED: frozenset(),
|
||||
Phase.FAILED: frozenset(),
|
||||
Phase.CANCELLED: frozenset(),
|
||||
}
|
||||
|
||||
|
||||
class InvalidPhaseTransition(ValueError):
|
||||
"""Raised when a write attempts a transition not in ALLOWED_TRANSITIONS."""
|
||||
|
||||
|
||||
def validate_transition(previous: Phase | None, target: Phase) -> None:
|
||||
"""Raise InvalidPhaseTransition when target is not reachable from previous.
|
||||
|
||||
Initial creation (previous is None) and idempotent writes (previous == target)
|
||||
are always allowed.
|
||||
"""
|
||||
if previous is None or previous == target:
|
||||
return
|
||||
allowed = ALLOWED_TRANSITIONS.get(previous, frozenset())
|
||||
if target not in allowed:
|
||||
raise InvalidPhaseTransition(
|
||||
f"invalid phase transition: {previous.value} -> {target.value}"
|
||||
)
|
||||
|
||||
|
||||
# ── Pure-function projections ────────────────────────────────────────────
|
||||
|
||||
_PHASE_TO_COLUMN: dict[Phase, str] = {
|
||||
**{p: "todo" for p in TODO_PHASES},
|
||||
**{p: "in_progress" for p in IN_PROGRESS_PHASES},
|
||||
**{p: "in_review" for p in IN_REVIEW_PHASES},
|
||||
**{p: "done" for p in DONE_PHASES},
|
||||
}
|
||||
|
||||
|
||||
def kanban_column(phase: Phase) -> str:
|
||||
"""Project Phase to one of the four kanban columns."""
|
||||
return _PHASE_TO_COLUMN[phase]
|
||||
|
||||
|
||||
def is_runnable(phase: Phase) -> bool:
|
||||
"""Whether the dispatcher should claim+spawn this card on its next tick."""
|
||||
return phase in RUNNABLE_PHASES
|
||||
|
||||
|
||||
# Phases whose runtime claim, when released as stale (process restart, crashed
|
||||
# session), allow the dispatcher to re-pick the card. Without this set, a card
|
||||
# that was actively running when the process died becomes a zombie: phase still
|
||||
# says RUNNING / WAITING_FOR_*, but no session is alive to make progress.
|
||||
_RESUMABLE_AFTER_STALE_CLAIM: frozenset[Phase] = frozenset({
|
||||
Phase.RUNNING,
|
||||
Phase.WAITING_FOR_PEER,
|
||||
Phase.WAITING_FOR_CHILDREN,
|
||||
Phase.PAUSED,
|
||||
Phase.NEEDS_ATTENTION,
|
||||
Phase.AWAITING_MANAGER_REVIEW,
|
||||
Phase.AWAITING_HUMAN,
|
||||
})
|
||||
|
||||
|
||||
def is_resumable_after_claim_release(phase: Phase) -> bool:
|
||||
"""True iff a stale claim on this phase can be released and the card
|
||||
re-picked up by the dispatcher (rather than left as a zombie).
|
||||
|
||||
Used by the periodic stale-claim sweeper. Every in-flight phase must
|
||||
return True here — the invariant test in
|
||||
test_phase_state_machine_invariants.py enforces this.
|
||||
"""
|
||||
return phase in _RESUMABLE_AFTER_STALE_CLAIM
|
||||
|
||||
|
||||
def is_orphaned(item: Any) -> bool:
|
||||
"""A work item is orphaned when its phase says 'in flight' but no
|
||||
runtime session currently holds a claim on it.
|
||||
|
||||
Typical scenario: the process that owned the claim died (restart,
|
||||
crash). On startup the stale-claim sweeper clears the claim
|
||||
metadata; this function then lets the dispatcher re-pick the card
|
||||
on the next tick, eliminating zombie work items (Bug C).
|
||||
"""
|
||||
if not is_resumable_after_claim_release(item.phase):
|
||||
return False
|
||||
claim = str(getattr(item, "claimed_by_role_runtime_session_id", "") or "").strip()
|
||||
return not claim
|
||||
|
||||
|
||||
def is_dispatchable(item: Any) -> bool:
|
||||
"""Combined check used by the dispatcher: pick this card on next tick?
|
||||
|
||||
True for fresh-runnable phases (READY/READY_FOR_REWORK) and for
|
||||
orphaned in-flight cards (RUNNING / WAITING_FOR_* / PAUSED / etc.
|
||||
whose previous claim died and was swept).
|
||||
|
||||
Fix 5 PR3: a work item stamped with ``metadata.queued_behind_session``
|
||||
is waiting behind another task on its role's serial queue — the
|
||||
dispatcher must skip it until the session that holds the queue
|
||||
dequeues it (``clear_session_focus_on_terminal_hook`` does the
|
||||
dequeue on terminal transitions and drops the stamp). Without this
|
||||
check the dispatcher would claim both the focused item and the queued
|
||||
item into the same session on the same tick, defeating the queue.
|
||||
"""
|
||||
metadata = getattr(item, "metadata", {}) or {}
|
||||
if isinstance(metadata, dict) and str(metadata.get("queued_behind_session", "") or "").strip():
|
||||
return False
|
||||
if isinstance(metadata, dict) and str(metadata.get("dispatch_hold", "") or "").strip():
|
||||
return False
|
||||
return is_runnable(item.phase) or is_orphaned(item)
|
||||
|
||||
|
||||
def is_terminal(phase: Phase) -> bool:
|
||||
return phase in TERMINAL_PHASES
|
||||
|
||||
|
||||
def is_waiting_external(phase: Phase) -> bool:
|
||||
"""True when the card is suspended waiting for an external event/actor."""
|
||||
return phase in WAITING_EXTERNAL_PHASES
|
||||
|
||||
|
||||
def effective_owner(phase: Phase, item: Any) -> tuple[str, str]:
|
||||
"""Return (role_id, seat_id) of the actor currently responsible for the card.
|
||||
|
||||
In `in_review` phases the owner swaps from worker to manager so the card
|
||||
appears in the reviewer's swimlane. In all other phases the worker stays
|
||||
as owner. `item` may be a DelegationWorkItem or a mapping with the same
|
||||
field names.
|
||||
"""
|
||||
def _field(name: str) -> str:
|
||||
if isinstance(item, Mapping):
|
||||
return str(item.get(name, "") or "").strip()
|
||||
return str(getattr(item, name, "") or "").strip()
|
||||
|
||||
if phase in IN_REVIEW_PHASES:
|
||||
return _field("manager_role_id") or _field("role_id"), \
|
||||
_field("manager_seat_id") or _field("seat_id")
|
||||
return _field("role_id"), _field("seat_id")
|
||||
|
||||
|
||||
_PHASE_TO_VERDICT: dict[Phase, str] = {
|
||||
Phase.APPROVED: "approve",
|
||||
Phase.READY_FOR_REWORK: "rework",
|
||||
Phase.FAILED: "fail",
|
||||
Phase.CANCELLED: "cancel",
|
||||
}
|
||||
|
||||
|
||||
def verdict(phase: Phase) -> Optional[str]:
|
||||
"""Return the verdict label for terminal/rework phases, else None."""
|
||||
return _PHASE_TO_VERDICT.get(phase)
|
||||
|
||||
|
||||
# Company-mode boundary note:
|
||||
# DelegationWorkItem.phase is the business state source of truth. Task.status is
|
||||
# only the runtime/job projection needed by existing execution, session, and
|
||||
# tool infrastructure.
|
||||
_PHASE_TO_TASK_STATUS: dict[Phase, TaskStatus] = {
|
||||
# todo column
|
||||
Phase.QUEUED: TaskStatus.PENDING,
|
||||
Phase.READY: TaskStatus.PENDING,
|
||||
Phase.READY_FOR_REWORK: TaskStatus.PENDING,
|
||||
Phase.WAITING_DEPENDENCIES: TaskStatus.BLOCKED,
|
||||
# in_progress column
|
||||
Phase.RUNNING: TaskStatus.RUNNING,
|
||||
Phase.WAITING_FOR_PEER: TaskStatus.AWAITING_PEER,
|
||||
Phase.WAITING_FOR_CHILDREN: TaskStatus.BLOCKED,
|
||||
Phase.PAUSED: TaskStatus.BLOCKED,
|
||||
Phase.NEEDS_ATTENTION: TaskStatus.BLOCKED,
|
||||
# in_review column
|
||||
Phase.AWAITING_MANAGER_REVIEW: TaskStatus.AWAITING_MANAGER_REVIEW,
|
||||
Phase.AWAITING_HUMAN: TaskStatus.AWAITING_HUMAN,
|
||||
# done column
|
||||
Phase.APPROVED: TaskStatus.DONE,
|
||||
Phase.FAILED: TaskStatus.FAILED,
|
||||
Phase.CANCELLED: TaskStatus.CANCELLED,
|
||||
}
|
||||
|
||||
|
||||
def task_status_for_phase(phase: Phase) -> TaskStatus:
|
||||
"""Project Phase to the corresponding runtime TaskStatus.
|
||||
|
||||
Used when syncing a work item's phase back to its associated tasks row.
|
||||
"""
|
||||
return _PHASE_TO_TASK_STATUS[phase]
|
||||
|
||||
|
||||
# ── TaskStatus → Phase reverse mapping (for runtime → work-item sync) ────
|
||||
|
||||
def phase_for_task_status(
|
||||
status: TaskStatus | str,
|
||||
*,
|
||||
has_pending_children: bool = False,
|
||||
) -> Phase:
|
||||
"""Map a runtime TaskStatus to a Phase.
|
||||
|
||||
`has_pending_children` lets the caller distinguish two BLOCKED sub-cases:
|
||||
BLOCKED-with-children-pending → WAITING_FOR_CHILDREN, otherwise PAUSED.
|
||||
All other TaskStatus values map 1-to-1.
|
||||
"""
|
||||
s = TaskStatus(status) if not isinstance(status, TaskStatus) else status
|
||||
if s == TaskStatus.RUNNING:
|
||||
return Phase.RUNNING
|
||||
if s == TaskStatus.AWAITING_PEER:
|
||||
return Phase.WAITING_FOR_PEER
|
||||
if s == TaskStatus.BLOCKED:
|
||||
return Phase.WAITING_FOR_CHILDREN if has_pending_children else Phase.PAUSED
|
||||
if s in (TaskStatus.AWAITING_MANAGER_REVIEW, TaskStatus.AWAITING_REVIEW):
|
||||
return Phase.AWAITING_MANAGER_REVIEW
|
||||
if s == TaskStatus.AWAITING_HUMAN:
|
||||
return Phase.AWAITING_HUMAN
|
||||
if s == TaskStatus.DONE:
|
||||
return Phase.APPROVED
|
||||
if s == TaskStatus.FAILED:
|
||||
return Phase.FAILED
|
||||
if s == TaskStatus.CANCELLED:
|
||||
return Phase.CANCELLED
|
||||
# PENDING / IDLE → READY (caller can override to QUEUED if not released)
|
||||
return Phase.READY
|
||||
|
||||
|
||||
# ── Convenience parsers ─────────────────────────────────────────────────
|
||||
|
||||
def coerce_phase(value: Any) -> Phase:
|
||||
"""Parse a phase value from a string/Phase, raising ValueError on garbage."""
|
||||
if isinstance(value, Phase):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return Phase(value.strip().lower())
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"unknown phase value: {value!r}") from exc
|
||||
raise TypeError(f"phase must be Phase or str, got {type(value).__name__}")
|
||||
Reference in New Issue
Block a user