feat: filter operations by type + fix stale auto-refresh closure (#522) (#527)

* feat: filter operations by type + fix stale closure in auto-refresh

- Add `type` query param to GET /operations endpoint and engine layer
- Add operation type dropdown filter in Background Operations UI
- Fix auto-refresh interval using stale statusFilter/offset closure by
  adding filter state to useEffect deps and wrapping loadOperations in
  useCallback (fixes #522)
- Regenerate OpenAPI spec and all SDK clients

* fix: update Rust CLI list_operations call with new type parameter
This commit is contained in:
Nicolò Boschi 2026-03-09 13:17:00 +01:00 committed by GitHub
parent 7accac94b2
commit f7a60f898d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 188 additions and 60 deletions

View file

@ -3427,13 +3427,17 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/operations",
response_model=OperationsListResponse,
summary="List async operations",
description="Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.",
description="Get a list of async operations for a specific agent, with optional filtering by status and operation type. Results are sorted by most recent first.",
operation_id="list_operations",
tags=["Operations"],
)
async def api_list_operations(
bank_id: str,
status: str | None = Query(default=None, description="Filter by status: pending, completed, or failed"),
type: str | None = Query(
default=None,
description="Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery",
),
limit: int = Query(default=20, ge=1, le=100, description="Maximum number of operations to return"),
offset: int = Query(default=0, ge=0, description="Number of operations to skip"),
request_context: RequestContext = Depends(get_request_context),
@ -3441,7 +3445,7 @@ def _register_routes(app: FastAPI):
"""List async operations for a memory bank with optional filtering and pagination."""
try:
result = await app.state.memory.list_operations(
bank_id, status=status, limit=limit, offset=offset, request_context=request_context
bank_id, status=status, task_type=type, limit=limit, offset=offset, request_context=request_context
)
return OperationsListResponse(
bank_id=bank_id,

View file

@ -6840,6 +6840,7 @@ class MemoryEngine(MemoryEngineInterface):
bank_id: str,
*,
status: str | None = None,
task_type: str | None = None,
limit: int = 20,
offset: int = 0,
request_context: "RequestContext",
@ -6849,6 +6850,7 @@ class MemoryEngine(MemoryEngineInterface):
Args:
bank_id: Bank identifier
status: Optional status filter (pending, completed, failed)
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)
request_context: Request context for authentication
@ -6877,6 +6879,10 @@ class MemoryEngine(MemoryEngineInterface):
where_conditions.append(f"status = ${len(params) + 1}")
params.append(status)
if task_type:
where_conditions.append(f"operation_type = ${len(params) + 1}")
params.append(task_type)
where_clause = " AND ".join(where_conditions)
# Get total count (with filter)

View file

@ -242,7 +242,7 @@ impl ApiClient {
pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option<String>)> {
self.runtime.block_on(async {
loop {
let response = self.client.list_operations(agent_id, None, None, None, None).await?;
let response = self.client.list_operations(agent_id, None, None, None, None, None).await?;
let ops = response.into_inner();
// Find our operation
@ -329,7 +329,7 @@ impl ApiClient {
pub fn list_operations(&self, agent_id: &str, _verbose: bool) -> Result<OperationsResponse> {
self.runtime.block_on(async {
let response = self.client.list_operations(agent_id, None, None, None, None).await?;
let response = self.client.list_operations(agent_id, None, None, None, None, None).await?;
let value = response.into_inner();
// Convert to JSON Value first, then parse into our type
let json_value = serde_json::to_value(&value)?;

View file

@ -1600,7 +1600,8 @@ paths:
/v1/default/banks/{bank_id}/operations:
get:
description: "Get a list of async operations for a specific agent, with optional\
\ filtering by status. Results are sorted by most recent first."
\ filtering by status and operation type. Results are sorted by most recent\
\ first."
operationId: list_operations
parameters:
- explode: false
@ -1620,6 +1621,16 @@ paths:
nullable: true
type: string
style: form
- description: "Filter by operation type: retain, consolidation, refresh_mental_model,\
\ file_convert_retain, webhook_delivery"
explode: true
in: query
name: type
required: false
schema:
nullable: true
type: string
style: form
- description: Maximum number of operations to return
explode: true
in: query

View file

@ -280,6 +280,7 @@ type ApiListOperationsRequest struct {
ApiService *OperationsAPIService
bankId string
status *string
type_ *string
limit *int32
offset *int32
authorization *string
@ -291,6 +292,12 @@ func (r ApiListOperationsRequest) Status(status string) ApiListOperationsRequest
return r
}
// Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery
func (r ApiListOperationsRequest) Type_(type_ string) ApiListOperationsRequest {
r.type_ = &type_
return r
}
// Maximum number of operations to return
func (r ApiListOperationsRequest) Limit(limit int32) ApiListOperationsRequest {
r.limit = &limit
@ -315,7 +322,7 @@ func (r ApiListOperationsRequest) Execute() (*OperationsListResponse, *http.Resp
/*
ListOperations List async operations
Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.
Get a list of async operations for a specific agent, with optional filtering by status and operation type. Results are sorted by most recent first.
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
@param bankId
@ -354,6 +361,9 @@ func (a *OperationsAPIService) ListOperationsExecute(r ApiListOperationsRequest)
if r.status != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "status", r.status, "form", "")
}
if r.type_ != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "type", r.type_, "form", "")
}
if r.limit != nil {
parameterAddToHeaderOrQuery(localVarQueryParams, "limit", r.limit, "form", "")
} else {

View file

@ -632,6 +632,7 @@ class OperationsApi:
self,
bank_id: StrictStr,
status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, completed, or failed")] = 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,
authorization: Optional[StrictStr] = None,
@ -650,12 +651,14 @@ class OperationsApi:
) -> OperationsListResponse:
"""List async operations
Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.
Get a list of async operations for a specific agent, with optional filtering by status and operation type. Results are sorted by most recent first.
:param bank_id: (required)
:type bank_id: str
:param status: Filter by status: pending, completed, or failed
:type status: str
:param type: Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery
:type type: str
:param limit: Maximum number of operations to return
:type limit: int
:param offset: Number of operations to skip
@ -687,6 +690,7 @@ class OperationsApi:
_param = self._list_operations_serialize(
bank_id=bank_id,
status=status,
type=type,
limit=limit,
offset=offset,
authorization=authorization,
@ -716,6 +720,7 @@ class OperationsApi:
self,
bank_id: StrictStr,
status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, completed, or failed")] = 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,
authorization: Optional[StrictStr] = None,
@ -734,12 +739,14 @@ class OperationsApi:
) -> ApiResponse[OperationsListResponse]:
"""List async operations
Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.
Get a list of async operations for a specific agent, with optional filtering by status and operation type. Results are sorted by most recent first.
:param bank_id: (required)
:type bank_id: str
:param status: Filter by status: pending, completed, or failed
:type status: str
:param type: Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery
:type type: str
:param limit: Maximum number of operations to return
:type limit: int
:param offset: Number of operations to skip
@ -771,6 +778,7 @@ class OperationsApi:
_param = self._list_operations_serialize(
bank_id=bank_id,
status=status,
type=type,
limit=limit,
offset=offset,
authorization=authorization,
@ -800,6 +808,7 @@ class OperationsApi:
self,
bank_id: StrictStr,
status: Annotated[Optional[StrictStr], Field(description="Filter by status: pending, completed, or failed")] = 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,
authorization: Optional[StrictStr] = None,
@ -818,12 +827,14 @@ class OperationsApi:
) -> RESTResponseType:
"""List async operations
Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.
Get a list of async operations for a specific agent, with optional filtering by status and operation type. Results are sorted by most recent first.
:param bank_id: (required)
:type bank_id: str
:param status: Filter by status: pending, completed, or failed
:type status: str
:param type: Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery
:type type: str
:param limit: Maximum number of operations to return
:type limit: int
:param offset: Number of operations to skip
@ -855,6 +866,7 @@ class OperationsApi:
_param = self._list_operations_serialize(
bank_id=bank_id,
status=status,
type=type,
limit=limit,
offset=offset,
authorization=authorization,
@ -879,6 +891,7 @@ class OperationsApi:
self,
bank_id,
status,
type,
limit,
offset,
authorization,
@ -910,6 +923,10 @@ class OperationsApi:
_query_params.append(('status', status))
if type is not None:
_query_params.append(('type', type))
if limit is not None:
_query_params.append(('limit', limit))

View file

@ -738,7 +738,7 @@ export const getChunk = <ThrowOnError extends boolean = false>(
/**
* List async operations
*
* Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.
* Get a list of async operations for a specific agent, with optional filtering by status and operation type. Results are sorted by most recent first.
*/
export const listOperations = <ThrowOnError extends boolean = false>(
options: Options<ListOperationsData, ThrowOnError>,

View file

@ -3715,6 +3715,12 @@ export type ListOperationsData = {
* Filter by status: pending, completed, or failed
*/
status?: string | null;
/**
* Type
*
* Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery
*/
type?: string | null;
/**
* Limit
*

View file

@ -9,13 +9,14 @@ export async function GET(
const { agentId } = await params;
const searchParams = request.nextUrl.searchParams;
const status = searchParams.get("status") || undefined;
const type = searchParams.get("type") || undefined;
const limit = searchParams.get("limit") ? parseInt(searchParams.get("limit")!) : undefined;
const offset = searchParams.get("offset") ? parseInt(searchParams.get("offset")!) : undefined;
const response = await sdk.listOperations({
client: lowLevelClient,
path: { bank_id: agentId },
query: { status, limit, offset },
query: { status, type, limit, offset },
});
return NextResponse.json(response.data || {}, { status: 200 });
} catch (error) {

View file

@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { useBank } from "@/lib/bank-context";
import { client } from "@/lib/api";
import { Button } from "@/components/ui/button";
@ -19,6 +19,13 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { RefreshCw, Clock, AlertCircle, CheckCircle, Loader2, X } from "lucide-react";
interface Operation {
@ -71,11 +78,21 @@ type OperationDetails =
child_operations?: never;
};
const OPERATION_TYPE_OPTIONS = [
{ value: "all", label: "All types" },
{ value: "retain", label: "Retain" },
{ value: "consolidation", label: "Consolidation" },
{ value: "refresh_mental_model", label: "Mental Model Refresh" },
{ value: "file_convert_retain", label: "File Convert & Retain" },
{ value: "webhook_delivery", label: "Webhook Delivery" },
];
export function BankOperationsView() {
const { currentBank } = useBank();
const [operations, setOperations] = useState<Operation[]>([]);
const [totalOperations, setTotalOperations] = useState(0);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [taskTypeFilter, setTaskTypeFilter] = useState<string | null>(null);
const [limit] = useState(10);
const [offset, setOffset] = useState(0);
const [cancellingOpId, setCancellingOpId] = useState<string | null>(null);
@ -84,37 +101,48 @@ export function BankOperationsView() {
const [dialogOpen, setDialogOpen] = useState(false);
const [loadingDetails, setLoadingDetails] = useState(false);
const loadOperations = async (
newStatusFilter: string | null = statusFilter,
newOffset: number = offset
) => {
if (!currentBank) return;
const loadOperations = useCallback(
async (
newStatusFilter: string | null = statusFilter,
newOffset: number = offset,
newTaskTypeFilter: string | null = taskTypeFilter
) => {
if (!currentBank) return;
setLoading(true);
try {
const opsData = await client.listOperations(currentBank, {
status: newStatusFilter || undefined,
limit,
offset: newOffset,
});
setOperations(opsData.operations || []);
setTotalOperations(opsData.total || 0);
} catch (error) {
console.error("Error loading operations:", error);
} finally {
setLoading(false);
}
};
setLoading(true);
try {
const opsData = await client.listOperations(currentBank, {
status: newStatusFilter || undefined,
type: newTaskTypeFilter || undefined,
limit,
offset: newOffset,
});
setOperations(opsData.operations || []);
setTotalOperations(opsData.total || 0);
} catch (error) {
console.error("Error loading operations:", error);
} finally {
setLoading(false);
}
},
[currentBank, statusFilter, offset, taskTypeFilter, limit]
);
const handleFilterChange = (newFilter: string | null) => {
setStatusFilter(newFilter);
setOffset(0);
loadOperations(newFilter, 0);
loadOperations(newFilter, 0, taskTypeFilter);
};
const handleTaskTypeFilterChange = (newTaskType: string | null) => {
setTaskTypeFilter(newTaskType);
setOffset(0);
loadOperations(statusFilter, 0, newTaskType);
};
const handlePageChange = (newOffset: number) => {
setOffset(newOffset);
loadOperations(statusFilter, newOffset);
loadOperations(statusFilter, newOffset, taskTypeFilter);
};
const handleCancelOperation = async (operationId: string) => {
@ -149,12 +177,14 @@ export function BankOperationsView() {
useEffect(() => {
if (currentBank) {
loadOperations();
// Refresh operations every 5 seconds
const interval = setInterval(() => loadOperations(), 5000);
loadOperations(statusFilter, offset, taskTypeFilter);
const interval = setInterval(
() => loadOperations(statusFilter, offset, taskTypeFilter),
5000
);
return () => clearInterval(interval);
}
}, [currentBank]);
}, [currentBank, statusFilter, offset, taskTypeFilter]);
if (!currentBank) return null;
@ -180,25 +210,47 @@ export function BankOperationsView() {
{statusFilter ? ` (${statusFilter})` : ""}
</p>
</div>
<div className="flex gap-1 bg-muted p-1 rounded-lg">
{[
{ value: null, label: "All" },
{ value: "pending", label: "Pending" },
{ value: "completed", label: "Completed" },
{ value: "failed", label: "Failed" },
].map((filter) => (
<button
key={filter.value ?? "all"}
onClick={() => handleFilterChange(filter.value)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
statusFilter === filter.value
? "bg-background shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{filter.label}
</button>
))}
<div className="flex items-center gap-3">
<Select
value={taskTypeFilter ?? "all"}
onValueChange={(val) => handleTaskTypeFilterChange(val === "all" ? null : val)}
>
<SelectTrigger className="h-9 w-[180px] text-sm">
<SelectValue placeholder="All types" />
</SelectTrigger>
<SelectContent>
{OPERATION_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
<div>
<div>{opt.label}</div>
{opt.value !== "all" && (
<div className="text-xs text-muted-foreground font-mono">{opt.value}</div>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-1 bg-muted p-1 rounded-lg">
{[
{ value: null, label: "All" },
{ value: "pending", label: "Pending" },
{ value: "completed", label: "Completed" },
{ value: "failed", label: "Failed" },
].map((filter) => (
<button
key={filter.value ?? "all"}
onClick={() => handleFilterChange(filter.value)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
statusFilter === filter.value
? "bg-background shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{filter.label}
</button>
))}
</div>
</div>
</div>
<div>

View file

@ -248,10 +248,11 @@ export class ControlPlaneClient {
*/
async listOperations(
bankId: string,
options?: { status?: string; limit?: number; offset?: number }
options?: { status?: string; type?: string; limit?: number; offset?: number }
) {
const params = new URLSearchParams();
if (options?.status) params.append("status", options.status);
if (options?.type) params.append("type", options.type);
if (options?.limit) params.append("limit", options.limit.toString());
if (options?.offset) params.append("offset", options.offset.toString());
const query = params.toString();

View file

@ -52,7 +52,9 @@
".next-50432/types/**/*.ts",
".next-50432/dev/types/**/*.ts",
".next-54840/types/**/*.ts",
".next-54840/dev/types/**/*.ts"
".next-54840/dev/types/**/*.ts",
".next-64856/types/**/*.ts",
".next-64856/dev/types/**/*.ts"
],
"exclude": [
"node_modules"

View file

@ -2357,7 +2357,7 @@
"Operations"
],
"summary": "List async operations",
"description": "Get a list of async operations for a specific agent, with optional filtering by status. Results are sorted by most recent first.",
"description": "Get a list of async operations for a specific agent, with optional filtering by status and operation type. Results are sorted by most recent first.",
"operationId": "list_operations",
"parameters": [
{
@ -2387,6 +2387,24 @@
},
"description": "Filter by status: pending, completed, or failed"
},
{
"name": "type",
"in": "query",
"required": false,
"schema": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery",
"title": "Type"
},
"description": "Filter by operation type: retain, consolidation, refresh_mental_model, file_convert_retain, webhook_delivery"
},
{
"name": "limit",
"in": "query",