feat: add manual retry for failed async operations (#537)

- API: POST /v1/default/banks/{bank_id}/operations/{operation_id}/retry
  resets status to pending so the worker re-executes the task
- UI: Retry button on failed operations in the operations view
- Control plane proxy route + ControlPlaneClient.retryOperation()
- Updated OpenAPI spec, all generated clients, and operations docs
This commit is contained in:
Nicolò Boschi 2026-03-10 17:16:11 +01:00 committed by GitHub
parent 32a4882a10
commit dcaacbe407
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1195 additions and 2 deletions

View file

@ -1558,6 +1558,24 @@ class CancelOperationResponse(BaseModel):
operation_id: str
class RetryOperationResponse(BaseModel):
"""Response model for retry operation endpoint."""
model_config = ConfigDict(
json_schema_extra={
"example": {
"success": True,
"message": "Operation 550e8400-e29b-41d4-a716-446655440000 queued for retry",
"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)."""
@ -3532,6 +3550,39 @@ def _register_routes(app: FastAPI):
logger.error(f"Error in /v1/default/banks/{bank_id}/operations/{operation_id}: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.post(
"/v1/default/banks/{bank_id}/operations/{operation_id}/retry",
response_model=RetryOperationResponse,
summary="Retry a failed async operation",
description="Re-queue a failed async operation so the worker picks it up again",
operation_id="retry_operation",
tags=["Operations"],
)
async def api_retry_operation(
bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context)
):
"""Retry a failed async operation."""
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.retry_operation(bank_id, operation_id, request_context=request_context)
return RetryOperationResponse(**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 POST /v1/default/banks/{bank_id}/operations/{operation_id}/retry: {error_detail}")
raise HTTPException(status_code=500, detail=str(e))
@app.get(
"/v1/default/banks/{bank_id}/profile",
response_model=BankProfileResponse,

View file

@ -7118,6 +7118,64 @@ class MemoryEngine(MemoryEngineInterface):
"bank_id": bank_id,
}
async def retry_operation(
self,
bank_id: str,
operation_id: str,
*,
request_context: "RequestContext",
) -> dict[str, Any]:
"""Re-queue a failed async operation."""
await self._authenticate_tenant(request_context)
from hindsight_api.extensions import OperationValidationError
if self._operation_validator:
from hindsight_api.extensions import BankWriteContext
ctx = BankWriteContext(bank_id=bank_id, operation="retry_operation", request_context=request_context)
await self._validate_operation(self._operation_validator.validate_bank_write(ctx))
pool = await self._get_pool()
op_uuid = uuid.UUID(operation_id)
async with acquire_with_retry(pool) as conn:
row = await conn.fetchrow(
f"SELECT bank_id, 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}")
if row["status"] != "failed":
raise OperationValidationError(
f"Operation {operation_id} cannot be retried: status is '{row['status']}', expected 'failed'",
409,
)
await conn.execute(
f"""
UPDATE {fq_table("async_operations")}
SET status = 'pending',
error_message = NULL,
completed_at = NULL,
next_retry_at = NULL,
worker_id = NULL,
claimed_at = NULL,
retry_count = 0,
updated_at = NOW()
WHERE operation_id = $1
""",
op_uuid,
)
return {
"success": True,
"message": f"Operation {operation_id} queued for retry",
"operation_id": operation_id,
}
async def update_bank(
self,
bank_id: str,

View file

@ -1771,6 +1771,51 @@ paths:
summary: Get operation status
tags:
- Operations
/v1/default/banks/{bank_id}/operations/{operation_id}/retry:
post:
description: Re-queue a failed async operation so the worker picks it up again
operationId: retry_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/RetryOperationResponse'
description: Successful Response
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
description: Validation Error
summary: Retry a failed async operation
tags:
- Operations
/v1/default/banks/{bank_id}/profile:
get:
deprecated: true
@ -4748,6 +4793,27 @@ components:
- items_count
- success
title: RetainResponse
RetryOperationResponse:
description: Response model for retry operation endpoint.
example:
message: Operation 550e8400-e29b-41d4-a716-446655440000 queued for retry
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: RetryOperationResponse
SourceFactsIncludeOptions:
description: Options for including source facts for observation-type results.
properties:

View file

@ -442,3 +442,129 @@ func (a *OperationsAPIService) ListOperationsExecute(r ApiListOperationsRequest)
return localVarReturnValue, localVarHTTPResponse, nil
}
type ApiRetryOperationRequest struct {
ctx context.Context
ApiService *OperationsAPIService
bankId string
operationId string
authorization *string
}
func (r ApiRetryOperationRequest) Authorization(authorization string) ApiRetryOperationRequest {
r.authorization = &authorization
return r
}
func (r ApiRetryOperationRequest) Execute() (*RetryOperationResponse, *http.Response, error) {
return r.ApiService.RetryOperationExecute(r)
}
/*
RetryOperation Retry a failed async operation
Re-queue a failed async operation so the worker picks it up again
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@param operationId
@return ApiRetryOperationRequest
*/
func (a *OperationsAPIService) RetryOperation(ctx context.Context, bankId string, operationId string) ApiRetryOperationRequest {
return ApiRetryOperationRequest{
ApiService: a,
ctx: ctx,
bankId: bankId,
operationId: operationId,
}
}
// Execute executes the request
// @return RetryOperationResponse
func (a *OperationsAPIService) RetryOperationExecute(r ApiRetryOperationRequest) (*RetryOperationResponse, *http.Response, error) {
var (
localVarHTTPMethod = http.MethodPost
localVarPostBody interface{}
formFiles []formFile
localVarReturnValue *RetryOperationResponse
)
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "OperationsAPIService.RetryOperation")
if err != nil {
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
}
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/operations/{operation_id}/retry"
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
localVarPath = strings.Replace(localVarPath, "{"+"operation_id"+"}", url.PathEscape(parameterValueToString(r.operationId, "operationId")), -1)
localVarHeaderParams := make(map[string]string)
localVarQueryParams := url.Values{}
localVarFormParams := url.Values{}
// to determine the Content-Type header
localVarHTTPContentTypes := []string{}
// set Content-Type header
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
if localVarHTTPContentType != "" {
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
}
// to determine the Accept header
localVarHTTPHeaderAccepts := []string{"application/json"}
// set Accept header
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
if localVarHTTPHeaderAccept != "" {
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
}
if r.authorization != nil {
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
}
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
if err != nil {
return localVarReturnValue, nil, err
}
localVarHTTPResponse, err := a.client.callAPI(req)
if err != nil || localVarHTTPResponse == nil {
return localVarReturnValue, localVarHTTPResponse, err
}
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
localVarHTTPResponse.Body.Close()
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
if err != nil {
return localVarReturnValue, localVarHTTPResponse, err
}
if localVarHTTPResponse.StatusCode >= 300 {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: localVarHTTPResponse.Status,
}
if localVarHTTPResponse.StatusCode == 422 {
var v HTTPValidationError
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr.error = err.Error()
return localVarReturnValue, localVarHTTPResponse, newErr
}
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
newErr.model = v
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
if err != nil {
newErr := &GenericOpenAPIError{
body: localVarBody,
error: err.Error(),
}
return localVarReturnValue, localVarHTTPResponse, newErr
}
return localVarReturnValue, localVarHTTPResponse, nil
}

View file

@ -0,0 +1,214 @@
/*
Hindsight HTTP API
HTTP API for Hindsight
API version: 0.4.16
*/
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
package hindsight
import (
"encoding/json"
"bytes"
"fmt"
)
// checks if the RetryOperationResponse type satisfies the MappedNullable interface at compile time
var _ MappedNullable = &RetryOperationResponse{}
// RetryOperationResponse Response model for retry operation endpoint.
type RetryOperationResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
OperationId string `json:"operation_id"`
}
type _RetryOperationResponse RetryOperationResponse
// NewRetryOperationResponse instantiates a new RetryOperationResponse object
// This constructor will assign default values to properties that have it defined,
// and makes sure properties required by API are set, but the set of arguments
// will change when the set of required properties is changed
func NewRetryOperationResponse(success bool, message string, operationId string) *RetryOperationResponse {
this := RetryOperationResponse{}
this.Success = success
this.Message = message
this.OperationId = operationId
return &this
}
// NewRetryOperationResponseWithDefaults instantiates a new RetryOperationResponse object
// This constructor will only assign default values to properties that have it defined,
// but it doesn't guarantee that properties required by API are set
func NewRetryOperationResponseWithDefaults() *RetryOperationResponse {
this := RetryOperationResponse{}
return &this
}
// GetSuccess returns the Success field value
func (o *RetryOperationResponse) GetSuccess() bool {
if o == nil {
var ret bool
return ret
}
return o.Success
}
// GetSuccessOk returns a tuple with the Success field value
// and a boolean to check if the value has been set.
func (o *RetryOperationResponse) GetSuccessOk() (*bool, bool) {
if o == nil {
return nil, false
}
return &o.Success, true
}
// SetSuccess sets field value
func (o *RetryOperationResponse) SetSuccess(v bool) {
o.Success = v
}
// GetMessage returns the Message field value
func (o *RetryOperationResponse) GetMessage() string {
if o == nil {
var ret string
return ret
}
return o.Message
}
// GetMessageOk returns a tuple with the Message field value
// and a boolean to check if the value has been set.
func (o *RetryOperationResponse) GetMessageOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.Message, true
}
// SetMessage sets field value
func (o *RetryOperationResponse) SetMessage(v string) {
o.Message = v
}
// GetOperationId returns the OperationId field value
func (o *RetryOperationResponse) GetOperationId() string {
if o == nil {
var ret string
return ret
}
return o.OperationId
}
// GetOperationIdOk returns a tuple with the OperationId field value
// and a boolean to check if the value has been set.
func (o *RetryOperationResponse) GetOperationIdOk() (*string, bool) {
if o == nil {
return nil, false
}
return &o.OperationId, true
}
// SetOperationId sets field value
func (o *RetryOperationResponse) SetOperationId(v string) {
o.OperationId = v
}
func (o RetryOperationResponse) MarshalJSON() ([]byte, error) {
toSerialize,err := o.ToMap()
if err != nil {
return []byte{}, err
}
return json.Marshal(toSerialize)
}
func (o RetryOperationResponse) ToMap() (map[string]interface{}, error) {
toSerialize := map[string]interface{}{}
toSerialize["success"] = o.Success
toSerialize["message"] = o.Message
toSerialize["operation_id"] = o.OperationId
return toSerialize, nil
}
func (o *RetryOperationResponse) UnmarshalJSON(data []byte) (err error) {
// This validates that all required properties are included in the JSON object
// by unmarshalling the object into a generic map with string keys and checking
// that every required field exists as a key in the generic map.
requiredProperties := []string{
"success",
"message",
"operation_id",
}
allProperties := make(map[string]interface{})
err = json.Unmarshal(data, &allProperties)
if err != nil {
return err;
}
for _, requiredProperty := range(requiredProperties) {
if _, exists := allProperties[requiredProperty]; !exists {
return fmt.Errorf("no value given for required property %v", requiredProperty)
}
}
varRetryOperationResponse := _RetryOperationResponse{}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
err = decoder.Decode(&varRetryOperationResponse)
if err != nil {
return err
}
*o = RetryOperationResponse(varRetryOperationResponse)
return err
}
type NullableRetryOperationResponse struct {
value *RetryOperationResponse
isSet bool
}
func (v NullableRetryOperationResponse) Get() *RetryOperationResponse {
return v.value
}
func (v *NullableRetryOperationResponse) Set(val *RetryOperationResponse) {
v.value = val
v.isSet = true
}
func (v NullableRetryOperationResponse) IsSet() bool {
return v.isSet
}
func (v *NullableRetryOperationResponse) Unset() {
v.value = nil
v.isSet = false
}
func NewNullableRetryOperationResponse(val *RetryOperationResponse) *NullableRetryOperationResponse {
return &NullableRetryOperationResponse{value: val, isSet: true}
}
func (v NullableRetryOperationResponse) MarshalJSON() ([]byte, error) {
return json.Marshal(v.value)
}
func (v *NullableRetryOperationResponse) UnmarshalJSON(src []byte) error {
v.isSet = true
return json.Unmarshal(src, &v.value)
}

View file

@ -81,6 +81,7 @@ hindsight_client_api/models/reflect_tool_call.py
hindsight_client_api/models/reflect_trace.py
hindsight_client_api/models/retain_request.py
hindsight_client_api/models/retain_response.py
hindsight_client_api/models/retry_operation_response.py
hindsight_client_api/models/source_facts_include_options.py
hindsight_client_api/models/tag_item.py
hindsight_client_api/models/timestamp.py

View file

@ -106,6 +106,7 @@ from hindsight_client_api.models.reflect_tool_call import ReflectToolCall
from hindsight_client_api.models.reflect_trace import ReflectTrace
from hindsight_client_api.models.retain_request import RetainRequest
from hindsight_client_api.models.retain_response import RetainResponse
from hindsight_client_api.models.retry_operation_response import RetryOperationResponse
from hindsight_client_api.models.source_facts_include_options import SourceFactsIncludeOptions
from hindsight_client_api.models.tag_item import TagItem
from hindsight_client_api.models.timestamp import Timestamp

View file

@ -22,6 +22,7 @@ from typing_extensions import Annotated
from hindsight_client_api.models.cancel_operation_response import CancelOperationResponse
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
from hindsight_client_api.models.operations_list_response import OperationsListResponse
from hindsight_client_api.models.retry_operation_response import RetryOperationResponse
from hindsight_client_api.api_client import ApiClient, RequestSerialized
from hindsight_client_api.api_response import ApiResponse
@ -971,3 +972,296 @@ class OperationsApi:
)
@validate_call
async def retry_operation(
self,
bank_id: StrictStr,
operation_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RetryOperationResponse:
"""Retry a failed async operation
Re-queue a failed async operation so the worker picks it up again
:param bank_id: (required)
:type bank_id: str
:param operation_id: (required)
:type operation_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._retry_operation_serialize(
bank_id=bank_id,
operation_id=operation_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "RetryOperationResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
).data
@validate_call
async def retry_operation_with_http_info(
self,
bank_id: StrictStr,
operation_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[RetryOperationResponse]:
"""Retry a failed async operation
Re-queue a failed async operation so the worker picks it up again
:param bank_id: (required)
:type bank_id: str
:param operation_id: (required)
:type operation_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._retry_operation_serialize(
bank_id=bank_id,
operation_id=operation_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "RetryOperationResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
)
@validate_call
async def retry_operation_without_preload_content(
self,
bank_id: StrictStr,
operation_id: StrictStr,
authorization: Optional[StrictStr] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Retry a failed async operation
Re-queue a failed async operation so the worker picks it up again
:param bank_id: (required)
:type bank_id: str
:param operation_id: (required)
:type operation_id: str
:param authorization:
:type authorization: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._retry_operation_serialize(
bank_id=bank_id,
operation_id=operation_id,
authorization=authorization,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "RetryOperationResponse",
'422': "HTTPValidationError",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _retry_operation_serialize(
self,
bank_id,
operation_id,
authorization,
_request_auth,
_content_type,
_headers,
_host_index,
) -> RequestSerialized:
_host = None
_collection_formats: Dict[str, str] = {
}
_path_params: Dict[str, str] = {}
_query_params: List[Tuple[str, str]] = []
_header_params: Dict[str, Optional[str]] = _headers or {}
_form_params: List[Tuple[str, str]] = []
_files: Dict[
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
] = {}
_body_params: Optional[bytes] = None
# process the path parameters
if bank_id is not None:
_path_params['bank_id'] = bank_id
if operation_id is not None:
_path_params['operation_id'] = operation_id
# process the query parameters
# process the header parameters
if authorization is not None:
_header_params['authorization'] = authorization
# process the form parameters
# process the body parameter
# set the HTTP header `Accept`
if 'Accept' not in _header_params:
_header_params['Accept'] = self.api_client.select_header_accept(
[
'application/json'
]
)
# authentication setting
_auth_settings: List[str] = [
]
return self.api_client.param_serialize(
method='POST',
resource_path='/v1/default/banks/{bank_id}/operations/{operation_id}/retry',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
body=_body_params,
post_params=_form_params,
files=_files,
auth_settings=_auth_settings,
collection_formats=_collection_formats,
_host=_host,
_request_auth=_request_auth
)

View file

@ -80,6 +80,7 @@ from hindsight_client_api.models.reflect_tool_call import ReflectToolCall
from hindsight_client_api.models.reflect_trace import ReflectTrace
from hindsight_client_api.models.retain_request import RetainRequest
from hindsight_client_api.models.retain_response import RetainResponse
from hindsight_client_api.models.retry_operation_response import RetryOperationResponse
from hindsight_client_api.models.source_facts_include_options import SourceFactsIncludeOptions
from hindsight_client_api.models.tag_item import TagItem
from hindsight_client_api.models.timestamp import Timestamp

View file

@ -0,0 +1,91 @@
# coding: utf-8
"""
Hindsight HTTP API
HTTP API for Hindsight
The version of the OpenAPI document: 0.4.16
Generated by OpenAPI Generator (https://openapi-generator.tech)
Do not edit the class manually.
""" # noqa: E501
from __future__ import annotations
import pprint
import re # noqa: F401
import json
from pydantic import BaseModel, ConfigDict, StrictBool, StrictStr
from typing import Any, ClassVar, Dict, List
from typing import Optional, Set
from typing_extensions import Self
class RetryOperationResponse(BaseModel):
"""
Response model for retry operation endpoint.
""" # noqa: E501
success: StrictBool
message: StrictStr
operation_id: StrictStr
__properties: ClassVar[List[str]] = ["success", "message", "operation_id"]
model_config = ConfigDict(
populate_by_name=True,
validate_assignment=True,
protected_namespaces=(),
)
def to_str(self) -> str:
"""Returns the string representation of the model using alias"""
return pprint.pformat(self.model_dump(by_alias=True))
def to_json(self) -> str:
"""Returns the JSON representation of the model using alias"""
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
return json.dumps(self.to_dict())
@classmethod
def from_json(cls, json_str: str) -> Optional[Self]:
"""Create an instance of RetryOperationResponse from a JSON string"""
return cls.from_dict(json.loads(json_str))
def to_dict(self) -> Dict[str, Any]:
"""Return the dictionary representation of the model using alias.
This has the following differences from calling pydantic's
`self.model_dump(by_alias=True)`:
* `None` is only added to the output dict for nullable fields that
were set at model initialization. Other fields with value `None`
are ignored.
"""
excluded_fields: Set[str] = set([
])
_dict = self.model_dump(
by_alias=True,
exclude=excluded_fields,
exclude_none=True,
)
return _dict
@classmethod
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
"""Create an instance of RetryOperationResponse from a dict"""
if obj is None:
return None
if not isinstance(obj, dict):
return cls.model_validate(obj)
_obj = cls.model_validate({
"success": obj.get("success"),
"message": obj.get("message"),
"operation_id": obj.get("operation_id")
})
return _obj

View file

@ -146,6 +146,9 @@ import type {
RetainMemoriesData,
RetainMemoriesErrors,
RetainMemoriesResponses,
RetryOperationData,
RetryOperationErrors,
RetryOperationResponses,
TriggerConsolidationData,
TriggerConsolidationErrors,
TriggerConsolidationResponses,
@ -783,6 +786,23 @@ export const getOperationStatus = <ThrowOnError extends boolean = false>(
...options,
});
/**
* Retry a failed async operation
*
* Re-queue a failed async operation so the worker picks it up again
*/
export const retryOperation = <ThrowOnError extends boolean = false>(
options: Options<RetryOperationData, ThrowOnError>,
) =>
(options.client ?? client).post<
RetryOperationResponses,
RetryOperationErrors,
ThrowOnError
>({
url: "/v1/default/banks/{bank_id}/operations/{operation_id}/retry",
...options,
});
/**
* Get memory bank profile
*

View file

@ -1951,6 +1951,26 @@ export type RetainResponse = {
usage?: TokenUsage | null;
};
/**
* RetryOperationResponse
*
* Response model for retry operation endpoint.
*/
export type RetryOperationResponse = {
/**
* Success
*/
success: boolean;
/**
* Message
*/
message: string;
/**
* Operation Id
*/
operation_id: string;
};
/**
* SourceFactsIncludeOptions
*
@ -3841,6 +3861,48 @@ export type GetOperationStatusResponses = {
export type GetOperationStatusResponse =
GetOperationStatusResponses[keyof GetOperationStatusResponses];
export type RetryOperationData = {
body?: never;
headers?: {
/**
* Authorization
*/
authorization?: string | null;
};
path: {
/**
* Bank Id
*/
bank_id: string;
/**
* Operation Id
*/
operation_id: string;
};
query?: never;
url: "/v1/default/banks/{bank_id}/operations/{operation_id}/retry";
};
export type RetryOperationErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type RetryOperationError =
RetryOperationErrors[keyof RetryOperationErrors];
export type RetryOperationResponses = {
/**
* Successful Response
*/
200: RetryOperationResponse;
};
export type RetryOperationResponse2 =
RetryOperationResponses[keyof RetryOperationResponses];
export type GetBankProfileData = {
body?: never;
headers?: {

View file

@ -1,5 +1,5 @@
import { NextResponse } from "next/server";
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
import { sdk, lowLevelClient, DATAPLANE_URL, getDataplaneHeaders } from "@/lib/hindsight-client";
export async function GET(
request: Request,
@ -32,3 +32,40 @@ export async function GET(
return NextResponse.json({ error: "Failed to get operation status" }, { status: 500 });
}
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ bankId: string; operationId: string }> }
) {
try {
const { bankId, operationId } = await params;
if (!bankId) {
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
}
if (!operationId) {
return NextResponse.json({ error: "operation_id is required" }, { status: 400 });
}
const url = `${DATAPLANE_URL}/v1/default/banks/${bankId}/operations/${operationId}/retry`;
const response = await fetch(url, {
method: "POST",
headers: getDataplaneHeaders({ "Content-Type": "application/json" }),
});
const data = await response.json();
if (!response.ok) {
return NextResponse.json(
{ error: data.detail || "Failed to retry operation" },
{ status: response.status }
);
}
return NextResponse.json(data, { status: 200 });
} catch (error) {
console.error("Error retrying operation:", error);
return NextResponse.json({ error: "Failed to retry operation" }, { status: 500 });
}
}

View file

@ -26,7 +26,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RefreshCw, Clock, AlertCircle, CheckCircle, Loader2, X } from "lucide-react";
import { RefreshCw, Clock, AlertCircle, CheckCircle, Loader2, X, RotateCcw } from "lucide-react";
interface Operation {
id: string;
@ -96,6 +96,7 @@ export function BankOperationsView() {
const [limit] = useState(10);
const [offset, setOffset] = useState(0);
const [cancellingOpId, setCancellingOpId] = useState<string | null>(null);
const [retryingOpId, setRetryingOpId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [selectedOperation, setSelectedOperation] = useState<OperationDetails | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
@ -159,6 +160,20 @@ export function BankOperationsView() {
}
};
const handleRetryOperation = async (operationId: string) => {
if (!currentBank) return;
setRetryingOpId(operationId);
try {
await client.retryOperation(currentBank, operationId);
await loadOperations();
} catch (error) {
// Error toast is shown automatically by the API client interceptor
} finally {
setRetryingOpId(null);
}
};
const handleOperationClick = async (operationId: string) => {
if (!currentBank) return;
@ -324,6 +339,25 @@ export function BankOperationsView() {
{cancellingOpId === op.id ? "" : "Cancel"}
</Button>
)}
{op.status === "failed" && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs text-muted-foreground hover:text-blue-600 dark:hover:text-blue-400"
onClick={(e) => {
e.stopPropagation();
handleRetryOperation(op.id);
}}
disabled={retryingOpId === op.id}
>
{retryingOpId === op.id ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RotateCcw className="w-3 h-3 mr-1" />
)}
{retryingOpId === op.id ? "" : "Retry"}
</Button>
)}
</TableCell>
</TableRow>
))}

