Files
cleveragents-core/robot/helper_plan_lifecycle_v3.py
CoreRasurae b41f536da6
CI / helm (push) Successful in 47s
CI / build (push) Successful in 1m11s
CI / push-validation (push) Successful in 40s
CI / benchmark-publish (push) Has started running
CI / lint (push) Successful in 1m26s
CI / typecheck (push) Successful in 1m55s
CI / e2e_tests (push) Successful in 1m12s
CI / quality (push) Successful in 2m6s
CI / security (push) Successful in 2m9s
CI / benchmark-regression (push) Failing after 1m28s
CI / integration_tests (push) Successful in 3m48s
CI / unit_tests (push) Successful in 7m31s
CI / coverage (push) Has started running
CI / docker (push) Successful in 1m35s
CI / helm (pull_request) Successful in 39s
CI / push-validation (pull_request) Successful in 39s
CI / build (pull_request) Successful in 1m13s
CI / lint (pull_request) Successful in 1m32s
CI / quality (pull_request) Successful in 1m49s
CI / typecheck (pull_request) Successful in 1m48s
CI / security (pull_request) Successful in 1m58s
CI / status-check (push) Has been cancelled
CI / integration_tests (pull_request) Successful in 4m48s
CI / unit_tests (pull_request) Successful in 5m11s
CI / coverage (pull_request) Has started running
CI / docker (pull_request) Successful in 1m44s
CI / status-check (pull_request) Has been cancelled
fix(automation): respect automation profile gates in lifecycle service and async jobs
Add BDD regression tests for issue #4328 automation profile gates

Tests verify that complete_strategize() and complete_execute() respect
automation profile thresholds and do NOT unconditionally call auto_progress().

Covers 8 built-in profiles: manual, full-auto, supervised, auto,
review_before_apply, ci, trusted

ISSUES CLOSED: #4328
2026-05-14 22:57:11 +00:00

770 lines
26 KiB
Python

