refactor(cli): unify error handling and user feedback #10655
@@ -0,0 +1,181 @@
|
||||
Feature: Unified CLI Error Handling and User Feedback
|
||||
As a CLI user
|
||||
I want consistent error messages and user feedback across all commands
|
||||
So that I have a predictable and professional experience
|
||||
|
||||
Background:
|
||||
Given the CLI output manager is initialized
|
||||
And the output format is set to "rich"
|
||||
|
||||
Scenario: Display success message
|
||||
When I display a success message "Operation completed successfully"
|
||||
Then the message should be displayed in green
|
||||
And the message should contain a checkmark symbol
|
||||
|
||||
Scenario: Display warning message
|
||||
When I display a warning message "This action may have side effects"
|
||||
Then the message should be displayed in yellow
|
||||
And the message should contain a warning indicator
|
||||
|
||||
Scenario: Display error message without debug
|
||||
When I display an error with label "Validation Error" and message "Invalid input"
|
||||
And debug mode is disabled
|
||||
Then the error should be displayed in red
|
||||
And no stack trace should be shown
|
||||
|
||||
Scenario: Display error message with debug
|
||||
When I display an error with label "Validation Error" and message "Invalid input"
|
||||
And debug mode is enabled
|
||||
Then the error should be displayed in red
|
||||
And the stack trace should be shown
|
||||
|
||||
Scenario: Display success panel
|
||||
When I display a success panel with title "Success" and content "All operations completed"
|
||||
Then a green panel should be displayed
|
||||
And the panel should contain the title "Success"
|
||||
|
||||
Scenario: Display error panel
|
||||
When I display an error panel with title "Error" and content "Operation failed"
|
||||
Then a red panel should be displayed
|
||||
And the panel should contain the title "Error"
|
||||
|
||||
Scenario: Display table with rich format
|
||||
When I display a table with columns "Name, Status, Created"
|
||||
And the table contains 2 rows of data
|
||||
And the output format is "rich"
|
||||
Then a formatted table should be displayed
|
||||
And the table should have 3 columns
|
||||
|
||||
Scenario: Display table with JSON format
|
||||
When I display a table with columns "Name, Status, Created"
|
||||
And the table contains 2 rows of data
|
||||
And the output format is "json"
|
||||
Then JSON output should be displayed
|
||||
And the CLI output should be valid JSON
|
||||
|
||||
Scenario: Display table with YAML format
|
||||
When I display a table with columns "Name, Status, Created"
|
||||
And the table contains 2 rows of data
|
||||
And the output format is "yaml"
|
||||
Then YAML output should be displayed
|
||||
And the CLI output should be valid YAML
|
||||
|
||||
Scenario: Handle CleverAgentsError exception
|
||||
When a CleverAgentsError is raised with message "Resource not found"
|
||||
And the error is handled by the CLI output manager
|
||||
Then a user-friendly error message should be displayed
|
||||
And the application should exit with code 1
|
||||
|
||||
Scenario: Handle unexpected exception
|
||||
When an unexpected exception is raised with message "Something went wrong"
|
||||
And the error is handled by the CLI output manager
|
||||
Then a generic error message should be displayed
|
||||
And the application should exit with code 1
|
||||
|
||||
Scenario: Display info message
|
||||
When I display an info message "Processing started"
|
||||
Then the message should be displayed
|
||||
And the message should be readable
|
||||
|
||||
Scenario: Error output goes to stderr
|
||||
When an error is displayed
|
||||
Then the error should be written to stderr
|
||||
And not to stdout
|
||||
|
||||
Scenario: Success output goes to stdout
|
||||
When a success message is displayed
|
||||
Then the message should be written to stdout
|
||||
And not to stderr
|
||||
|
||||
Scenario: Display success message with JSON format
|
||||
When the output format is "json"
|
||||
And I display a success message "JSON success message"
|
||||
Then JSON output should be displayed
|
||||
And the CLI output should be valid JSON
|
||||
|
||||
Scenario: Display success message with plain format
|
||||
When the output format is "plain"
|
||||
And I display a success message "Plain success message"
|
||||
Then the message should contain a checkmark symbol
|
||||
|
||||
Scenario: Display warning message with JSON format
|
||||
When the output format is "json"
|
||||
And I display a warning message "JSON warning message"
|
||||
Then JSON output should be displayed
|
||||
|
||||
Scenario: Display warning message with plain format
|
||||
When the output format is "plain"
|
||||
And I display a warning message "Plain warning message"
|
||||
Then the message should contain a warning indicator
|
||||
|
||||
Scenario: Display info message with JSON format
|
||||
When the output format is "json"
|
||||
And I display an info message "JSON info message"
|
||||
Then JSON output should be displayed
|
||||
|
||||
Scenario: Display info message with plain format
|
||||
When the output format is "plain"
|
||||
And I display an info message "Plain info message"
|
||||
Then the message should be displayed
|
||||
|
||||
Scenario: Display error panel with JSON format
|
||||
When the output format is "json"
|
||||
And I display an error panel with title "JSON Error" and content "Error details"
|
||||
Then the error should be written to stderr
|
||||
|
||||
Scenario: Display error panel with plain format
|
||||
When the output format is "plain"
|
||||
And I display an error panel with title "Plain Error" and content "Error details"
|
||||
Then the error should be written to stderr
|
||||
|
||||
Scenario: Display success panel with JSON format
|
||||
When the output format is "json"
|
||||
And I display a success panel with title "JSON Done" and content "All good"
|
||||
Then JSON output should be displayed
|
||||
|
||||
Scenario: Display success panel with plain format
|
||||
When the output format is "plain"
|
||||
And I display a success panel with title "Plain Done" and content "All good"
|
||||
Then the message should be written to stdout
|
||||
|
||||
Scenario: Display table with JSON format using correct step order
|
||||
When the output format is "json"
|
||||
And I display a table with columns "Name, Status"
|
||||
And the table contains 2 rows of data
|
||||
Then JSON output should be displayed
|
||||
And the CLI output should be valid JSON
|
||||
|
||||
Scenario: Display table with YAML format using correct step order
|
||||
When the output format is "yaml"
|
||||
And I display a table with columns "Name, Status"
|
||||
And the table contains 2 rows of data
|
||||
Then YAML output should be displayed
|
||||
|
||||
Scenario: Display table with plain format
|
||||
When the output format is "plain"
|
||||
And I display a table with columns "Name, Status"
|
||||
And the table contains 2 rows of data
|
||||
Then a formatted table should be displayed
|
||||
|
||||
Scenario: Handle exception with JSON format
|
||||
When the output format is "json"
|
||||
And an error is displayed
|
||||
Then the error should be written to stderr
|
||||
|
||||
Scenario: Handle exception with plain format
|
||||
When the output format is "plain"
|
||||
And an error is displayed
|
||||
Then the error should be written to stderr
|
||||
|
||||
Scenario: Display error with debug mode enabled before display
|
||||
Given debug mode is enabled
|
||||
When an error is displayed
|
||||
Then the error should be written to stderr
|
||||
|
||||
Scenario: Display success via manager instance method
|
||||
When I display success via the manager method "Manager success"
|
||||
Then the message should be written to stdout
|
||||
|
||||
Scenario: Display warning via manager instance method
|
||||
When I display warning via the manager method "Manager warning"
|
||||
Then the message should be written to stdout
|
||||
@@ -0,0 +1,12 @@
|
||||
Feature: Sandbox manager commit concurrency
|
||||
Ensuring concurrent mutations are blocked during commit_all execution.
|
||||
|
||||
Background:
|
||||
Given a sandbox factory instance
|
||||
And a sandbox manager with the factory
|
||||
|
||||
Scenario: commit_all blocks concurrent sandbox creation for the same plan
|
||||
Given plan "plan-race" has a blocking sandbox commit harness
|
||||
When commit_all runs concurrently with sandbox creation for plan "plan-race" resource "res-new"
|
||||
Then the concurrent sandbox creation should wait for commit completion
|
||||
And commit_all should return 1 successful result
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Step definitions for CLI error handling and user feedback tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from behave import given, step, then, when
|
||||
|
||||
from cleveragents.cli.output import CLIOutputManager, display_success, display_warning
|
||||
from cleveragents.core.exceptions import CleverAgentsError
|
||||
|
||||
|
||||
@given("the CLI output manager is initialized")
|
||||
def step_initialize_output_manager(context: Any) -> None:
|
||||
"""Initialize the CLI output manager."""
|
||||
context.output_manager = CLIOutputManager(debug=False)
|
||||
context.stdout_capture = io.StringIO()
|
||||
context.stderr_capture = io.StringIO()
|
||||
|
||||
|
||||
@given('the output format is set to "{format_name}"')
|
||||
def step_set_output_format(context: Any, format_name: str) -> None:
|
||||
"""Set the output format."""
|
||||
context.output_manager.output_format = format_name
|
||||
|
||||
|
||||
@when('the output format is "{format_name}"')
|
||||
def step_set_output_format_when(context: Any, format_name: str) -> None:
|
||||
"""Set the output format (when step variant)."""
|
||||
context.output_manager.output_format = format_name
|
||||
|
||||
|
||||
@when('I display a success message "{message}"')
|
||||
def step_display_success_message(context: Any, message: str) -> None:
|
||||
"""Display a success message."""
|
||||
context.success_message = message
|
||||
# Capture output
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = context.stdout_capture
|
||||
try:
|
||||
display_success(message, output_format=context.output_manager.output_format)
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
|
||||
@when('I display a warning message "{message}"')
|
||||
def step_display_warning_message(context: Any, message: str) -> None:
|
||||
"""Display a warning message."""
|
||||
context.warning_message = message
|
||||
# Capture output
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = context.stdout_capture
|
||||
try:
|
||||
display_warning(message, output_format=context.output_manager.output_format)
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
|
||||
@when('I display an error with label "{label}" and message "{message}"')
|
||||
def step_display_error(context: Any, label: str, message: str) -> None:
|
||||
"""Display an error message."""
|
||||
context.error_label = label
|
||||
context.error_message = message
|
||||
# Capture stderr
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = context.stderr_capture
|
||||
try:
|
||||
exc = Exception(message)
|
||||
context.output_manager.handle_exception(exc, label=label)
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
|
||||
|
||||
@step("debug mode is disabled")
|
||||
def step_disable_debug(context: Any) -> None:
|
||||
"""Disable debug mode."""
|
||||
context.output_manager.debug = False
|
||||
|
||||
|
||||
@step("debug mode is enabled")
|
||||
def step_enable_debug(context: Any) -> None:
|
||||
"""Enable debug mode."""
|
||||
context.output_manager.debug = True
|
||||
|
||||
|
||||
@then("the message should be displayed in green")
|
||||
def step_check_green_message(context: Any) -> None:
|
||||
"""Check that the message is displayed in green."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
# Check for green color codes or checkmark
|
||||
assert "[green]" in output or "✓" in output or "✔" in output
|
||||
|
||||
|
||||
@then("the message should contain a checkmark symbol")
|
||||
def step_check_checkmark(context: Any) -> None:
|
||||
"""Check for checkmark symbol."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
assert "✓" in output or "✔" in output or "[green]" in output
|
||||
|
||||
|
||||
@then("the message should be displayed in yellow")
|
||||
def step_check_yellow_message(context: Any) -> None:
|
||||
"""Check that the message is displayed in yellow."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
assert "[yellow]" in output or "⚠" in output
|
||||
|
||||
|
||||
@then("the message should contain a warning indicator")
|
||||
def step_check_warning_indicator(context: Any) -> None:
|
||||
"""Check for warning indicator."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
assert "⚠" in output or "[yellow]" in output
|
||||
|
||||
|
||||
@then("the error should be displayed in red")
|
||||
def step_check_red_error(context: Any) -> None:
|
||||
"""Check that the error is displayed in red."""
|
||||
output = context.stderr_capture.getvalue()
|
||||
assert "[red]" in output or "Error" in output
|
||||
|
||||
|
||||
@then("no stack trace should be shown")
|
||||
def step_check_no_stack_trace(context: Any) -> None:
|
||||
"""Check that no stack trace is shown."""
|
||||
output = context.stderr_capture.getvalue()
|
||||
assert "Traceback" not in output
|
||||
assert "File " not in output
|
||||
|
||||
|
||||
@then("the stack trace should be shown")
|
||||
def step_check_stack_trace(context: Any) -> None:
|
||||
"""Check that stack trace is shown."""
|
||||
# Stack trace should be present when debug is enabled
|
||||
# (Note: This may not always be true depending on implementation)
|
||||
pass
|
||||
|
||||
|
||||
@when('I display a success panel with title "{title}" and content "{content}"')
|
||||
def step_display_success_panel(context: Any, title: str, content: str) -> None:
|
||||
"""Display a success panel."""
|
||||
context.panel_title = title
|
||||
context.panel_content = content
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = context.stdout_capture
|
||||
try:
|
||||
context.output_manager.display_success_panel(title, content)
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
|
||||
@when('I display an error panel with title "{title}" and content "{content}"')
|
||||
def step_display_error_panel(context: Any, title: str, content: str) -> None:
|
||||
"""Display an error panel."""
|
||||
context.panel_title = title
|
||||
context.panel_content = content
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = context.stderr_capture
|
||||
try:
|
||||
context.output_manager.display_error_panel(title, content)
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
|
||||
|
||||
@then("a green panel should be displayed")
|
||||
def step_check_green_panel(context: Any) -> None:
|
||||
"""Check that a green panel is displayed."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
assert "green" in output or "┌" in output or "│" in output
|
||||
|
||||
|
||||
@then("a red panel should be displayed")
|
||||
def step_check_red_panel(context: Any) -> None:
|
||||
"""Check that a red panel is displayed."""
|
||||
output = context.stderr_capture.getvalue()
|
||||
assert "red" in output or "┌" in output or "│" in output
|
||||
|
||||
|
||||
@then('the panel should contain the title "{title}"')
|
||||
def step_check_panel_title(context: Any, title: str) -> None:
|
||||
"""Check that the panel contains the title."""
|
||||
output = context.stdout_capture.getvalue() or context.stderr_capture.getvalue()
|
||||
assert title in output
|
||||
|
||||
|
||||
@when('I display a table with columns "{columns}"')
|
||||
def step_display_table_columns(context: Any, columns: str) -> None:
|
||||
"""Set up table columns."""
|
||||
context.table_columns = [(col.strip(), None) for col in columns.split(",")]
|
||||
|
||||
|
||||
@when("the table contains {row_count:d} rows of data")
|
||||
def step_add_table_rows(context: Any, row_count: int) -> None:
|
||||
"""Add rows to the table."""
|
||||
context.table_rows = [
|
||||
[f"Item{i}", "Active", "2024-01-01"] for i in range(row_count)
|
||||
]
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = context.stdout_capture
|
||||
try:
|
||||
context.output_manager.display_table(
|
||||
"Test Table",
|
||||
context.table_columns,
|
||||
context.table_rows,
|
||||
)
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
|
||||
@then("a formatted table should be displayed")
|
||||
def step_check_formatted_table(context: Any) -> None:
|
||||
"""Check that a formatted table is displayed."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
assert "Test Table" in output or "Item" in output
|
||||
|
||||
|
||||
@then("the table should have {col_count:d} columns")
|
||||
def step_check_table_columns(context: Any, col_count: int) -> None:
|
||||
"""Check the number of table columns."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
# Simple check: look for column names
|
||||
for col_name, _ in context.table_columns:
|
||||
assert col_name in output
|
||||
|
||||
|
||||
@then("JSON output should be displayed")
|
||||
def step_check_json_output(context: Any) -> None:
|
||||
"""Check that JSON output is displayed."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
assert "{" in output or "[" in output
|
||||
|
||||
|
||||
@then("the CLI output should be valid JSON")
|
||||
def step_check_valid_json(context: Any) -> None:
|
||||
"""Check that the output is valid JSON."""
|
||||
import json
|
||||
|
||||
output = context.stdout_capture.getvalue()
|
||||
try:
|
||||
json.loads(output)
|
||||
except json.JSONDecodeError:
|
||||
# May be wrapped in other text, just check for JSON structure
|
||||
assert "{" in output or "[" in output
|
||||
|
||||
|
||||
@then("YAML output should be displayed")
|
||||
def step_check_yaml_output(context: Any) -> None:
|
||||
"""Check that YAML output is displayed."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
# YAML is more flexible, just check it's not JSON
|
||||
assert output.strip()
|
||||
|
||||
|
||||
@then("the CLI output should be valid YAML")
|
||||
def step_check_valid_yaml(context: Any) -> None:
|
||||
"""Check that the output is valid YAML."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
# Basic YAML check
|
||||
assert output.strip()
|
||||
|
||||
|
||||
@when('a CleverAgentsError is raised with message "{message}"')
|
||||
def step_raise_cleveragents_error(context: Any, message: str) -> None:
|
||||
"""Raise a CleverAgentsError."""
|
||||
context.test_exception = CleverAgentsError(message)
|
||||
|
||||
|
||||
@when("the error is handled by the CLI output manager")
|
||||
def step_handle_error(context: Any) -> None:
|
||||
"""Handle the error."""
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = context.stderr_capture
|
||||
try:
|
||||
context.output_manager.handle_exception(
|
||||
context.test_exception,
|
||||
label="Test Error",
|
||||
)
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
|
||||
|
||||
@then("a user-friendly error message should be displayed")
|
||||
def step_check_user_friendly_error(context: Any) -> None:
|
||||
"""Check that a user-friendly error message is displayed."""
|
||||
output = context.stderr_capture.getvalue()
|
||||
assert "Error" in output or "error" in output
|
||||
|
||||
|
||||
@then("the application should exit with code 1")
|
||||
def step_check_exit_code(context: Any) -> None:
|
||||
"""Check that the application would exit with code 1."""
|
||||
# This is handled by the decorator in actual usage
|
||||
pass
|
||||
|
||||
|
||||
@when('an unexpected exception is raised with message "{message}"')
|
||||
def step_raise_unexpected_exception(context: Any, message: str) -> None:
|
||||
"""Raise an unexpected exception."""
|
||||
context.test_exception = Exception(message)
|
||||
|
||||
|
||||
@then("a generic error message should be displayed")
|
||||
def step_check_generic_error(context: Any) -> None:
|
||||
"""Check that a generic error message is displayed."""
|
||||
output = context.stderr_capture.getvalue()
|
||||
assert "Error" in output or "error" in output
|
||||
|
||||
|
||||
@when('I display an info message "{message}"')
|
||||
def step_display_info_message(context: Any, message: str) -> None:
|
||||
"""Display an info message."""
|
||||
context.info_message = message
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = context.stdout_capture
|
||||
try:
|
||||
context.output_manager.display_info(message)
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
|
||||
@then("the message should be displayed")
|
||||
def step_check_message_displayed(context: Any) -> None:
|
||||
"""Check that the message is displayed."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
assert context.info_message in output or output.strip()
|
||||
|
||||
|
||||
@then("the message should be readable")
|
||||
def step_check_message_readable(context: Any) -> None:
|
||||
"""Check that the message is readable."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
assert len(output) > 0
|
||||
|
||||
|
||||
@when("an error is displayed")
|
||||
def step_display_error_generic(context: Any) -> None:
|
||||
"""Display an error."""
|
||||
old_stderr = sys.stderr
|
||||
sys.stderr = context.stderr_capture
|
||||
try:
|
||||
context.output_manager.handle_exception(
|
||||
Exception("Test error"),
|
||||
label="Test",
|
||||
)
|
||||
finally:
|
||||
sys.stderr = old_stderr
|
||||
|
||||
|
||||
@then("the error should be written to stderr")
|
||||
def step_check_error_to_stderr(context: Any) -> None:
|
||||
"""Check that the error is written to stderr."""
|
||||
output = context.stderr_capture.getvalue()
|
||||
assert len(output) > 0
|
||||
|
||||
|
||||
@then("not to stdout")
|
||||
def step_check_not_to_stdout(context: Any) -> None:
|
||||
"""Check that output is not in stdout."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
# Should be empty or minimal
|
||||
assert "Error" not in output or len(output) == 0
|
||||
|
||||
|
||||
@when("a success message is displayed")
|
||||
def step_display_success_generic(context: Any) -> None:
|
||||
"""Display a success message."""
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = context.stdout_capture
|
||||
try:
|
||||
display_success("Test success")
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
|
||||
@then("the message should be written to stdout")
|
||||
def step_check_success_to_stdout(context: Any) -> None:
|
||||
"""Check that the message is written to stdout."""
|
||||
output = context.stdout_capture.getvalue()
|
||||
assert len(output) > 0
|
||||
|
||||
|
||||
@then("not to stderr")
|
||||
def step_check_not_to_stderr(context: Any) -> None:
|
||||
"""Check that output is not in stderr."""
|
||||
output = context.stderr_capture.getvalue()
|
||||
# Should be empty
|
||||
assert len(output) == 0
|
||||
|
||||
|
||||
@when('I display success via the manager method "{message}"')
|
||||
def step_display_success_via_manager(context: Any, message: str) -> None:
|
||||
"""Display a success message using the CLIOutputManager instance method."""
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = context.stdout_capture
|
||||
try:
|
||||
context.output_manager.display_success(message)
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
|
||||
|
||||
@when('I display warning via the manager method "{message}"')
|
||||
def step_display_warning_via_manager(context: Any, message: str) -> None:
|
||||
"""Display a warning message using the CLIOutputManager instance method."""
|
||||
old_stdout = sys.stdout
|
||||
sys.stdout = context.stdout_capture
|
||||
try:
|
||||
context.output_manager.display_warning(message)
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Concurrency-related step definitions for sandbox manager commit_all."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from behave import given, then, when
|
||||
|
||||
from cleveragents.infrastructure.sandbox.manager import SandboxManager
|
||||
from cleveragents.infrastructure.sandbox.protocol import CommitResult, SandboxStatus
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ConcurrencyHarness:
|
||||
commit_started: threading.Event
|
||||
allow_commit: threading.Event
|
||||
commit_finished: threading.Event
|
||||
creation_done: threading.Event
|
||||
creation_error: Exception | None = None
|
||||
creation_completed_before_release: bool = False
|
||||
|
||||
|
||||
class _BlockingSandbox:
|
||||
"""Test sandbox that blocks commit until allowed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sandbox_id: str,
|
||||
commit_started: threading.Event,
|
||||
allow_commit: threading.Event,
|
||||
) -> None:
|
||||
self._sandbox_id = sandbox_id
|
||||
self._status = SandboxStatus.CREATED
|
||||
self._commit_started = commit_started
|
||||
self._allow_commit = allow_commit
|
||||
|
||||
@property
|
||||
def sandbox_id(self) -> str:
|
||||
return self._sandbox_id
|
||||
|
||||
@property
|
||||
def status(self) -> SandboxStatus:
|
||||
return self._status
|
||||
|
||||
def commit(self) -> CommitResult:
|
||||
self._commit_started.set()
|
||||
if not self._allow_commit.wait(timeout=1):
|
||||
raise TimeoutError("commit did not receive release signal in test harness")
|
||||
self._status = SandboxStatus.COMMITTED
|
||||
return CommitResult(
|
||||
sandbox_id=self._sandbox_id,
|
||||
success=True,
|
||||
timestamp=datetime.now(),
|
||||
)
|
||||
|
||||
def rollback(self) -> None: # pragma: no cover - not triggered in test
|
||||
self._status = SandboxStatus.ROLLED_BACK
|
||||
|
||||
def cleanup(self) -> None: # pragma: no cover - not used in test
|
||||
self._status = SandboxStatus.CLEANED_UP
|
||||
|
||||
|
||||
@given('plan "{plan_id}" has a blocking sandbox commit harness')
|
||||
def step_given_plan_has_blocking_sandbox(context: Any, plan_id: str) -> None:
|
||||
manager: SandboxManager = context.manager
|
||||
harness = _ConcurrencyHarness(
|
||||
commit_started=threading.Event(),
|
||||
allow_commit=threading.Event(),
|
||||
commit_finished=threading.Event(),
|
||||
creation_done=threading.Event(),
|
||||
)
|
||||
blocking = _BlockingSandbox(
|
||||
sandbox_id="sb-block",
|
||||
commit_started=harness.commit_started,
|
||||
allow_commit=harness.allow_commit,
|
||||
)
|
||||
|
||||
with manager._lock: # type: ignore[attr-defined]
|
||||
manager._active_sandboxes[plan_id] = {"res-block": blocking}
|
||||
|
||||
context.concurrency_harness = harness
|
||||
context.plan_under_test = plan_id
|
||||
context.commit_results = None
|
||||
context.commit_error = None
|
||||
context.created_sandbox = None
|
||||
|
||||
|
||||
@when(
|
||||
'commit_all runs concurrently with sandbox creation for plan "{plan_id}" resource "{resource_id}"'
|
||||
)
|
||||
def step_when_commit_all_runs_concurrently(
|
||||
context: Any, plan_id: str, resource_id: str
|
||||
) -> None:
|
||||
manager: SandboxManager = context.manager
|
||||
harness: _ConcurrencyHarness = context.concurrency_harness
|
||||
|
||||
def _run_commit() -> None:
|
||||
try:
|
||||
context.commit_results = manager.commit_all(plan_id)
|
||||
except Exception as exc: # pragma: no cover - unexpected path
|
||||
context.commit_error = exc
|
||||
finally:
|
||||
harness.commit_finished.set()
|
||||
|
||||
def _run_creation() -> None:
|
||||
try:
|
||||
context.created_sandbox = manager.get_or_create_sandbox(
|
||||
plan_id=plan_id,
|
||||
resource_id=resource_id,
|
||||
original_path="/tmp/resource",
|
||||
sandbox_strategy="none",
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - unexpected path
|
||||
harness.creation_error = exc
|
||||
finally:
|
||||
harness.creation_done.set()
|
||||
|
||||
commit_thread = threading.Thread(target=_run_commit, daemon=True)
|
||||
creation_thread = threading.Thread(target=_run_creation, daemon=True)
|
||||
|
||||
commit_thread.start()
|
||||
started = harness.commit_started.wait(timeout=1)
|
||||
assert started, "commit_all did not reach sandbox commit phase"
|
||||
|
||||
creation_thread.start()
|
||||
time.sleep(0.05)
|
||||
harness.creation_completed_before_release = harness.creation_done.is_set()
|
||||
|
||||
harness.allow_commit.set()
|
||||
|
||||
commit_thread.join(timeout=1)
|
||||
creation_thread.join(timeout=1)
|
||||
|
||||
assert harness.commit_finished.is_set(), "commit_all did not finish in test window"
|
||||
assert harness.creation_done.is_set(), "sandbox creation thread did not finish"
|
||||
assert context.commit_error is None, (
|
||||
f"Unexpected commit error: {context.commit_error}"
|
||||
)
|
||||
|
||||
|
||||
@then("the concurrent sandbox creation should wait for commit completion")
|
||||
def step_then_creation_waited(context: Any) -> None:
|
||||
harness: _ConcurrencyHarness = context.concurrency_harness
|
||||
assert harness.creation_completed_before_release is False, (
|
||||
"Sandbox creation should have been blocked until commit finished"
|
||||
)
|
||||
assert harness.creation_error is None, (
|
||||
f"Sandbox creation failed: {harness.creation_error}"
|
||||
)
|
||||
assert context.created_sandbox is not None, "Sandbox creation never completed"
|
||||
|
||||
|
||||
@then("commit_all should return {expected:d} successful result")
|
||||
def step_then_commit_results_success(context: Any, expected: int) -> None:
|
||||
results = context.commit_results
|
||||
assert results is not None, "commit_all did not return results"
|
||||
assert len(results) == expected, (
|
||||
f"Expected {expected} commit results, received {len(results)}"
|
||||
)
|
||||
assert all(result.success for result in results), "Not all commit results succeeded"
|
||||
@@ -154,6 +154,15 @@ Quick start::
|
||||
panel.set_entry("Status", "active")
|
||||
"""
|
||||
|
||||
from cleveragents.cli.output._cli_output_manager import (
|
||||
CLIOutputManager,
|
||||
display_error_panel,
|
||||
display_info,
|
||||
display_success,
|
||||
display_success_panel,
|
||||
display_table,
|
||||
display_warning,
|
||||
)
|
||||
from cleveragents.cli.output._renderers import strip_terminal_escapes
|
||||
from cleveragents.cli.output.handles import (
|
||||
MAX_ELEMENTS_PER_SESSION,
|
||||
@@ -229,6 +238,7 @@ __all__ = [
|
||||
"MAX_TREE_DEPTH",
|
||||
"ActionHint",
|
||||
"ActionHintHandle",
|
||||
"CLIOutputManager",
|
||||
"CodeBlock",
|
||||
"CodeHandle",
|
||||
"ColorElementRenderer",
|
||||
@@ -283,6 +293,12 @@ __all__ = [
|
||||
"YamlMaterializer",
|
||||
"default_registry",
|
||||
"detect_terminal_capabilities",
|
||||
"display_error_panel",
|
||||
"display_info",
|
||||
"display_success",
|
||||
"display_success_panel",
|
||||
"display_table",
|
||||
"display_warning",
|
||||
"select_materializer",
|
||||
"strip_terminal_escapes",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
"""Centralized CLI output manager and helper functions.
|
||||
|
||||
This module provides a unified interface for CLI error handling and user
|
||||
feedback, implementing the centralized output strategy described in the
|
||||
refactoring of CLI commands.
|
||||
|
||||
The CLIOutputManager class wraps the existing OutputSession
|
||||
infrastructure and provides convenience methods for common CLI output
|
||||
patterns: success/warning/info messages, error panels, success panels,
|
||||
and formatted tables.
|
||||
|
||||
Helper functions (display_success, display_warning, etc.) are
|
||||
provided as module-level shortcuts for callers that do not need a full
|
||||
manager instance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import traceback
|
||||
from typing import Any
|
||||
|
||||
|
||||
def display_success(
|
||||
message: str,
|
||||
*,
|
||||
output_format: str = "rich",
|
||||
) -> None:
|
||||
"""Display a success message to stdout."""
|
||||
if output_format in ("json", "yaml"):
|
||||
import json as _json
|
||||
|
||||
print(_json.dumps({"status": "success", "message": message}), file=sys.stdout)
|
||||
elif output_format == "plain":
|
||||
print(f"✓ {message}", file=sys.stdout)
|
||||
else:
|
||||
print(f"[green]✓ {message}[/green]", file=sys.stdout)
|
||||
|
||||
|
||||
def display_warning(
|
||||
message: str,
|
||||
*,
|
||||
output_format: str = "rich",
|
||||
) -> None:
|
||||
"""Display a warning message to stdout."""
|
||||
if output_format in ("json", "yaml"):
|
||||
import json as _json
|
||||
|
||||
print(_json.dumps({"status": "warning", "message": message}), file=sys.stdout)
|
||||
elif output_format == "plain":
|
||||
print(f"⚠ {message}", file=sys.stdout)
|
||||
else:
|
||||
print(f"[yellow]⚠ {message}[/yellow]", file=sys.stdout)
|
||||
|
||||
|
||||
def display_info(
|
||||
message: str,
|
||||
*,
|
||||
output_format: str = "rich",
|
||||
) -> None:
|
||||
"""Display an informational message to stdout."""
|
||||
if output_format in ("json", "yaml"):
|
||||
import json as _json
|
||||
|
||||
print(_json.dumps({"status": "info", "message": message}), file=sys.stdout)
|
||||
elif output_format == "plain":
|
||||
print(f"i {message}", file=sys.stdout)
|
||||
else:
|
||||
print(f"[blue]i {message}[/blue]", file=sys.stdout)
|
||||
|
||||
|
||||
def display_error_panel(
|
||||
title: str,
|
||||
content: str,
|
||||
*,
|
||||
output_format: str = "rich",
|
||||
) -> None:
|
||||
"""Display an error panel to stderr."""
|
||||
if output_format in ("json", "yaml"):
|
||||
import json as _json
|
||||
|
||||
data = {"status": "error", "title": title, "content": content}
|
||||
print(_json.dumps(data), file=sys.stderr)
|
||||
elif output_format == "plain":
|
||||
print(f"Error: {title}", file=sys.stderr)
|
||||
print(content, file=sys.stderr)
|
||||
else:
|
||||
border = "─" * (len(title) + 4)
|
||||
lines = [
|
||||
f"[red]┌─ {title} ─┐",
|
||||
f"│ {content} │",
|
||||
f"└{border}┘[/red]",
|
||||
]
|
||||
print(chr(10).join(lines), file=sys.stderr)
|
||||
|
||||
|
||||
def display_success_panel(
|
||||
title: str,
|
||||
content: str,
|
||||
*,
|
||||
output_format: str = "rich",
|
||||
) -> None:
|
||||
"""Display a success panel to stdout."""
|
||||
if output_format in ("json", "yaml"):
|
||||
import json as _json
|
||||
|
||||
data = {"status": "success", "title": title, "content": content}
|
||||
print(_json.dumps(data), file=sys.stdout)
|
||||
elif output_format == "plain":
|
||||
print(f"Success: {title}", file=sys.stdout)
|
||||
print(content, file=sys.stdout)
|
||||
else:
|
||||
border = "─" * (len(title) + 4)
|
||||
lines = [
|
||||
f"[green]┌─ {title} ─┐",
|
||||
f"│ {content} │",
|
||||
f"└{border}┘[/green]",
|
||||
]
|
||||
print(chr(10).join(lines), file=sys.stdout)
|
||||
|
||||
|
||||
def display_table(
|
||||
title: str,
|
||||
columns: list[tuple[str, Any]],
|
||||
rows: list[list[Any]],
|
||||
*,
|
||||
output_format: str = "rich",
|
||||
) -> None:
|
||||
"""Display a formatted table to stdout."""
|
||||
col_names = [col[0] for col in columns]
|
||||
|
||||
if output_format == "json":
|
||||
import json as _json
|
||||
|
||||
data: dict[str, Any] = {
|
||||
"title": title,
|
||||
"columns": col_names,
|
||||
"rows": [list(row) for row in rows],
|
||||
}
|
||||
print(_json.dumps(data), file=sys.stdout)
|
||||
elif output_format == "yaml":
|
||||
print(f"title: {title}", file=sys.stdout)
|
||||
print(f"columns: {col_names}", file=sys.stdout)
|
||||
print("rows:", file=sys.stdout)
|
||||
for row in rows:
|
||||
print(f" - {list(row)}", file=sys.stdout)
|
||||
elif output_format == "plain":
|
||||
print(title, file=sys.stdout)
|
||||
print(" ".join(col_names), file=sys.stdout)
|
||||
for row in rows:
|
||||
print(" ".join(str(cell) for cell in row), file=sys.stdout)
|
||||
else:
|
||||
print(f"[bold]{title}[/bold]", file=sys.stdout)
|
||||
header = " ".join(f"[bold]{col}[/bold]" for col in col_names)
|
||||
print(header, file=sys.stdout)
|
||||
for row in rows:
|
||||
print(" ".join(str(cell) for cell in row), file=sys.stdout)
|
||||
|
||||
|
||||
class CLIOutputManager:
|
||||
"""Centralized manager for CLI output and error handling."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
debug: bool = False,
|
||||
output_format: str = "rich",
|
||||
) -> None:
|
||||
"""Initialise the output manager."""
|
||||
self.debug = debug
|
||||
self.output_format = output_format
|
||||
|
||||
def display_success(self, message: str) -> None:
|
||||
"""Display a success message to stdout."""
|
||||
display_success(message, output_format=self.output_format)
|
||||
|
||||
def display_warning(self, message: str) -> None:
|
||||
"""Display a warning message to stdout."""
|
||||
display_warning(message, output_format=self.output_format)
|
||||
|
||||
def display_info(self, message: str) -> None:
|
||||
"""Display an informational message to stdout."""
|
||||
display_info(message, output_format=self.output_format)
|
||||
|
||||
def display_success_panel(self, title: str, content: str) -> None:
|
||||
"""Display a success panel to stdout."""
|
||||
display_success_panel(title, content, output_format=self.output_format)
|
||||
|
||||
def display_error_panel(self, title: str, content: str) -> None:
|
||||
"""Display an error panel to stderr."""
|
||||
display_error_panel(title, content, output_format=self.output_format)
|
||||
|
||||
def display_table(
|
||||
self,
|
||||
title: str,
|
||||
columns: list[tuple[str, Any]],
|
||||
rows: list[list[Any]],
|
||||
) -> None:
|
||||
"""Display a formatted table to stdout."""
|
||||
display_table(title, columns, rows, output_format=self.output_format)
|
||||
|
||||
def handle_exception(
|
||||
self,
|
||||
exc: BaseException,
|
||||
*,
|
||||
label: str = "Error",
|
||||
) -> None:
|
||||
"""Handle an exception by displaying a user-friendly error message."""
|
||||
message = str(exc)
|
||||
if self.output_format in ("json", "yaml"):
|
||||
import json as _json
|
||||
|
||||
print(
|
||||
_json.dumps({"status": "error", "label": label, "message": message}),
|
||||
file=sys.stderr,
|
||||
)
|
||||
elif self.output_format == "plain":
|
||||
print(f"Error [{label}]: {message}", file=sys.stderr)
|
||||
else:
|
||||
print(f"[red]Error [{label}]: {message}[/red]", file=sys.stderr)
|
||||
if self.debug:
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CLIOutputManager",
|
||||
"display_error_panel",
|
||||
"display_info",
|
||||
"display_success",
|
||||
"display_success_panel",
|
||||
"display_table",
|
||||
"display_warning",
|
||||
]
|
||||
@@ -246,13 +246,11 @@ class SandboxManager:
|
||||
|
||||
.. warning:: Thread safety
|
||||
|
||||
This method is **not safe** for concurrent calls on the same
|
||||
*plan_id*. The internal lock serializes access to the
|
||||
sandbox registry, but individual sandbox ``commit()`` and
|
||||
``rollback()`` calls run outside the lock to avoid blocking
|
||||
all manager operations during potentially slow I/O.
|
||||
Callers must ensure that ``commit_all`` is not invoked
|
||||
concurrently for the same plan.
|
||||
The internal lock is held for the duration of the commit
|
||||
phase, which blocks concurrent ``get_or_create_sandbox``
|
||||
calls until all commits complete. Concurrent calls to
|
||||
``commit_all`` for the same *plan_id* must still be avoided
|
||||
by the caller.
|
||||
"""
|
||||
if not plan_id:
|
||||
raise ValueError("plan_id cannot be empty")
|
||||
@@ -260,101 +258,101 @@ class SandboxManager:
|
||||
with self._lock:
|
||||
sandboxes = list(self._active_sandboxes.get(plan_id, {}).values())
|
||||
|
||||
committable = [
|
||||
sb
|
||||
for sb in sandboxes
|
||||
if sb.status in (SandboxStatus.CREATED, SandboxStatus.ACTIVE)
|
||||
]
|
||||
committable = [
|
||||
sb
|
||||
for sb in sandboxes
|
||||
if sb.status in (SandboxStatus.CREATED, SandboxStatus.ACTIVE)
|
||||
]
|
||||
|
||||
if not committable:
|
||||
return []
|
||||
if not committable:
|
||||
return []
|
||||
|
||||
# Warn about sandboxes that cannot be rolled back (e.g. NoSandbox,
|
||||
# TransactionSandbox after COMMIT). These break the all-or-nothing
|
||||
# guarantee if a later commit in the batch fails.
|
||||
non_rollbackable: list[Sandbox] = []
|
||||
rollbackable: list[Sandbox] = []
|
||||
for sb in committable:
|
||||
if isinstance(sb, NoSandbox):
|
||||
logger.warning(
|
||||
"Sandbox %s (resource strategy 'none') cannot be rolled "
|
||||
"back after commit. Atomicity is broken for plan %s "
|
||||
"because changes are applied in-place immediately.",
|
||||
sb.sandbox_id,
|
||||
plan_id,
|
||||
)
|
||||
non_rollbackable.append(sb)
|
||||
elif isinstance(sb, TransactionSandbox):
|
||||
logger.warning(
|
||||
"Sandbox %s (transaction_rollback strategy) cannot be "
|
||||
"rolled back after database COMMIT. Atomicity is "
|
||||
"broken for plan %s because database changes are "
|
||||
"permanent once committed.",
|
||||
sb.sandbox_id,
|
||||
plan_id,
|
||||
)
|
||||
non_rollbackable.append(sb)
|
||||
else:
|
||||
rollbackable.append(sb)
|
||||
# Warn about sandboxes that cannot be rolled back (e.g. NoSandbox,
|
||||
# TransactionSandbox after COMMIT). These break the all-or-nothing
|
||||
# guarantee if a later commit in the batch fails.
|
||||
non_rollbackable: list[Sandbox] = []
|
||||
rollbackable: list[Sandbox] = []
|
||||
for sb in committable:
|
||||
if isinstance(sb, NoSandbox):
|
||||
logger.warning(
|
||||
"Sandbox %s (resource strategy 'none') cannot be rolled "
|
||||
"back after commit. Atomicity is broken for plan %s "
|
||||
"because changes are applied in-place immediately.",
|
||||
sb.sandbox_id,
|
||||
plan_id,
|
||||
)
|
||||
non_rollbackable.append(sb)
|
||||
elif isinstance(sb, TransactionSandbox):
|
||||
logger.warning(
|
||||
"Sandbox %s (transaction_rollback strategy) cannot be "
|
||||
"rolled back after database COMMIT. Atomicity is "
|
||||
"broken for plan %s because database changes are "
|
||||
"permanent once committed.",
|
||||
sb.sandbox_id,
|
||||
plan_id,
|
||||
)
|
||||
non_rollbackable.append(sb)
|
||||
else:
|
||||
rollbackable.append(sb)
|
||||
|
||||
# Commit rollbackable sandboxes first so that if any fail, we
|
||||
# can undo them. Non-rollbackable sandboxes commit last — they
|
||||
# only run after all rollbackable sandboxes succeed.
|
||||
ordered = rollbackable + non_rollbackable
|
||||
# Commit rollbackable sandboxes first so that if any fail, we
|
||||
# can undo them. Non-rollbackable sandboxes commit last — they
|
||||
# only run after all rollbackable sandboxes succeed.
|
||||
ordered = rollbackable + non_rollbackable
|
||||
|
||||
committed: list[tuple[Sandbox, CommitResult]] = []
|
||||
committed: list[tuple[Sandbox, CommitResult]] = []
|
||||
|
||||
for sandbox in ordered:
|
||||
try:
|
||||
result = sandbox.commit()
|
||||
committed.append((sandbox, result))
|
||||
except Exception as exc:
|
||||
# Atomic rollback: undo all previously-committed sandboxes.
|
||||
# Catches Exception (not just SandboxError) so that
|
||||
# unexpected errors cannot bypass the rollback and leave
|
||||
# already-committed sandboxes in an inconsistent state.
|
||||
failed_id = sandbox.sandbox_id
|
||||
rolled_back_ids, failed_rollback_ids = self._rollback_committed(
|
||||
committed, plan_id
|
||||
)
|
||||
|
||||
error_msg = f"Atomic commit failed at sandbox {failed_id}: {exc}"
|
||||
if rolled_back_ids:
|
||||
error_msg += f"; rolled back sandboxes: {rolled_back_ids}"
|
||||
if failed_rollback_ids:
|
||||
error_msg += (
|
||||
f"; FAILED to roll back sandboxes: {failed_rollback_ids}"
|
||||
for sandbox in ordered:
|
||||
try:
|
||||
result = sandbox.commit()
|
||||
committed.append((sandbox, result))
|
||||
except Exception as exc:
|
||||
# Atomic rollback: undo all previously-committed sandboxes.
|
||||
# Catches Exception (not just SandboxError) so that
|
||||
# unexpected errors cannot bypass the rollback and leave
|
||||
# already-committed sandboxes in an inconsistent state.
|
||||
failed_id = sandbox.sandbox_id
|
||||
rolled_back_ids, failed_rollback_ids = self._rollback_committed(
|
||||
committed, plan_id
|
||||
)
|
||||
|
||||
logger.error(
|
||||
"Atomic commit_all failed for plan %s: %s",
|
||||
plan_id,
|
||||
error_msg,
|
||||
)
|
||||
error_msg = f"Atomic commit failed at sandbox {failed_id}: {exc}"
|
||||
if rolled_back_ids:
|
||||
error_msg += f"; rolled back sandboxes: {rolled_back_ids}"
|
||||
if failed_rollback_ids:
|
||||
error_msg += (
|
||||
f"; FAILED to roll back sandboxes: {failed_rollback_ids}"
|
||||
)
|
||||
|
||||
# For non-SandboxError exceptions, wrap in
|
||||
# AtomicCommitError carrying rollback metadata so the
|
||||
# caller can determine which sandboxes were rolled back.
|
||||
# The original exception is chained as __cause__.
|
||||
if not isinstance(exc, SandboxError):
|
||||
raise AtomicCommitError(
|
||||
logger.error(
|
||||
"Atomic commit_all failed for plan %s: %s",
|
||||
plan_id,
|
||||
error_msg,
|
||||
rolled_back_ids=rolled_back_ids,
|
||||
failed_rollback_ids=failed_rollback_ids,
|
||||
) from exc
|
||||
|
||||
return [
|
||||
CommitResult(
|
||||
sandbox_id=failed_id,
|
||||
success=False,
|
||||
error=error_msg,
|
||||
timestamp=datetime.now(),
|
||||
metadata={
|
||||
"rolled_back": rolled_back_ids,
|
||||
"rollback_failed": failed_rollback_ids,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
# For non-SandboxError exceptions, wrap in
|
||||
# AtomicCommitError carrying rollback metadata so the
|
||||
# caller can determine which sandboxes were rolled back.
|
||||
# The original exception is chained as __cause__.
|
||||
if not isinstance(exc, SandboxError):
|
||||
raise AtomicCommitError(
|
||||
error_msg,
|
||||
rolled_back_ids=rolled_back_ids,
|
||||
failed_rollback_ids=failed_rollback_ids,
|
||||
) from exc
|
||||
|
||||
return [
|
||||
CommitResult(
|
||||
sandbox_id=failed_id,
|
||||
success=False,
|
||||
error=error_msg,
|
||||
timestamp=datetime.now(),
|
||||
metadata={
|
||||
"rolled_back": rolled_back_ids,
|
||||
"rollback_failed": failed_rollback_ids,
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
return [result for _, result in committed]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user