Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
befeba1112 | ||
|
|
f927abef82 | ||
|
|
b082b1f44e | ||
|
|
f253822411 |
+39
@@ -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'))"
|
||||
)
|
||||
@@ -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}"
|
||||
@@ -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((
|
||||
|
||||
@@ -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"),
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user