"""Helper utilities for v3 plan lifecycle Robot integration tests.
Covers the integration between:
- Plan and Action domain models (plan.py, action.py)
- PlanLifecycleService (plan_lifecycle_service.py)
- Automation levels
- Subplan support
"""
from __future__ import annotations
import sys
from datetime import UTC, datetime
from cleveragents.application.services.plan_lifecycle_service import (
InvalidPhaseTransitionError,
PlanLifecycleService,
PlanNotReadyError,
)
from cleveragents.config.settings import Settings
from cleveragents.domain.models.core.action import (
Action,
ActionArgument,
ActionState,
ArgumentRequirement,
ArgumentType,
)
from cleveragents.domain.models.core.plan import (
AutomationProfileProvenance,
AutomationProfileRef,
ExecutionMode,
NamespacedName,
PlanPhase,
ProcessingState,
ProjectLink,
SubplanConfig,
SubplanFailureHandler,
SubplanMergeStrategy,
SubplanStatus,
can_transition,
)
def _create_service() -> PlanLifecycleService:
settings = Settings()
return PlanLifecycleService(settings=settings)
def _lifecycle_full_cycle() -> None:
"""Integration test: create action -> use -> strategize -> execute -> apply."""
service = _create_service()
# Create action (state is AVAILABLE immediately per new spec)
action = service.create_action(
name="local/code-review",
definition_of_done="All files reviewed and approved",
strategy_actor="local/planner",
execution_actor="local/executor",
description="Code review action",
)
assert action.state == ActionState.AVAILABLE, (
f"Expected AVAILABLE, got {action.state}"
)
# Use action to create plan (enters STRATEGIZE, QUEUED)
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-001")],
created_by="integration-test",
)
assert plan.phase == PlanPhase.STRATEGIZE
assert plan.state == ProcessingState.QUEUED
plan_id = plan.identity.plan_id
# Strategize lifecycle
plan = service.start_strategize(plan_id)
assert plan.state == ProcessingState.PROCESSING
plan = service.complete_strategize(plan_id)
assert plan.state == ProcessingState.COMPLETE
# Execute transition
plan = service.execute_plan(plan_id)
assert plan.phase == PlanPhase.EXECUTE
assert plan.state == ProcessingState.QUEUED
plan = service.start_execute(plan_id)
assert plan.state == ProcessingState.PROCESSING
plan = service.complete_execute(plan_id)
assert plan.state == ProcessingState.COMPLETE
# Apply transition
plan = service.apply_plan(plan_id)
assert plan.phase == PlanPhase.APPLY
assert plan.state == ProcessingState.QUEUED
plan = service.start_apply(plan_id)
assert plan.state == ProcessingState.PROCESSING
plan = service.complete_apply(plan_id)
assert plan.phase == PlanPhase.APPLY
assert plan.state == ProcessingState.APPLIED
assert plan.is_terminal
print("lifecycle-full-cycle-ok")
def _action_crud() -> None:
"""Integration test: action CRUD operations via service."""
service = _create_service()
# Create multiple actions (both AVAILABLE immediately)
a1 = service.create_action(
name="local/action-one",
description="Action one",
definition_of_done="Done 1",
strategy_actor="local/s1",
execution_actor="local/e1",
)
service.create_action(
name="local/action-two",
description="Action two",
definition_of_done="Done 2",
strategy_actor="local/s2",
execution_actor="local/e2",
)
# List all
actions = service.list_actions()
assert len(actions) == 2, f"Expected 2 actions, got {len(actions)}"
# List by namespace
local_actions = service.list_actions(namespace="local")
assert len(local_actions) == 2
# Get by name
found = service.get_action_by_name("local/action-one")
assert str(found.namespaced_name) == str(a1.namespaced_name)
# Archive
archived = service.archive_action(str(a1.namespaced_name))
assert archived.state == ActionState.ARCHIVED
# List by state
available = service.list_actions(state=ActionState.AVAILABLE)
assert len(available) == 1 # Only action-two remains available
archived_list = service.list_actions(state=ActionState.ARCHIVED)
assert len(archived_list) == 1
print("action-crud-ok")
def _plan_cancellation() -> None:
"""Integration test: plan cancellation at various phases."""
service = _create_service()
action = service.create_action(
name="local/cancel-test",
description="Cancel test action",
definition_of_done="Test cancellation",
strategy_actor="local/s",
execution_actor="local/e",
)
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-001")],
)
plan_id = plan.identity.plan_id
# Cancel while in STRATEGIZE
cancelled = service.cancel_plan(plan_id, reason="User requested")
assert cancelled.state == ProcessingState.CANCELLED
assert cancelled.error_message == "User requested"
print("plan-cancellation-ok")
def _plan_failure_recovery() -> None:
"""Integration test: plan failure and error states."""
service = _create_service()
action = service.create_action(
name="local/fail-test",
description="Fail test action",
definition_of_done="Test failure",
strategy_actor="local/s",
execution_actor="local/e",
)
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-001")],
)
plan_id = plan.identity.plan_id
# Start strategize then fail it
service.start_strategize(plan_id)
plan = service.fail_strategize(plan_id, "Resource unavailable")
assert plan.state == ProcessingState.ERRORED
assert plan.is_errored
assert plan.error_message == "Resource unavailable"
print("plan-failure-recovery-ok")
def _invalid_transitions() -> None:
"""Integration test: invalid phase transitions raise proper errors."""
service = _create_service()
action = service.create_action(
name="local/invalid-trans",
description="Invalid transitions test action",
definition_of_done="Test invalid transitions",
strategy_actor="local/s",
execution_actor="local/e",
)
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-001")],
)
plan_id = plan.identity.plan_id
# Try to execute before completing strategize (should fail)
try:
service.execute_plan(plan_id)
print("FAIL: Expected PlanNotReadyError")
return
except PlanNotReadyError:
pass
# Try to apply before execute (should fail)
try:
service.apply_plan(plan_id)
print("FAIL: Expected InvalidPhaseTransitionError or PlanNotReadyError")
return
except (InvalidPhaseTransitionError, PlanNotReadyError):
pass
print("invalid-transitions-ok")
def _automation_full_auto() -> None:
"""Integration test: full automation auto-progresses through all phases."""
service = _create_service()
action = service.create_action(
name="local/full-auto",
description="Full automation test action",
definition_of_done="Test full automation",
strategy_actor="local/s",
execution_actor="local/e",
)
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-001")],
)
plan_id = plan.identity.plan_id
# Set full-auto profile so auto_progress will advance the plan
plan.automation_profile = AutomationProfileRef(
profile_name="full-auto",
provenance=AutomationProfileProvenance.PLAN,
)
# Start and complete strategize - should auto-progress to EXECUTE
service.start_strategize(plan_id)
plan = service.complete_strategize(plan_id)
# After complete_strategize with full-auto profile, auto_progress kicks in
plan = service.get_plan(plan_id)
assert plan.phase == PlanPhase.EXECUTE, f"Expected EXECUTE, got {plan.phase}"
# Start and complete execute - should auto-progress to APPLY
service.start_execute(plan_id)
plan = service.complete_execute(plan_id)
plan = service.get_plan(plan_id)
assert plan.phase == PlanPhase.APPLY, f"Expected APPLY, got {plan.phase}"
print("automation-full-auto-ok")
def _automation_review_before_apply() -> None:
"""Integration test: review-before-apply pauses before apply."""
service = _create_service()
action = service.create_action(
name="local/review-mode",
description="Review mode test action",
definition_of_done="Test review mode",
strategy_actor="local/s",
execution_actor="local/e",
)
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-001")],
)
plan_id = plan.identity.plan_id
# Set "auto" profile (review-before-apply equivalent):
# create_tool=0.0, select_tool=1.0 → advances after strategize but pauses at apply
plan.automation_profile = AutomationProfileRef(
profile_name="auto",
provenance=AutomationProfileProvenance.PLAN,
)
# Complete strategize - should auto-progress to EXECUTE
service.start_strategize(plan_id)
plan = service.complete_strategize(plan_id)
plan = service.get_plan(plan_id)
assert plan.phase == PlanPhase.EXECUTE, f"Expected EXECUTE, got {plan.phase}"
# Complete execute - should NOT auto-progress to APPLY (requires review)
service.start_execute(plan_id)
plan = service.complete_execute(plan_id)
plan = service.get_plan(plan_id)
# In review/auto mode, execute completes but does not auto-transition to APPLY
assert plan.phase == PlanPhase.EXECUTE, (
f"Expected EXECUTE (paused), got {plan.phase}"
)
assert plan.state == ProcessingState.COMPLETE
print("automation-review-before-apply-ok")
def _automation_pause_resume() -> None:
"""Integration test: pause and resume automation."""
service = _create_service()
action = service.create_action(
name="local/pause-resume",
description="Pause/resume test action",
definition_of_done="Test pause/resume",
strategy_actor="local/s",
execution_actor="local/e",
)
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-001")],
)
plan_id = plan.identity.plan_id
# Pause - should set profile to manual
plan = service.pause_plan(plan_id)
assert plan.automation_profile is not None
assert plan.automation_profile.profile_name == "manual"
# Resume with auto profile
plan = service.resume_plan(plan_id, automation_profile="auto")
assert plan.automation_profile is not None
assert plan.automation_profile.profile_name == "auto"
print("automation-pause-resume-ok")
def _subplan_model() -> None:
"""Integration test: subplan configuration and failure handling."""
config = SubplanConfig(
execution_mode=ExecutionMode.PARALLEL,
merge_strategy=SubplanMergeStrategy.GIT_THREE_WAY,
max_parallel=3,
fail_fast=True,
timeout_per_subplan_seconds=600,
retry_failed=True,
max_retries=2,
)
assert config.execution_mode == ExecutionMode.PARALLEL
assert config.max_parallel == 3
# Create a subplan status (using valid ULID-format IDs)
# Error strings must match RETRIABLE_FAILURES (PascalCase: TimeoutError, etc.)
status = SubplanStatus(
subplan_id="01KH72MDGQMPRW3R5WPZVB8KC1",
action_name="local/sub-task",
target_resources=["src/main.py"],
status=ProcessingState.ERRORED,
error="TimeoutError: Operation timed out",
)
handler = SubplanFailureHandler()
should_stop = handler.should_stop_others(config, status)
assert should_stop is True, "fail_fast=True should stop others"
should_retry = handler.should_retry(config, status)
assert should_retry is True, "TimeoutError is retriable and retries enabled"
# Non-retriable error (must match NON_RETRIABLE_ERRORS: ConfigurationError, etc.)
status_perm = SubplanStatus(
subplan_id="01KH72MDGQMPRW3R5WPZVB8KC2",
action_name="local/sub-task",
target_resources=["src/other.py"],
status=ProcessingState.ERRORED,
error="ConfigurationError: Invalid config",
)
should_retry_perm = handler.should_retry(config, status_perm)
assert should_retry_perm is False, "ConfigurationError is not retriable"
print("subplan-model-ok")
def _plan_with_subplans() -> None:
"""Integration test: plan with subplan config via service."""
service = _create_service()
action = service.create_action(
name="local/parent-action",
description="Parent action with subplans",
definition_of_done="Parent task with subplans",
strategy_actor="local/s",
execution_actor="local/e",
)
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[ProjectLink(project_name="proj-001")],
)
# Add subplan config to verify Plan model accepts it
plan_copy = plan.model_copy(
update={
"subplan_config": SubplanConfig(
execution_mode=ExecutionMode.SEQUENTIAL,
merge_strategy=SubplanMergeStrategy.SEQUENTIAL_APPLY,
),
"subplan_statuses": [
SubplanStatus(
subplan_id="01KH72MDGQMPRW3R5WPZVB8KC3",
action_name="local/sub-task",
target_resources=["src/main.py"],
status=ProcessingState.QUEUED,
),
],
}
)
assert plan_copy.has_subplans
assert plan_copy.subplan_config is not None
assert plan_copy.subplan_config.execution_mode == ExecutionMode.SEQUENTIAL
assert len(plan_copy.subplan_statuses) == 1
# The original plan has no parent => is_root_plan
assert not plan_copy.is_subplan # no parent_plan_id set
print("plan-with-subplans-ok")
def _phase_transitions_model() -> None:
"""Integration test: verify can_transition function for all valid/invalid paths."""
# Valid transitions (four-phase: ACTION -> STRATEGIZE -> EXECUTE -> APPLY)
assert can_transition(PlanPhase.ACTION, PlanPhase.STRATEGIZE)
assert can_transition(PlanPhase.STRATEGIZE, PlanPhase.EXECUTE)
assert can_transition(PlanPhase.EXECUTE, PlanPhase.APPLY)
assert can_transition(PlanPhase.EXECUTE, PlanPhase.STRATEGIZE)
assert can_transition(PlanPhase.APPLY, PlanPhase.STRATEGIZE)
# Invalid transitions
assert not can_transition(PlanPhase.STRATEGIZE, PlanPhase.APPLY)
assert not can_transition(PlanPhase.ACTION, PlanPhase.EXECUTE)
assert not can_transition(PlanPhase.ACTION, PlanPhase.APPLY)
assert not can_transition(PlanPhase.APPLY, PlanPhase.EXECUTE)
print("phase-transitions-ok")
def _namespaced_name_parsing() -> None:
"""Integration test: NamespacedName parse and string round-trip."""
# Simple local name
n1 = NamespacedName.parse("local/my-action")
assert n1.namespace == "local"
assert n1.name == "my-action"
assert str(n1) == "local/my-action"
# Server-prefixed name
n2 = NamespacedName.parse("server:org/my-action")
assert n2.server == "server"
assert n2.namespace == "org"
assert n2.name == "my-action"
assert str(n2) == "server:org/my-action"
# Default namespace
n3 = NamespacedName.parse("standalone-action")
assert n3.namespace == "local"
assert n3.name == "standalone-action"
print("namespaced-name-ok")
def _action_argument_parsing() -> None:
"""Integration test: ActionArgument parse and validation."""
arg = ActionArgument.parse("target:str:required:The target directory")
assert arg.name == "target"
assert arg.arg_type.value == "string"
assert arg.requirement.value == "required"
assert arg.description == "The target directory"
# Test Action argument validation
action = Action(
namespaced_name=NamespacedName.parse("local/test-action"),
description="Test",
definition_of_done="Done",
strategy_actor="local/s",
execution_actor="local/e",
arguments=[arg],
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
# Valid arguments
errors = action.validate_arguments({"target": "/tmp/dir"})
assert len(errors) == 0, f"Expected no errors, got {errors}"
# Missing required argument
errors = action.validate_arguments({})
assert len(errors) > 0, "Expected validation error for missing required arg"
print("action-argument-ok")
def _action_yaml_config_loading() -> None:
"""Integration test: Action.from_config() loads YAML fields
and as_cli_dict() renders them."""
# Simulate a full YAML config dict with all supported fields
config = {
"name": "org/code-coverage",
"description": "Increase code coverage to target level",
"long_description": "Comprehensive code coverage improvement action",
"definition_of_done": "Coverage meets ${target_coverage}% threshold",
"strategy_actor": "openai/gpt-4",
"execution_actor": "anthropic/claude-3",
"review_actor": "local/reviewer",
"apply_actor": "local/applier",
"estimation_actor": "local/estimator",
"invariant_actor": "local/invariant-checker",
"automation_profile": "org/high-trust",
"invariants": [
"No test deletions allowed",
"Coverage must not decrease",
"All new code must have tests",
],
"arguments": [
{
"name": "target_coverage",
"type": "integer",
"required": True,
"description": "Target coverage percentage",
"default": 80,
"min_value": 0,
"max_value": 100,
},
{
"name": "test_framework",
"type": "string",
"required": False,
"description": "Test framework to use",
},
{
"name": "include_branches",
"type": "boolean",
"required": False,
"description": "Include branch coverage",
},
],
"reusable": True,
"read_only": False,
"tags": ["coverage", "testing"],
"created_by": "yaml-test",
}
# --- Test from_config() loads all fields correctly ---
action = Action.from_config(config)
assert str(action.namespaced_name) == "org/code-coverage", (
f"Expected 'org/code-coverage', got '{action.namespaced_name}'"
)
assert action.description == "Increase code coverage to target level"
assert action.long_description == "Comprehensive code coverage improvement action"
assert action.definition_of_done == "Coverage meets ${target_coverage}% threshold"
assert action.strategy_actor == "openai/gpt-4"
assert action.execution_actor == "anthropic/claude-3"
assert action.review_actor == "local/reviewer"
assert action.apply_actor == "local/applier"
assert action.estimation_actor == "local/estimator"
assert action.invariant_actor == "local/invariant-checker"
assert action.automation_profile == "org/high-trust"
assert action.reusable is True
assert action.read_only is False
assert action.state == ActionState.AVAILABLE
assert action.created_by == "yaml-test"
assert action.tags == ["coverage", "testing"]
# Invariants
assert len(action.invariants) == 3
assert action.invariants[0] == "No test deletions allowed"
assert action.invariants[1] == "Coverage must not decrease"
assert action.invariants[2] == "All new code must have tests"
# Arguments
assert len(action.arguments) == 3
arg_coverage = action.arguments[0]
assert arg_coverage.name == "target_coverage"
assert arg_coverage.arg_type == ArgumentType.INTEGER
assert arg_coverage.requirement == ArgumentRequirement.REQUIRED
assert arg_coverage.description == "Target coverage percentage"
assert arg_coverage.default_value == 80
assert arg_coverage.min_value == 0
assert arg_coverage.max_value == 100
arg_framework = action.arguments[1]
assert arg_framework.name == "test_framework"
assert arg_framework.arg_type == ArgumentType.STRING
assert arg_framework.requirement == ArgumentRequirement.OPTIONAL
arg_branches = action.arguments[2]
assert arg_branches.name == "include_branches"
assert arg_branches.arg_type == ArgumentType.BOOLEAN
assert arg_branches.requirement == ArgumentRequirement.OPTIONAL
# --- Test as_cli_dict() renders expected keys ---
cli = action.as_cli_dict()
# Core identity
assert cli["name"] == "org/code-coverage"
assert cli["description"] == "Increase code coverage to target level"
assert cli["state"] == "available"
# Actors
assert cli["strategy_actor"] == "openai/gpt-4"
assert cli["execution_actor"] == "anthropic/claude-3"
assert cli["review_actor"] == "local/reviewer"
assert cli["apply_actor"] == "local/applier"
assert cli["estimation_actor"] == "local/estimator"
assert cli["invariant_actor"] == "local/invariant-checker"
# Automation profile
assert "automation_profile" in cli, "automation_profile missing from cli dict"
assert cli["automation_profile"] == "org/high-trust"
# Invariants
assert "invariants" in cli, "invariants missing from cli dict"
assert len(cli["invariants"]) == 3
assert cli["invariants"][0] == "No test deletions allowed"
assert cli["invariants"][2] == "All new code must have tests"
# Arguments rendered
assert "arguments" in cli, "arguments missing from cli dict"
assert len(cli["arguments"]) == 3
assert cli["arguments"][0]["name"] == "target_coverage"
assert cli["arguments"][0]["type"] == "integer"
assert cli["arguments"][0]["required"] is True
assert cli["arguments"][1]["name"] == "test_framework"
assert cli["arguments"][1]["required"] is False
assert cli["arguments"][2]["name"] == "include_branches"
assert cli["arguments"][2]["type"] == "boolean"
# Behavior flags
assert cli["reusable"] is True
assert cli["read_only"] is False
# Tags
assert cli["tags"] == ["coverage", "testing"]
# Definition of done
assert cli["definition_of_done"] == "Coverage meets ${target_coverage}% threshold"
print("action-yaml-config-ok")
def _plan_status_rendering() -> None:
"""Plan status renders action_name + automation profile.
Verifies that Plan.as_cli_dict() includes the correct action_name and
automation_profile fields with provenance tags.
"""
service = _create_service()
action = service.create_action(
name="local/status-test",
description="Status rendering test action",
definition_of_done="Test plan status rendering",
strategy_actor="local/s",
execution_actor="local/e",
automation_profile="manual",
)
plan = service.use_action(
action_name=str(action.namespaced_name),
project_links=[
ProjectLink(project_name="local/api-service", alias="api"),
],
)
# Verify action_name is set on the plan
assert plan.action_name, "Plan must have action_name set"
assert "status-test" in plan.action_name, (
f"Expected action_name to contain 'status-test', got '{plan.action_name}'"
)
# Verify as_cli_dict renders action_name
cli_dict = plan.as_cli_dict()
assert "action" in cli_dict, "CLI dict must include 'action' key"
assert cli_dict["action"] == plan.action_name
# Verify phase and state are rendered
assert cli_dict["phase"] == "strategize"
assert cli_dict["state"] == "queued"
# Verify projects are rendered
assert "projects" in cli_dict, "CLI dict must include 'projects' key"
assert len(cli_dict["projects"]) == 1
assert cli_dict["projects"][0]["name"] == "local/api-service"
assert cli_dict["projects"][0]["alias"] == "api"
# If automation profile was resolved, verify provenance
if plan.automation_profile:
assert "automation_profile" in cli_dict
assert "automation_profile_source" in cli_dict
assert cli_dict["automation_profile_source"] in (
"plan",
"action",
"project",
"global",
)
# Verify timestamps are present
assert "created_at" in cli_dict
assert "updated_at" in cli_dict
print("plan-status-rendering-ok")
def main() -> None:
if len(sys.argv) < 2:
raise SystemExit("Expected command argument")
command = sys.argv[1]
commands: dict[str, object] = {
"lifecycle-full-cycle": _lifecycle_full_cycle,
"action-crud": _action_crud,
"plan-cancellation": _plan_cancellation,
"plan-failure-recovery": _plan_failure_recovery,
"invalid-transitions": _invalid_transitions,
"automation-full-auto": _automation_full_auto,
"automation-review-before-apply": _automation_review_before_apply,
"automation-pause-resume": _automation_pause_resume,
"subplan-model": _subplan_model,
"plan-with-subplans": _plan_with_subplans,
"phase-transitions": _phase_transitions_model,
"namespaced-name": _namespaced_name_parsing,
"action-argument": _action_argument_parsing,
"action-yaml-config": _action_yaml_config_loading,
"plan-status-rendering": _plan_status_rendering,
}
if command not in commands:
raise SystemExit(f"Unknown command: {command}")
func = commands[command]
if callable(func):
func()
if __name__ == "__main__":
main()