View file

@ -286,6 +286,19 @@ export class ControlPlaneClient {
});
}
/**
* Retry a failed operation
*/
async retryOperation(bankId: string, operationId: string) {
return this.fetchApi<{
success: boolean;
message: string;
operation_id: string;
}>(`/api/banks/${bankId}/operations/${operationId}`, {
method: "POST",
});
}
/**
* List entities
*/

View file

@ -88,6 +88,33 @@ Response:
| `completed` | Operation finished successfully |
| `failed` | Operation failed (check `error_message` for details) |
## Managing Operations
### Cancel a pending operation
```bash
curl -X DELETE "http://localhost:8000/v1/default/banks/my-bank/operations/550e8400-e29b-41d4-a716-446655440000"
```
### Retry a failed operation
If an operation fails, you can manually re-queue it for execution:
```bash
curl -X POST "http://localhost:8000/v1/default/banks/my-bank/operations/550e8400-e29b-41d4-a716-446655440000/retry"
```
Response:
```json
{
"success": true,
"message": "Operation 550e8400-e29b-41d4-a716-446655440000 queued for retry",
"operation_id": "550e8400-e29b-41d4-a716-446655440000"
}
```
The operation status resets to `pending` and the worker picks it up again. Returns `409` if the operation is not in `failed` state.
## Next Steps
- [**Documents**](./documents) — Track document sources

