Skip to content

Commit 3f2a4d5

Browse files
committed
[Node] add delete workflow in debug view
1 parent 7e5092f commit 3f2a4d5

14 files changed

Lines changed: 980 additions & 325 deletions

File tree

packages/node/octobot_node/scheduler/scheduler.py

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,27 @@ async def _get_parent_and_children_automation_workflow_ids(
242242
)
243243
return [workflow.workflow_id for workflow in matching_workflows]
244244

245+
async def _get_user_action_workflow_ids(
246+
self,
247+
user_id: typing.Optional[str],
248+
user_action_ids: list[str],
249+
statuses: list[dbos.WorkflowStatusString],
250+
load_output: bool = False
251+
) -> list[str]:
252+
if not user_action_ids:
253+
return []
254+
user_action_id_set = set(user_action_ids)
255+
matching_workflows = await self._list_workflows(
256+
user_id, statuses,
257+
[octobot_node.enums.SchedulerQueues.USER_ACTION_QUEUE.value], load_output
258+
)
259+
matched_workflow_ids: list[str] = []
260+
for workflow in matching_workflows:
261+
user_action_id = self._user_action_id_from_workflow(workflow, load_output=load_output)
262+
if user_action_id is not None and user_action_id in user_action_id_set:
263+
matched_workflow_ids.append(workflow.workflow_id)
264+
return matched_workflow_ids
265+
245266
async def resolve_active_automation_workflow_ids_for_parent_id(
246267
self,
247268
user_id: typing.Optional[str],
@@ -299,17 +320,30 @@ async def cancel_workflows(self, workflow_ids: list[str]) -> list[str]:
299320
except Exception as e:
300321
self.logger.exception(e, True, f"Failed to cancel workflows {workflow_ids}: {e}")
301322
return []
302-
303-
async def delete_workflows(self, to_delete_workflow_ids: list[str]):
304-
self.logger.info(f"Deleting {len(to_delete_workflow_ids)} workflows")
305-
merged_to_delete_workflow_ids = await self._get_parent_and_children_automation_workflow_ids(
323+
324+
async def _get_workflows_to_delete(self, workflow_ids: list[str]) -> list[str]:
325+
automation_workflows = await self._get_parent_and_children_automation_workflow_ids(
306326
None,
307-
to_delete_workflow_ids,
327+
workflow_ids,
308328
[
309329
dbos.WorkflowStatusString.SUCCESS, dbos.WorkflowStatusString.ERROR,
310330
dbos.WorkflowStatusString.CANCELLED, dbos.WorkflowStatusString.MAX_RECOVERY_ATTEMPTS_EXCEEDED
311331
]
312332
)
333+
user_action_workflows = await self._get_user_action_workflow_ids(
334+
None,
335+
workflow_ids,
336+
[
337+
dbos.WorkflowStatusString.SUCCESS, dbos.WorkflowStatusString.ERROR,
338+
dbos.WorkflowStatusString.CANCELLED, dbos.WorkflowStatusString.MAX_RECOVERY_ATTEMPTS_EXCEEDED
339+
],
340+
load_output=True,
341+
)
342+
return automation_workflows + user_action_workflows
343+
344+
async def delete_workflows(self, to_delete_workflow_ids: list[str]):
345+
self.logger.info(f"Deleting {len(to_delete_workflow_ids)} workflows")
346+
merged_to_delete_workflow_ids = await self._get_workflows_to_delete(to_delete_workflow_ids)
313347
self.logger.info(
314348
f"Including {len(merged_to_delete_workflow_ids) - len(to_delete_workflow_ids)} associated children workflows to delete"
315349
)
@@ -547,6 +581,21 @@ def _user_action_list_sort_key(
547581
workflow_identifier = str(workflow_status.workflow_id or "")
548582
return (workflow_status.created_at or 0, user_action.id, workflow_identifier)
549583

584+
def _user_action_id_from_workflow(
585+
self,
586+
workflow_status: dbos.WorkflowStatus,
587+
*,
588+
load_output: bool,
589+
) -> typing.Optional[str]:
590+
if load_output and workflow_status.output:
591+
from_output = self._parse_user_action_from_workflow_output(workflow_status)
592+
if from_output is not None:
593+
return from_output.id
594+
resolved = workflows_util.resolve_user_action_workflow_inputs(workflow_status)
595+
if resolved.inputs is not None and resolved.inputs.user_action is not None:
596+
return resolved.inputs.user_action.id
597+
return resolved.partial_user_action_id
598+
550599
def _parse_user_action_from_workflow_output(
551600
self,
552601
workflow_status: dbos.WorkflowStatus,

packages/node/tests/scheduler/test_scheduler.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import octobot_commons.cryptography
2424
import octobot_protocol.models as protocol_models
2525
import octobot_node.config
26+
import octobot_node.enums
2627
import octobot_node.models
2728
import octobot_node.scheduler.encryption as encryption
2829
import octobot_node.scheduler.encryption.task_inputs as task_inputs_encryption
@@ -87,6 +88,52 @@ def _make_scheduler_with_mock_instance() -> tuple[scheduler_module.Scheduler, mo
8788
return sched, sched.INSTANCE
8889

8990

91+
_DELETE_WORKFLOW_STATUSES = [
92+
dbos.WorkflowStatusString.SUCCESS,
93+
dbos.WorkflowStatusString.ERROR,
94+
dbos.WorkflowStatusString.CANCELLED,
95+
dbos.WorkflowStatusString.MAX_RECOVERY_ATTEMPTS_EXCEEDED,
96+
]
97+
98+
99+
def _build_user_action_workflow_with_inputs(
100+
user_action_id: str,
101+
workflow_id: str,
102+
user_id: str = "0xw1",
103+
) -> mock.Mock:
104+
user_action = protocol_models.UserAction(id=user_action_id, configuration=None)
105+
workflow_inputs = params.UserActionWorkflowInputs(
106+
user_id=user_id,
107+
user_action=user_action,
108+
).to_dict(include_default_values=False)
109+
workflow_status = mock.Mock(spec=dbos.WorkflowStatus)
110+
workflow_status.workflow_id = workflow_id
111+
workflow_status.input = {"args": [workflow_inputs], "kwargs": {}}
112+
workflow_status.output = None
113+
return workflow_status
114+
115+
116+
def _build_user_action_workflow_with_output(
117+
user_action_id: str,
118+
workflow_id: str,
119+
user_id: str = "0xw1",
120+
) -> mock.Mock:
121+
user_action = protocol_models.UserAction(
122+
id=user_action_id,
123+
status=protocol_models.UserActionStatus.COMPLETED,
124+
configuration=None,
125+
)
126+
output_payload = params.UserActionWorkflowOutput(
127+
user_id=user_id,
128+
updated_user_action=user_action,
129+
).to_dict(include_default_values=False)
130+
workflow_status = mock.Mock(spec=dbos.WorkflowStatus)
131+
workflow_status.workflow_id = workflow_id
132+
workflow_status.input = {"args": [], "kwargs": {}}
133+
workflow_status.output = output_payload
134+
return workflow_status
135+
136+
90137
class TestSchedulerGetResults:
91138

92139
@pytest.mark.asyncio
@@ -940,3 +987,82 @@ async def test_terminal_workflow_with_empty_input_returns_minimal_failed_action(
940987
assert isinstance(inner, protocol_models.AccountActionResult)
941988
assert inner.error_details is not None
942989
assert "persist failed" in inner.error_details
990+
991+
992+
class TestSchedulerGetUserActionWorkflowIds:
993+
@pytest.mark.asyncio
994+
async def test_returns_empty_when_user_action_ids_empty(self):
995+
sched, mock_instance = _make_scheduler_with_mock_instance()
996+
result = await sched._get_user_action_workflow_ids(None, [], _DELETE_WORKFLOW_STATUSES)
997+
assert result == []
998+
mock_instance.list_workflows_async.assert_not_called()
999+
1000+
@pytest.mark.asyncio
1001+
async def test_matches_by_parsed_input_user_action_id(self):
1002+
workflow_status = _build_user_action_workflow_with_inputs("ua-1", "wf-ua-1")
1003+
sched, mock_instance = _make_scheduler_with_mock_instance()
1004+
mock_instance.list_workflows_async = mock.AsyncMock(return_value=[workflow_status])
1005+
result = await sched._get_user_action_workflow_ids(
1006+
None,
1007+
["ua-1"],
1008+
_DELETE_WORKFLOW_STATUSES,
1009+
)
1010+
assert result == ["wf-ua-1"]
1011+
1012+
@pytest.mark.asyncio
1013+
async def test_skips_workflows_with_unrelated_user_action_id(self):
1014+
workflow_first = _build_user_action_workflow_with_inputs("ua-1", "wf-1")
1015+
workflow_second = _build_user_action_workflow_with_inputs("ua-2", "wf-2")
1016+
sched, mock_instance = _make_scheduler_with_mock_instance()
1017+
mock_instance.list_workflows_async = mock.AsyncMock(
1018+
return_value=[workflow_first, workflow_second],
1019+
)
1020+
result = await sched._get_user_action_workflow_ids(
1021+
None,
1022+
["ua-1"],
1023+
_DELETE_WORKFLOW_STATUSES,
1024+
)
1025+
assert result == ["wf-1"]
1026+
1027+
@pytest.mark.asyncio
1028+
async def test_matches_terminal_workflow_from_output_when_load_output_true(self):
1029+
workflow_status = _build_user_action_workflow_with_output("ua-done", "wf-done")
1030+
sched, mock_instance = _make_scheduler_with_mock_instance()
1031+
mock_instance.list_workflows_async = mock.AsyncMock(return_value=[workflow_status])
1032+
result = await sched._get_user_action_workflow_ids(
1033+
None,
1034+
["ua-done"],
1035+
_DELETE_WORKFLOW_STATUSES,
1036+
load_output=True,
1037+
)
1038+
assert result == ["wf-done"]
1039+
1040+
@pytest.mark.asyncio
1041+
async def test_get_workflows_to_delete_merges_automation_and_user_action_ids(self):
1042+
automation_task = octobot_node.models.Task(
1043+
id=PARENT_ID,
1044+
name="automation-task",
1045+
content="encrypted_content",
1046+
content_metadata="meta",
1047+
type="execute_actions",
1048+
)
1049+
automation_workflow = _build_mock_workflow_status(
1050+
automation_task,
1051+
"encrypted_state",
1052+
None,
1053+
workflow_id=PARENT_ID,
1054+
)
1055+
user_action_workflow = _build_user_action_workflow_with_output("ua-delete", "wf-ua-delete")
1056+
1057+
async def list_workflows_side_effect(**kwargs):
1058+
queue_name = kwargs.get("queue_name")
1059+
if queue_name == [octobot_node.enums.SchedulerQueues.AUTOMATION_WORKFLOW_QUEUE.value]:
1060+
return [automation_workflow]
1061+
if queue_name == [octobot_node.enums.SchedulerQueues.USER_ACTION_QUEUE.value]:
1062+
return [user_action_workflow]
1063+
return []
1064+
1065+
sched, mock_instance = _make_scheduler_with_mock_instance()
1066+
mock_instance.list_workflows_async = mock.AsyncMock(side_effect=list_workflows_side_effect)
1067+
result = await sched._get_workflows_to_delete([PARENT_ID, "ua-delete"])
1068+
assert result == [PARENT_ID, "wf-ua-delete"]

packages/tentacles/Services/Interfaces/node_api_interface/api/routes/tasks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ async def export_results(body: ExportResultsBody, current_user: CurrentUser) ->
125125
@router.delete("/", response_model=list[str])
126126
async def delete_tasks(
127127
current_user: CurrentUser,
128-
taskIds: list[uuid.UUID] = Query(...),
128+
taskIds: list[str] = Query(...),
129129
) -> list[str]:
130130
requested_ids = [str(t) for t in taskIds]
131131
if not current_user.is_superuser:
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { CenteredCellContent } from "@/components/Common/Tables/CenteredCellContent"
2+
import { Checkbox } from "@/components/ui/checkbox"
3+
import { TableCell } from "@/components/ui/table"
4+
import { debugTableCellClass } from "@/lib/debug/display-utils"
5+
6+
type TableSelectionCellProps = {
7+
rowId: string
8+
selected: boolean
9+
onToggleRow: (id: string) => void
10+
}
11+
12+
export function TableSelectionCell({
13+
rowId,
14+
selected,
15+
onToggleRow,
16+
}: TableSelectionCellProps) {
17+
return (
18+
<TableCell className={debugTableCellClass("center", "w-10")}>
19+
<CenteredCellContent>
20+
<Checkbox
21+
aria-label={`Select row ${rowId}`}
22+
checked={selected}
23+
onCheckedChange={() => onToggleRow(rowId)}
24+
/>
25+
</CenteredCellContent>
26+
</TableCell>
27+
)
28+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { CenteredCellContent } from "@/components/Common/Tables/CenteredCellContent"
2+
import { Checkbox } from "@/components/ui/checkbox"
3+
import { TableHead } from "@/components/ui/table"
4+
5+
type TableSelectionHeaderProps = {
6+
visibleIds: string[]
7+
selectedIds: Set<string>
8+
onToggleAllVisible: (ids: string[], select: boolean) => void
9+
}
10+
11+
export function TableSelectionHeader({
12+
visibleIds,
13+
selectedIds,
14+
onToggleAllVisible,
15+
}: TableSelectionHeaderProps) {
16+
const selectedVisibleCount = visibleIds.filter((id) =>
17+
selectedIds.has(id),
18+
).length
19+
const allVisibleSelected =
20+
visibleIds.length > 0 && selectedVisibleCount === visibleIds.length
21+
const someVisibleSelected =
22+
selectedVisibleCount > 0 && selectedVisibleCount < visibleIds.length
23+
24+
return (
25+
<TableHead className="w-10 text-center">
26+
<CenteredCellContent>
27+
<Checkbox
28+
aria-label="Select all visible rows"
29+
checked={
30+
allVisibleSelected
31+
? true
32+
: someVisibleSelected
33+
? "indeterminate"
34+
: false
35+
}
36+
disabled={visibleIds.length === 0}
37+
onCheckedChange={() => {
38+
onToggleAllVisible(visibleIds, !allVisibleSelected)
39+
}}
40+
/>
41+
</CenteredCellContent>
42+
</TableHead>
43+
)
44+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {
2+
forwardRef,
3+
useCallback,
4+
useImperativeHandle,
5+
useState,
6+
} from "react"
7+
8+
import { ExecuteActionDialog } from "@/components/Debug/dialogs/ExecuteActionDialog"
9+
import type { ExecuteActionDraft } from "@/lib/debug/types"
10+
11+
export type DebugExecuteActionHandle = {
12+
open: (draft?: ExecuteActionDraft) => void
13+
}
14+
15+
type DebugExecuteActionDialogHostProps = {
16+
walletAddress?: string
17+
onSuccess: () => void
18+
copyOnly: boolean
19+
}
20+
21+
export const DebugExecuteActionDialogHost = forwardRef<
22+
DebugExecuteActionHandle,
23+
DebugExecuteActionDialogHostProps
24+
>(function DebugExecuteActionDialogHost(
25+
{ walletAddress, onSuccess, copyOnly },
26+
ref,
27+
) {
28+
const [executeOpen, setExecuteOpen] = useState(false)
29+
const [executeDraft, setExecuteDraft] = useState<ExecuteActionDraft | null>(
30+
null,
31+
)
32+
33+
const open = useCallback((draft?: ExecuteActionDraft) => {
34+
setExecuteDraft(draft ?? null)
35+
setExecuteOpen(true)
36+
}, [])
37+
38+
useImperativeHandle(ref, () => ({ open }), [open])
39+
40+
const handleExecuteOpenChange = (openDialog: boolean) => {
41+
setExecuteOpen(openDialog)
42+
if (!openDialog) {
43+
setExecuteDraft(null)
44+
}
45+
}
46+
47+
return (
48+
<ExecuteActionDialog
49+
open={executeOpen}
50+
onOpenChange={handleExecuteOpenChange}
51+
walletAddress={walletAddress}
52+
onSuccess={onSuccess}
53+
draft={executeDraft}
54+
copyOnly={copyOnly}
55+
/>
56+
)
57+
})

0 commit comments

Comments
 (0)