Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions hindsight-api-slim/hindsight_api/api/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -2730,6 +2730,24 @@ class RetryOperationResponse(BaseModel):
operation_id: str


class DeleteOperationResponse(BaseModel):
"""Response model for delete operation endpoint."""

model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"message": "Operation 550e8400-e29b-41d4-a716-446655440000 deleted",
"operation_id": "550e8400-e29b-41d4-a716-446655440000",
}
}
)

success: bool
message: str
operation_id: str


class ChildOperationStatus(BaseModel):
"""Status of a child operation (for batch operations)."""

Expand Down Expand Up @@ -5531,6 +5549,42 @@ async def api_retry_operation(
logger.error(f"Error in POST /v1/default/banks/{bank_id}/operations/{operation_id}/retry: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))

@app.delete(
"/v1/default/banks/{bank_id}/operations/{operation_id}/record",
response_model=DeleteOperationResponse,
summary="Delete a terminal async operation",
description="Permanently remove a failed, cancelled, or completed async operation record",
operation_id="delete_operation",
tags=["Operations"],
)
@audited("delete_operation", request_param=None)
async def api_delete_operation(
bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context)
):
"""Delete a terminal async operation record."""
try:
try:
uuid.UUID(operation_id)
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}")

result = await app.state.memory.delete_operation(bank_id, operation_id, request_context=request_context)
return DeleteOperationResponse(**result)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
except OperationValidationError as e:
raise HTTPException(status_code=e.status_code, detail=e.reason)
except (AuthenticationError, HTTPException):
raise
except Exception as e:
import traceback

error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
logger.error(
f"Error in DELETE /v1/default/banks/{bank_id}/operations/{operation_id}/record: {error_detail}"
)
raise HTTPException(status_code=500, detail=str(e))

