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",