From f7a60f898d14617fb3b50f4142a911cac95d31e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Mon, 9 Mar 2026 13:17:00 +0100 Subject: [PATCH] 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 --- hindsight-api/hindsight_api/api/http.py | 8 +- .../hindsight_api/engine/memory_engine.py | 6 + hindsight-cli/src/api.rs | 4 +- hindsight-clients/go/api/openapi.yaml | 13 +- hindsight-clients/go/api_operations.go | 12 +- .../api/operations_api.py | 23 ++- .../typescript/generated/sdk.gen.ts | 2 +- .../typescript/generated/types.gen.ts | 6 + .../src/app/api/operations/[agentId]/route.ts | 3 +- .../src/components/bank-operations-view.tsx | 144 ++++++++++++------ hindsight-control-plane/src/lib/api.ts | 3 +- hindsight-control-plane/tsconfig.json | 4 +- hindsight-docs/static/openapi.json | 20 ++- 13 files changed, 188 insertions(+), 60 deletions(-) diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py index ab1b5e08..a4b9893e 100644 --- a/hindsight-api/hindsight_api/api/http.py +++ b/hindsight-api/hindsight_api/api/http.py @@ -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, diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py index 3ca38ec4..cbd1414b 100644 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ b/hindsight-api/hindsight_api/engine/memory_engine.py @@ -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) diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs index ceba328e..f0a01cac 100644 --- a/hindsight-cli/src/api.rs +++ b/hindsight-cli/src/api.rs @@ -242,7 +242,7 @@ impl ApiClient { pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option)> { 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 { 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)?; diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index b1d83e5f..d47d8447 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -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 diff --git a/hindsight-clients/go/api_operations.go b/hindsight-clients/go/api_operations.go index 3031401f..d0ed4f25 100644 --- a/hindsight-clients/go/api_operations.go +++ b/hindsight-clients/go/api_operations.go @@ -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 { diff --git a/hindsight-clients/python/hindsight_client_api/api/operations_api.py b/hindsight-clients/python/hindsight_client_api/api/operations_api.py index 5a84164c..cef1dfbe 100644 --- a/hindsight-clients/python/hindsight_client_api/api/operations_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/operations_api.py @@ -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)) diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts index 47ad9503..c64da0f8 100644 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ b/hindsight-clients/typescript/generated/sdk.gen.ts @@ -738,7 +738,7 @@ export const getChunk = ( /** * 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 = ( options: Options, diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index a44b9419..3fbec174 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -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 * diff --git a/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts b/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts index c01dc36a..016df483 100644 --- a/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts +++ b/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts @@ -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) { diff --git a/hindsight-control-plane/src/components/bank-operations-view.tsx b/hindsight-control-plane/src/components/bank-operations-view.tsx index 73f6b46c..7023453f 100644 --- a/hindsight-control-plane/src/components/bank-operations-view.tsx +++ b/hindsight-control-plane/src/components/bank-operations-view.tsx @@ -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([]); const [totalOperations, setTotalOperations] = useState(0); const [statusFilter, setStatusFilter] = useState(null); + const [taskTypeFilter, setTaskTypeFilter] = useState(null); const [limit] = useState(10); const [offset, setOffset] = useState(0); const [cancellingOpId, setCancellingOpId] = useState(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})` : ""}

-
- {[ - { value: null, label: "All" }, - { value: "pending", label: "Pending" }, - { value: "completed", label: "Completed" }, - { value: "failed", label: "Failed" }, - ].map((filter) => ( - - ))} +
+ +
+ {[ + { value: null, label: "All" }, + { value: "pending", label: "Pending" }, + { value: "completed", label: "Completed" }, + { value: "failed", label: "Failed" }, + ].map((filter) => ( + + ))} +
diff --git a/hindsight-control-plane/src/lib/api.ts b/hindsight-control-plane/src/lib/api.ts index b8b21d75..b22be686 100644 --- a/hindsight-control-plane/src/lib/api.ts +++ b/hindsight-control-plane/src/lib/api.ts @@ -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(); diff --git a/hindsight-control-plane/tsconfig.json b/hindsight-control-plane/tsconfig.json index 84516a6c..5d7e5a1c 100644 --- a/hindsight-control-plane/tsconfig.json +++ b/hindsight-control-plane/tsconfig.json @@ -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" diff --git a/hindsight-docs/static/openapi.json b/hindsight-docs/static/openapi.json index 779f1dab..904f53b7 100644 --- a/hindsight-docs/static/openapi.json +++ b/hindsight-docs/static/openapi.json @@ -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",