@app.get(
"/v1/default/banks/{bank_id}/profile",
response_model=BankProfileResponse,
Expand Down
24 changes: 24 additions & 0 deletions hindsight-api-slim/hindsight_api/engine/interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,30 @@ async def cancel_operation(
"""
...

@abstractmethod
async def delete_operation(
self,
bank_id: str,
operation_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""
Delete a terminal async operation record.

Args:
bank_id: The memory bank ID.
operation_id: The operation ID to delete.
request_context: Request context for authentication.

Returns:
Dict with success status and message.

Raises:
ValueError: If operation not found.
"""
...

@abstractmethod
async def update_bank(
self,
Expand Down
57 changes: 57 additions & 0 deletions hindsight-api-slim/hindsight_api/engine/memory_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -12029,6 +12029,63 @@ async def retry_operation(
"operation_id": operation_id,
}

async def delete_operation(
self,
bank_id: str,
operation_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""Delete a terminal (failed/cancelled/completed) async operation row."""
await self._authenticate_tenant(request_context)
from hindsight_api.extensions import OperationValidationError

if self._operation_validator:
from hindsight_api.extensions import BankWriteContext, BankWriteOperation

ctx = BankWriteContext(
bank_id=bank_id, operation=BankWriteOperation.DELETE_OPERATION, request_context=request_context
)
await self._validate_operation(self._operation_validator.validate_bank_write(ctx))
backend = await self._get_backend()

op_uuid = uuid.UUID(operation_id)

async with acquire_with_retry(backend) as conn:
# Single status-guarded DELETE avoids TOCTOU with concurrent retry_operation.
# Known edge: deleting a terminal child of a still-running batch removes it from
# the parent's roll-up (parent aggregates surviving siblings only), so a parent
# can finalize as completed even though a failed child was deleted mid-batch.
# Parent linkage lives in JSON result_metadata (no FK), so this is documented
# rather than guarded; block child deletion here if that trade-off changes.
deleted = await conn.fetchval(
f"""DELETE FROM {fq_table("async_operations")}
WHERE operation_id = $1 AND bank_id = $2
AND status IN ('failed', 'cancelled', 'completed')
RETURNING operation_id""",
op_uuid,
bank_id,
)
if deleted:
return {
"success": True,
"message": f"Operation {operation_id} deleted",
"operation_id": operation_id,
}

row = await conn.fetchrow(
f"SELECT status FROM {fq_table('async_operations')} WHERE operation_id = $1 AND bank_id = $2",
op_uuid,
bank_id,
)
if not row:
raise ValueError(f"Operation {operation_id} not found for bank {bank_id}")
raise OperationValidationError(
f"Operation {operation_id} cannot be deleted: status is '{row['status']}', "
f"expected 'failed', 'cancelled' or 'completed'",
409,
)

async def update_bank(
self,
bank_id: str,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,7 @@ class BankWriteOperation(StrEnum):
DELETE_DIRECTIVE = "delete_directive"
DELETE_DOCUMENT = "delete_document"
DELETE_MENTAL_MODEL = "delete_mental_model"
DELETE_OPERATION = "delete_operation"
DELETE_WEBHOOK = "delete_webhook"
MERGE_BANK_MISSION = "merge_bank_mission"
REPROCESS_DOCUMENT = "reprocess_document"
Expand Down
56 changes: 56 additions & 0 deletions hindsight-api-slim/tests/test_operation_status.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,3 +281,59 @@ async def test_cancel_rejects_non_pending_operations(api_client, memory, test_ba
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}"


@pytest.mark.asyncio
async def test_delete_removes_terminal_operation(api_client, memory, test_bank_id):
"""DELETE /operations/{id}/record should remove a failed operation's row entirely."""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)

op_id = await _insert_operation(pool, test_bank_id, "failed")

response = await api_client.delete(f"/v1/default/banks/{test_bank_id}/operations/{op_id}/record")
assert response.status_code == 200
assert response.json()["success"] is True

# get_operation_status returns a not_found dict (not HTTP 404)
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations/{op_id}")
assert response.status_code == 200
assert response.json()["status"] == "not_found"

response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/operations",
params={"status": "failed"},
)
assert response.status_code == 200
assert response.json()["operations"] == []


@pytest.mark.asyncio
async def test_delete_rejects_non_terminal_statuses(api_client, memory, test_bank_id):
"""DELETE /operations/{id}/record should reject pending and processing operations."""
pool = memory._pool
await _ensure_bank(pool, test_bank_id)

for status in ("pending", "processing"):
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}/record")
assert response.status_code == 409, f"Expected 409 for {status}, got {response.status_code}"


@pytest.mark.asyncio
async def test_delete_wrong_bank_returns_404(api_client, memory, test_bank_id):
"""DELETE under a different valid bank must 404 and leave the row intact."""
pool = memory._pool
bank_a = test_bank_id
bank_b = f"{test_bank_id}_other"
await _ensure_bank(pool, bank_a)
await _ensure_bank(pool, bank_b)

op_id = await _insert_operation(pool, bank_a, "failed")

response = await api_client.delete(f"/v1/default/banks/{bank_b}/operations/{op_id}/record")
assert response.status_code == 404

response = await api_client.get(f"/v1/default/banks/{bank_a}/operations/{op_id}")
assert response.status_code == 200
assert response.json()["status"] == "failed"
67 changes: 67 additions & 0 deletions hindsight-clients/go/api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2389,6 +2389,52 @@ paths:
summary: Retry a failed async operation
tags:
- Operations
/v1/default/banks/{bank_id}/operations/{operation_id}/record:
delete:
description: "Permanently remove a failed, cancelled, or completed async operation\
\ record"
operationId: delete_operation
parameters:
- explode: false
in: path
name: bank_id
required: true
schema:
title: Bank Id
type: string
style: simple
- explode: false
in: path
name: operation_id
required: true
schema:
title: Operation Id
type: string
style: simple
- explode: false
in: header
name: authorization
required: false
schema:
nullable: true
type: string
style: simple
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/DeleteOperationResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Delete a terminal async operation
tags:
- Operations
/v1/default/banks/{bank_id}/profile:
get:
deprecated: true
Expand Down Expand Up @@ -5222,6 +5268,27 @@ components:
- message
- success
title: DeleteDocumentResponse
DeleteOperationResponse:
description: Response model for delete operation endpoint.
example:
message: Operation 550e8400-e29b-41d4-a716-446655440000 deleted
operation_id: 550e8400-e29b-41d4-a716-446655440000
success: true
properties:
success:
title: Success
type: boolean
message:
title: Message
type: string
operation_id:
title: Operation Id
type: string
required:
- message
- operation_id
- success
title: DeleteOperationResponse
DeleteResponse:
description: Response model for delete operations.
example:
Expand Down
Loading