Compare commits

...
Author SHA1 Message Date
Nicolò Boschi befeba1112 fix(cli): handle processing/cancelled status variants in Rust CLI 2026-04-23 14:52:05 +02:00
Nicolò Boschi f927abef82 chore: regenerate clients and openapi spec (full sync) 2026-04-23 14:47:10 +02:00
Nicolò Boschi b082b1f44e chore: regenerate docs skill openapi reference 2026-04-23 14:28:37 +02:00
Nicolò Boschi f253822411 fix(ops): expose processing/cancelled statuses through API and UI
The API was collapsing 'processing' into 'pending' before returning
operation status to clients. Cancel was deleting the operation row
instead of preserving it with a 'cancelled' status.

- Stop mapping processing→pending in list/get operation responses
- Add 'processing' to OperationStatusResponse Literal type
- Change cancel_operation to set status='cancelled' instead of DELETE
- Guard cancel to only accept pending operations (409 otherwise)
- Extend retry to accept both failed and cancelled operations
- Add _check_op_alive support for cancelled status
- Add DB migration for 'cancelled' in status check constraint
- Add processing/cancelled badges and filters in operations UI
- Add cancel/retry buttons in operation detail dialog
- Align stats card status colors and labels with operations table
- Regenerate OpenAPI spec and all client SDKs
2026-04-23 14:20:47 +02:00
17 changed files with 452 additions and 66 deletions
@@ -0,0 +1,39 @@
"""Add 'cancelled' to async_operations status check constraint
Revision ID: i4j5k6l7m8n9
Revises: 8c6fa6f7230b
Create Date: 2026-04-23
"""
from collections.abc import Sequence
from alembic import context, op
revision: str = "i4j5k6l7m8n9"
down_revision: str | Sequence[str] | None = "8c6fa6f7230b"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _get_schema_prefix() -> str:
"""Get schema prefix for table names (required for multi-tenant support)."""
schema = context.config.get_main_option("target_schema")
return f'"{schema}".' if schema else ""
def upgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}async_operations DROP CONSTRAINT IF EXISTS async_operations_status_check")
op.execute(
f"ALTER TABLE {schema}async_operations ADD CONSTRAINT async_operations_status_check "
f"CHECK (status IN ('pending', 'processing', 'completed', 'failed', 'cancelled'))"
)
def downgrade() -> None:
schema = _get_schema_prefix()
op.execute(f"ALTER TABLE {schema}async_operations DROP CONSTRAINT IF EXISTS async_operations_status_check")
op.execute(
f"ALTER TABLE {schema}async_operations ADD CONSTRAINT async_operations_status_check "
f"CHECK (status IN ('pending', 'processing', 'completed', 'failed'))"
)
+5 -3
View File
@@ -1472,7 +1472,7 @@ class BankStatsResponse(BaseModel):
failed_operations: int
operations_by_status: dict[str, int] = Field(
default_factory=dict,
description="Async operations grouped by status (pending, in_progress, completed, failed, cancelled).",
description="Async operations grouped by status (pending, processing, completed, failed, cancelled).",
)
# Consolidation stats
last_consolidated_at: str | None = Field(default=None, description="When consolidation last ran (ISO format)")
@@ -2249,7 +2249,7 @@ class OperationStatusResponse(BaseModel):
)
operation_id: str
status: Literal["pending", "completed", "failed", "not_found"]
status: Literal["pending", "processing", "completed", "failed", "cancelled", "not_found"]
operation_type: str | None = None
created_at: str | None = None
updated_at: str | None = None
@@ -4388,7 +4388,9 @@ def _register_routes(app: FastAPI):
)
async def api_list_operations(
bank_id: str,
status: str | None = Query(default=None, description="Filter by status: pending, completed, or failed"),
status: str | None = Query(
default=None, description="Filter by status: pending, processing, completed, failed, or cancelled"
),
type: str | None = Query(
default=None,
description="Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery",
@@ -1115,10 +1115,10 @@ class MemoryEngine(MemoryEngineInterface):
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
result = await conn.fetchrow(
f"SELECT operation_id FROM {fq_table('async_operations')} WHERE operation_id = $1",
f"SELECT status FROM {fq_table('async_operations')} WHERE operation_id = $1",
uuid.UUID(operation_id),
)
if not result:
if not result or result["status"] == "cancelled":
# Operation was cancelled, skip processing
logger.info(f"Skipping cancelled operation: {operation_id}")
return
@@ -1418,19 +1418,19 @@ class MemoryEngine(MemoryEngineInterface):
logger.error(f"Failed to delete async operation record {operation_id}: {e}")
async def _check_op_alive(self, operation_id: str) -> bool:
"""Return False if the operation row no longer exists (e.g. bank was deleted via CASCADE).
"""Return False if the operation was cancelled or no longer exists (e.g. bank deleted via CASCADE).
Long-running operations should call this at natural checkpoints (e.g. after each
committed batch) to detect bank deletion early and abort cleanly.
committed batch) to detect cancellation or bank deletion early and abort cleanly.
"""
try:
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
row = await conn.fetchrow(
f"SELECT operation_id FROM {fq_table('async_operations')} WHERE operation_id = $1",
f"SELECT status FROM {fq_table('async_operations')} WHERE operation_id = $1",
uuid.UUID(operation_id),
)
return row is not None
return row is not None and row["status"] != "cancelled"
except Exception as e:
logger.error(f"Failed to check operation liveness {operation_id}: {e}")
return True # Assume alive on DB error to avoid false-positive aborts
@@ -8008,7 +8008,7 @@ class MemoryEngine(MemoryEngineInterface):
Args:
bank_id: Bank identifier
status: Optional status filter (pending, completed, failed)
status: Optional status filter (pending, processing, completed, failed, cancelled)
task_type: Optional operation type filter (retain, consolidation, etc.)
limit: Maximum number of operations to return (default 20)
offset: Number of operations to skip (default 0)
@@ -8031,12 +8031,8 @@ class MemoryEngine(MemoryEngineInterface):
params: list[Any] = [bank_id]
if status:
# Map API status to DB statuses (pending includes processing)
if status == "pending":
where_conditions.append("status IN ('pending', 'processing')")
else:
where_conditions.append(f"status = ${len(params) + 1}")
params.append(status)
where_conditions.append(f"status = ${len(params) + 1}")
params.append(status)
if task_type:
where_conditions.append(f"operation_type = ${len(params) + 1}")
@@ -8070,10 +8066,6 @@ class MemoryEngine(MemoryEngineInterface):
# Parent operations have their status updated when all children complete/fail
operation_list = []
for row in operations:
# Map DB status to API status (pending includes processing)
db_status = row["status"]
api_status = "pending" if db_status in ("pending", "processing") else db_status
result_metadata = json.loads(row["result_metadata"]) if row["result_metadata"] else {}
next_retry_at = row["next_retry_at"]
@@ -8084,7 +8076,7 @@ class MemoryEngine(MemoryEngineInterface):
"items_count": result_metadata.get("items_count", 0),
"document_id": None,
"created_at": row["created_at"].isoformat(),
"status": api_status,
"status": row["status"],
"error_message": row["error_message"],
"retry_count": row["retry_count"] or 0,
"next_retry_at": next_retry_at.isoformat() if next_retry_at else None,
@@ -8142,9 +8134,8 @@ class MemoryEngine(MemoryEngineInterface):
is_parent = result_metadata.get("is_parent", False)
task_payload = json.loads(row["task_payload"]) if include_payload and row["task_payload"] else None
# Use status from database (parent status is updated when all children complete/fail)
db_status = row["status"]
api_status = "pending" if db_status in ("pending", "processing") else db_status
# Status may be corrected by self-healing logic below for parent operations
api_status = row["status"]
# For parent operations, include child operations list
if is_parent:
@@ -8267,9 +8258,9 @@ class MemoryEngine(MemoryEngineInterface):
op_uuid = uuid.UUID(operation_id)
async with acquire_with_retry(pool) as conn:
# Check if operation exists and belongs to this memory bank
# Check if operation exists, belongs to this bank, and is in a cancellable state
result = await conn.fetchrow(
f"SELECT bank_id FROM {fq_table('async_operations')} WHERE operation_id = $1 AND bank_id = $2",
f"SELECT bank_id, status FROM {fq_table('async_operations')} WHERE operation_id = $1 AND bank_id = $2",
op_uuid,
bank_id,
)
@@ -8277,8 +8268,19 @@ class MemoryEngine(MemoryEngineInterface):
if not result:
raise ValueError(f"Operation {operation_id} not found for bank {bank_id}")
# Delete the operation
await conn.execute(f"DELETE FROM {fq_table('async_operations')} WHERE operation_id = $1", op_uuid)
if result["status"] != "pending":
from hindsight_api.extensions import OperationValidationError
raise OperationValidationError(
f"Operation {operation_id} cannot be cancelled: status is '{result['status']}', only 'pending' operations can be cancelled",
409,
)
# Mark the operation as cancelled
await conn.execute(
f"UPDATE {fq_table('async_operations')} SET status = 'cancelled', updated_at = now() WHERE operation_id = $1",
op_uuid,
)
return {
"success": True,
@@ -8317,9 +8319,9 @@ class MemoryEngine(MemoryEngineInterface):
if not row:
raise ValueError(f"Operation {operation_id} not found for bank {bank_id}")
if row["status"] != "failed":
if row["status"] not in ("failed", "cancelled"):
raise OperationValidationError(
f"Operation {operation_id} cannot be retried: status is '{row['status']}', expected 'failed'",
f"Operation {operation_id} cannot be retried: status is '{row['status']}', expected 'failed' or 'cancelled'",
409,
)
@@ -0,0 +1,245 @@
"""
Tests that async operation statuses (pending, processing, completed, failed, cancelled)
are correctly exposed through list and get API endpoints.
Regression tests:
- Previously the API collapsed 'processing' into 'pending', hiding the real status.
- Cancel used to delete the operation row; now it sets status to 'cancelled'.
- Retry now accepts both 'failed' and 'cancelled' operations.
"""
import uuid
from datetime import datetime
import httpx
import pytest
import pytest_asyncio
from hindsight_api.api import create_app
@pytest_asyncio.fixture
async def api_client(memory):
app = create_app(memory, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@pytest.fixture
def test_bank_id():
return f"op_status_test_{datetime.now().timestamp()}"
async def _ensure_bank(pool, bank_id: str) -> None:
"""Create a bank row if it doesn't already exist."""
await pool.execute(
"""
INSERT INTO banks (bank_id) VALUES ($1)
ON CONFLICT (bank_id) DO NOTHING
""",
bank_id,
)
async def _insert_operation(pool, bank_id: str, status: str) -> str:
"""Insert a test operation with the given status and return its ID."""
op_id = uuid.uuid4()
await pool.execute(
"""
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
VALUES ($1, $2, 'retain', $3, '{"test": true}'::jsonb)
""",
op_id,
bank_id,
status,
)
return str(op_id)
@pytest.mark.asyncio
async def test_list_operations_returns_processing_status(api_client, memory, test_bank_id):
"""GET /operations should return 'processing' status, not collapse it to 'pending'."""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)
pending_id = await _insert_operation(pool, test_bank_id, "pending")
processing_id = await _insert_operation(pool, test_bank_id, "processing")
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations")
assert response.status_code == 200
ops = response.json()["operations"]
statuses_by_id = {op["id"]: op["status"] for op in ops}
assert statuses_by_id[pending_id] == "pending"
assert statuses_by_id[processing_id] == "processing"
@pytest.mark.asyncio
async def test_list_operations_filter_by_processing(api_client, memory, test_bank_id):
"""Filtering by status=processing should only return processing operations."""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)
await _insert_operation(pool, test_bank_id, "pending")
processing_id = await _insert_operation(pool, test_bank_id, "processing")
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/operations",
params={"status": "processing"},
)
assert response.status_code == 200
ops = response.json()["operations"]
assert len(ops) == 1
assert ops[0]["id"] == processing_id
assert ops[0]["status"] == "processing"
@pytest.mark.asyncio
async def test_list_operations_filter_by_pending_excludes_processing(api_client, memory, test_bank_id):
"""Filtering by status=pending should NOT include processing operations."""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)
pending_id = await _insert_operation(pool, test_bank_id, "pending")
await _insert_operation(pool, test_bank_id, "processing")
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/operations",
params={"status": "pending"},
)
assert response.status_code == 200
ops = response.json()["operations"]
assert len(ops) == 1
assert ops[0]["id"] == pending_id
assert ops[0]["status"] == "pending"
@pytest.mark.asyncio
async def test_get_operation_returns_processing_status(api_client, memory, test_bank_id):
"""GET /operations/{id} should return 'processing' status."""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)
processing_id = await _insert_operation(pool, test_bank_id, "processing")
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/operations/{processing_id}"
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "processing"
assert data["operation_id"] == processing_id
@pytest.mark.asyncio
async def test_all_statuses_returned_correctly(api_client, memory, test_bank_id):
"""All four DB statuses should be returned as-is through both list and get endpoints."""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)
ids = {}
for status in ("pending", "processing", "completed", "failed", "cancelled"):
ids[status] = await _insert_operation(pool, test_bank_id, status)
# Verify list endpoint
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations")
assert response.status_code == 200
ops = response.json()["operations"]
statuses_by_id = {op["id"]: op["status"] for op in ops}
for status, op_id in ids.items():
assert statuses_by_id[op_id] == status, f"List: expected {status} for {op_id}, got {statuses_by_id[op_id]}"
# Verify get endpoint for each
for status, op_id in ids.items():
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/operations/{op_id}"
)
assert response.status_code == 200
assert response.json()["status"] == status, f"Get: expected {status} for {op_id}"
@pytest.mark.asyncio
async def test_cancel_sets_cancelled_status(api_client, memory, test_bank_id):
"""DELETE /operations/{id} should set status to 'cancelled', not delete the row."""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)
op_id = await _insert_operation(pool, test_bank_id, "pending")
# Cancel the operation
response = await api_client.delete(
f"/v1/default/banks/{test_bank_id}/operations/{op_id}"
)
assert response.status_code == 200
assert response.json()["success"] is True
# Verify the operation still exists with 'cancelled' status
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/operations/{op_id}"
)
assert response.status_code == 200
assert response.json()["status"] == "cancelled"
# Verify it shows up in list with cancelled filter
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/operations",
params={"status": "cancelled"},
)
assert response.status_code == 200
ops = response.json()["operations"]
assert len(ops) == 1
assert ops[0]["id"] == op_id
@pytest.mark.asyncio
async def test_retry_cancelled_operation(api_client, memory, test_bank_id):
"""POST /operations/{id}/retry should accept cancelled operations."""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)
op_id = await _insert_operation(pool, test_bank_id, "cancelled")
# Retry the cancelled operation
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/operations/{op_id}/retry"
)
assert response.status_code == 200
assert response.json()["success"] is True
# Verify the operation is now pending
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/operations/{op_id}"
)
assert response.status_code == 200
assert response.json()["status"] == "pending"
@pytest.mark.asyncio
async def test_retry_rejects_non_retriable_statuses(api_client, memory, test_bank_id):
"""POST /operations/{id}/retry should reject pending, processing, and completed operations."""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)
for status in ("pending", "processing", "completed"):
op_id = await _insert_operation(pool, test_bank_id, status)
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/operations/{op_id}/retry"
)
assert response.status_code == 409, f"Expected 409 for {status}, got {response.status_code}"
@pytest.mark.asyncio
async def test_cancel_rejects_non_pending_operations(api_client, memory, test_bank_id):
"""DELETE /operations/{id} should only cancel pending operations."""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)
for status in ("processing", "completed", "failed"):
op_id = await _insert_operation(pool, test_bank_id, status)
response = await api_client.delete(
f"/v1/default/banks/{test_bank_id}/operations/{op_id}"
)
assert response.status_code == 409, f"Expected 409 for {status}, got {response.status_code}"
+4 -1
View File
@@ -351,7 +351,7 @@ impl ApiClient {
eprintln!("Operation {} status: {}", operation_id, operation.status);
}
match operation.status.as_str() {
"pending" => {
"pending" | "processing" => {
// Still running, wait and poll again
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
@@ -362,6 +362,9 @@ impl ApiClient {
"failed" => {
return Ok((false, operation.error_message.clone()));
}
"cancelled" => {
return Ok((false, Some("Operation was cancelled".to_string())));
}
_ => {
// Unknown status, treat as failed
return Ok((
+2
View File
@@ -79,7 +79,9 @@ pub fn get(
let status_str = match &result.status {
Status::Completed => ui::gradient_start("completed"),
Status::Pending => ui::gradient_mid("pending"),
Status::Processing => ui::gradient_mid("processing"),
Status::Failed => ui::gradient_end("failed"),
Status::Cancelled => ui::dim("cancelled"),
Status::NotFound => ui::gradient_end("not_found"),
};
+6 -3
View File
@@ -1761,7 +1761,8 @@ paths:
title: Bank Id
type: string
style: simple
- description: "Filter by status: pending, completed, or failed"
- description: "Filter by status: pending, processing, completed, failed, or\
\ cancelled"
explode: true
in: query
name: status
@@ -3632,8 +3633,8 @@ components:
operations_by_status:
additionalProperties:
type: integer
description: "Async operations grouped by status (pending, in_progress,\
\ completed, failed, cancelled)."
description: "Async operations grouped by status (pending, processing, completed,\
\ failed, cancelled)."
title: Operations By Status
last_consolidated_at:
nullable: true
@@ -5497,8 +5498,10 @@ components:
status:
enum:
- pending
- processing
- completed
- failed
- cancelled
- not_found
title: Status
type: string
+1 -1
View File
@@ -299,7 +299,7 @@ type ApiListOperationsRequest struct {
authorization *string
}
// Filter by status: pending, completed, or failed
// Filter by status: pending, processing, completed, failed, or cancelled
func (r ApiListOperationsRequest) Status(status string) ApiListOperationsRequest {
r.status = &status
return r
@@ -31,7 +31,7 @@ type BankStatsResponse struct {
LinksBreakdown map[string]map[string]int32 `json:"links_breakdown"`
PendingOperations int32 `json:"pending_operations"`
FailedOperations int32 `json:"failed_operations"`
// Async operations grouped by status (pending, in_progress, completed, failed, cancelled).
// Async operations grouped by status (pending, processing, completed, failed, cancelled).
OperationsByStatus map[string]int32 `json:"operations_by_status,omitempty"`
LastConsolidatedAt NullableString `json:"last_consolidated_at,omitempty"`
// Number of memories not yet processed into observations
@@ -649,7 +649,7 @@ class OperationsApi:
async def list_operations(
self,
bank_id: StrictStr,
status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, completed, or failed")] = None,
status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, processing, completed, failed, or cancelled")] = None,
type: Annotated[Optional[StrictStr], Field(description="Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery")] = None,
limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of operations to return")] = None,
offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of operations to skip")] = None,
@@ -673,7 +673,7 @@ class OperationsApi:
:param bank_id: (required)
:type bank_id: str
:param status: Filter by status: pending, completed, or failed
:param status: Filter by status: pending, processing, completed, failed, or cancelled
:type status: str
:param type: Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery
:type type: str
@@ -737,7 +737,7 @@ class OperationsApi:
async def list_operations_with_http_info(
self,
bank_id: StrictStr,
status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, completed, or failed")] = None,
status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, processing, completed, failed, or cancelled")] = None,
type: Annotated[Optional[StrictStr], Field(description="Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery")] = None,
limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of operations to return")] = None,
offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of operations to skip")] = None,
@@ -761,7 +761,7 @@ class OperationsApi:
:param bank_id: (required)
:type bank_id: str
:param status: Filter by status: pending, completed, or failed
:param status: Filter by status: pending, processing, completed, failed, or cancelled
:type status: str
:param type: Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery
:type type: str
@@ -825,7 +825,7 @@ class OperationsApi:
async def list_operations_without_preload_content(
self,
bank_id: StrictStr,
status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, completed, or failed")] = None,
status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, processing, completed, failed, or cancelled")] = None,
type: Annotated[Optional[StrictStr], Field(description="Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery")] = None,
limit: Annotated[Optional[Annotated[int, Field(le=100, strict=True, ge=1)]], Field(description="Maximum number of operations to return")] = None,
offset: Annotated[Optional[Annotated[int, Field(strict=True, ge=0)]], Field(description="Number of operations to skip")] = None,
@@ -849,7 +849,7 @@ class OperationsApi:
:param bank_id: (required)
:type bank_id: str
:param status: Filter by status: pending, completed, or failed
:param status: Filter by status: pending, processing, completed, failed, or cancelled
:type status: str
:param type: Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery
:type type: str
@@ -36,7 +36,7 @@ class BankStatsResponse(BaseModel):
links_breakdown: Dict[str, Dict[str, StrictInt]]
pending_operations: StrictInt
failed_operations: StrictInt
operations_by_status: Optional[Dict[str, StrictInt]] = Field(default=None, description="Async operations grouped by status (pending, in_progress, completed, failed, cancelled).")
operations_by_status: Optional[Dict[str, StrictInt]] = Field(default=None, description="Async operations grouped by status (pending, processing, completed, failed, cancelled).")
last_consolidated_at: Optional[StrictStr] = None
pending_consolidation: Optional[StrictInt] = Field(default=0, description="Number of memories not yet processed into observations")
failed_consolidation: Optional[StrictInt] = Field(default=0, description="Number of source memories (world/experience) whose consolidation permanently failed and can be retried via the consolidation recovery endpoint.")
@@ -44,8 +44,8 @@ class OperationStatusResponse(BaseModel):
@field_validator('status')
def status_validate_enum(cls, value):
"""Validates the enum"""
if value not in set(['pending', 'completed', 'failed', 'not_found']):
raise ValueError("must be one of enum values ('pending', 'completed', 'failed', 'not_found')")
if value not in set(['pending', 'processing', 'completed', 'failed', 'cancelled', 'not_found']):
raise ValueError("must be one of enum values ('pending', 'processing', 'completed', 'failed', 'cancelled', 'not_found')")
return value
model_config = ConfigDict(
@@ -368,7 +368,7 @@ export type BankStatsResponse = {
/**
* Operations By Status
*
* Async operations grouped by status (pending, in_progress, completed, failed, cancelled).
* Async operations grouped by status (pending, processing, completed, failed, cancelled).
*/
operations_by_status?: {
[key: string]: number;
@@ -2163,7 +2163,13 @@ export type OperationStatusResponse = {
/**
* Status
*/
status: "pending" | "completed" | "failed" | "not_found";
status:
| "pending"
| "processing"
| "completed"
| "failed"
| "cancelled"
| "not_found";
/**
* Operation Type
*/
@@ -4812,7 +4818,7 @@ export type ListOperationsData = {
/**
* Status
*
* Filter by status: pending, completed, or failed
* Filter by status: pending, processing, completed, failed, or cancelled
*/
status?: string | null;
/**
@@ -35,6 +35,7 @@ import {
X,
RotateCcw,
Code,
Ban,
} from "lucide-react";
interface Operation {
@@ -282,8 +283,10 @@ export function BankOperationsView() {
{[
{ value: null, label: "All" },
{ value: "pending", label: "Pending" },
{ value: "processing", label: "Processing" },
{ value: "completed", label: "Completed" },
{ value: "failed", label: "Failed" },
{ value: "cancelled", label: "Cancelled" },
].map((filter) => (
<button
key={filter.value ?? "all"}
@@ -335,6 +338,12 @@ export function BankOperationsView() {
pending
</span>
)}
{op.status === "processing" && (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
<Loader2 className="w-3 h-3 animate-spin" />
processing
</span>
)}
{op.status === "failed" && (
<span
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20"
@@ -350,6 +359,12 @@ export function BankOperationsView() {
completed
</span>
)}
{op.status === "cancelled" && (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-500/10 text-gray-600 dark:text-gray-400 border border-gray-500/20">
<Ban className="w-3 h-3" />
cancelled
</span>
)}
</TableCell>
<TableCell>
{op.status === "pending" && (
@@ -371,7 +386,7 @@ export function BankOperationsView() {
{cancellingOpId === op.id ? "" : "Cancel"}
</Button>
)}
{op.status === "failed" && (
{(op.status === "failed" || op.status === "cancelled") && (
<Button
variant="ghost"
size="sm"
@@ -463,6 +478,12 @@ export function BankOperationsView() {
pending
</span>
)}
{selectedOperation.status === "processing" && (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
<Loader2 className="w-3 h-3 animate-spin" />
processing
</span>
)}
{selectedOperation.status === "failed" && (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/10 text-red-600 dark:text-red-400 border border-red-500/20">
<AlertCircle className="w-3 h-3" />
@@ -475,6 +496,12 @@ export function BankOperationsView() {
completed
</span>
)}
{selectedOperation.status === "cancelled" && (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-500/10 text-gray-600 dark:text-gray-400 border border-gray-500/20">
<Ban className="w-3 h-3" />
cancelled
</span>
)}
</div>
</div>
<div>
@@ -517,6 +544,47 @@ export function BankOperationsView() {
)}
</div>
{/* Action buttons */}
{(selectedOperation.status === "pending" ||
selectedOperation.status === "failed" ||
selectedOperation.status === "cancelled") && (
<div className="flex gap-2">
{selectedOperation.status === "pending" && (
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => handleCancelOperation(selectedOperation.operation_id)}
disabled={cancellingOpId === selectedOperation.operation_id}
>
{cancellingOpId === selectedOperation.operation_id ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<X className="w-3 h-3 mr-1" />
)}
Cancel
</Button>
)}
{(selectedOperation.status === "failed" ||
selectedOperation.status === "cancelled") && (
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => handleRetryOperation(selectedOperation.operation_id)}
disabled={retryingOpId === selectedOperation.operation_id}
>
{retryingOpId === selectedOperation.operation_id ? (
<Loader2 className="w-3 h-3 animate-spin mr-1" />
) : (
<RotateCcw className="w-3 h-3 mr-1" />
)}
Retry
</Button>
)}
</div>
)}
{/* Metadata */}
{selectedOperation.result_metadata &&
Object.keys(selectedOperation.result_metadata).length > 0 && (
@@ -576,6 +644,12 @@ export function BankOperationsView() {
pending
</span>
)}
{child.status === "processing" && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-600 dark:text-blue-400">
<Loader2 className="w-3 h-3 animate-spin" />
processing
</span>
)}
{child.status === "failed" && (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-500/10 text-red-600 dark:text-red-400"
@@ -591,6 +665,12 @@ export function BankOperationsView() {
completed
</span>
)}
{child.status === "cancelled" && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-gray-500/10 text-gray-600 dark:text-gray-400">
<Ban className="w-3 h-3" />
cancelled
</span>
)}
</TableCell>
</TableRow>
))}
@@ -365,20 +365,20 @@ function InlineStat({
);
}
const OPS_STATUS_ORDER = ["completed", "in_progress", "pending", "failed", "cancelled"] as const;
const OPS_STATUS_ORDER = ["completed", "processing", "pending", "failed", "cancelled"] as const;
const OPS_STATUS_COLORS: Record<string, string> = {
completed: CHART_COLORS.success,
in_progress: CHART_COLORS.semantic,
pending: CHART_COLORS.warning,
failed: CHART_COLORS.danger,
cancelled: CHART_COLORS.mutedFg,
completed: "#10b981", // emerald-500
processing: "#3b82f6", // blue-500
pending: "#f59e0b", // amber-500
failed: "#ef4444", // red-500
cancelled: "#6b7280", // gray-500
};
const OPS_STATUS_LABELS: Record<string, string> = {
completed: "Completed",
in_progress: "In progress",
pending: "Pending",
failed: "Failed",
cancelled: "Cancelled",
completed: "completed",
processing: "processing",
pending: "pending",
failed: "failed",
cancelled: "cancelled",
};
interface OpsStatusEntry {
+5 -3
View File
@@ -2584,10 +2584,10 @@
"type": "null"
}
],
"description": "Filter by status: pending, completed, or failed",
"description": "Filter by status: pending, processing, completed, failed, or cancelled",
"title": "Status"
},
"description": "Filter by status: pending, completed, or failed"
"description": "Filter by status: pending, processing, completed, failed, or cancelled"
},
{
"name": "type",
@@ -5255,7 +5255,7 @@
},
"type": "object",
"title": "Operations By Status",
"description": "Async operations grouped by status (pending, in_progress, completed, failed, cancelled)."
"description": "Async operations grouped by status (pending, processing, completed, failed, cancelled)."
},
"last_consolidated_at": {
"anyOf": [
@@ -8385,8 +8385,10 @@
"type": "string",
"enum": [
"pending",
"processing",
"completed",
"failed",
"cancelled",
"not_found"
],
"title": "Status"
@@ -2584,10 +2584,10 @@
"type": "null"
}
],
"description": "Filter by status: pending, completed, or failed",
"description": "Filter by status: pending, processing, completed, failed, or cancelled",
"title": "Status"
},
"description": "Filter by status: pending, completed, or failed"
"description": "Filter by status: pending, processing, completed, failed, or cancelled"
},
{
"name": "type",
@@ -5255,7 +5255,7 @@
},
"type": "object",
"title": "Operations By Status",
"description": "Async operations grouped by status (pending, in_progress, completed, failed, cancelled)."
"description": "Async operations grouped by status (pending, processing, completed, failed, cancelled)."
},
"last_consolidated_at": {
"anyOf": [
@@ -8385,8 +8385,10 @@
"type": "string",
"enum": [
"pending",
"processing",
"completed",
"failed",
"cancelled",
"not_found"
],
"title": "Status"