View file

@ -2607,6 +2607,74 @@
}
}
},
"/v1/default/banks/{bank_id}/operations/{operation_id}/retry": {
"post": {
"tags": [
"Operations"
],
"summary": "Retry a failed async operation",
"description": "Re-queue a failed async operation so the worker picks it up again",
"operationId": "retry_operation",
"parameters": [
{
"name": "bank_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Bank Id"
}
},
{
"name": "operation_id",
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Operation Id"
}
},
{
"name": "authorization",
"in": "header",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"title": "Authorization"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RetryOperationResponse"
}
}
}
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/HTTPValidationError"
}
}
}
}
}
}
},
"/v1/default/banks/{bank_id}/profile": {
"get": {
"tags": [
@ -7431,6 +7499,35 @@
}
}
},
"RetryOperationResponse": {
"properties": {
"success": {
"type": "boolean",
"title": "Success"
},
"message": {
"type": "string",
"title": "Message"
},
"operation_id": {
"type": "string",
"title": "Operation Id"
}
},
"type": "object",
"required": [
"success",
"message",
"operation_id"
],
"title": "RetryOperationResponse",
"description": "Response model for retry operation endpoint.",
"example": {
"message": "Operation 550e8400-e29b-41d4-a716-446655440000 queued for retry",
"operation_id": "550e8400-e29b-41d4-a716-446655440000",
"success": true
}
},
"SourceFactsIncludeOptions": {
"properties": {
"max_tokens": {