Skip to content

Commit 54cae2c

Browse files
Merge pull request #137 from askui/CL-1661-release-2025-09-rpa-crud-workflows
Cl 1661 release 2025 09 rpa crud workflows
2 parents 2f9bb93 + e8e285b commit 54cae2c

File tree

6 files changed

+306
-0
lines changed

6 files changed

+306
-0
lines changed

src/askui/chat/api/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from askui.chat.api.messages.router import router as messages_router
1818
from askui.chat.api.runs.router import router as runs_router
1919
from askui.chat.api.threads.router import router as threads_router
20+
from askui.chat.api.workflows.router import router as workflows_router
2021
from askui.utils.api_utils import (
2122
ConflictError,
2223
FileTooLargeError,
@@ -51,6 +52,7 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]: # noqa: ARG001
5152
v1_router.include_router(runs_router)
5253
v1_router.include_router(mcp_configs_router)
5354
v1_router.include_router(files_router)
55+
v1_router.include_router(workflows_router)
5456
v1_router.include_router(health_router)
5557
app.include_router(v1_router)
5658

src/askui/chat/api/workflows/__init__.py

Whitespace-only changes.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from fastapi import Depends
2+
3+
from askui.chat.api.dependencies import SettingsDep
4+
from askui.chat.api.settings import Settings
5+
from askui.chat.api.workflows.service import WorkflowService
6+
7+
8+
def get_workflow_service(settings: Settings = SettingsDep) -> WorkflowService:
9+
"""Get WorkflowService instance."""
10+
return WorkflowService(settings.data_dir)
11+
12+
13+
WorkflowServiceDep = Depends(get_workflow_service)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from typing import Annotated, Literal
2+
3+
from pydantic import BaseModel, Field
4+
5+
from askui.chat.api.models import WorkspaceId, WorkspaceResource
6+
from askui.utils.datetime_utils import UnixDatetime, now
7+
from askui.utils.id_utils import IdField, generate_time_ordered_id
8+
9+
WorkflowId = Annotated[str, IdField("wf")]
10+
11+
12+
class WorkflowCreateParams(BaseModel):
13+
"""
14+
Parameters for creating a workflow via API.
15+
"""
16+
17+
name: str
18+
description: str
19+
tags: list[str] = Field(default_factory=list)
20+
21+
22+
class WorkflowModifyParams(BaseModel):
23+
"""
24+
Parameters for modifying a workflow via API.
25+
"""
26+
27+
name: str | None = None
28+
description: str | None = None
29+
tags: list[str] | None = None
30+
31+
32+
class Workflow(WorkspaceResource):
33+
"""
34+
A workflow resource in the chat API.
35+
36+
Args:
37+
id (WorkflowId): The id of the workflow. Must start with the 'wf_' prefix and be
38+
followed by one or more alphanumerical characters.
39+
object (Literal['workflow']): The object type, always 'workflow'.
40+
created_at (UnixDatetime): The creation time as a Unix timestamp.
41+
name (str): The name or title of the workflow.
42+
description (str): A detailed description of the workflow's purpose and steps.
43+
tags (list[str], optional): Tags associated with the workflow for filtering or
44+
categorization. Default is an empty list.
45+
workspace_id (WorkspaceId | None, optional): The workspace this workflow belongs to.
46+
"""
47+
48+
id: WorkflowId
49+
object: Literal["workflow"] = "workflow"
50+
created_at: UnixDatetime
51+
name: str
52+
description: str
53+
tags: list[str] = Field(default_factory=list)
54+
55+
@classmethod
56+
def create(
57+
cls, workspace_id: WorkspaceId | None, params: WorkflowCreateParams
58+
) -> "Workflow":
59+
return cls(
60+
id=generate_time_ordered_id("wf"),
61+
created_at=now(),
62+
workspace_id=workspace_id,
63+
**params.model_dump(),
64+
)
65+
66+
def modify(self, params: WorkflowModifyParams) -> "Workflow":
67+
update_data = {k: v for k, v in params.model_dump().items() if v is not None}
68+
return Workflow.model_validate(
69+
{
70+
**self.model_dump(),
71+
**update_data,
72+
}
73+
)
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from typing import Annotated
2+
3+
from fastapi import APIRouter, Header, Path, Query, status
4+
5+
from askui.chat.api.dependencies import ListQueryDep
6+
from askui.chat.api.models import WorkspaceId
7+
from askui.chat.api.workflows.dependencies import WorkflowServiceDep
8+
from askui.chat.api.workflows.models import (
9+
Workflow,
10+
WorkflowCreateParams,
11+
WorkflowId,
12+
WorkflowModifyParams,
13+
)
14+
from askui.chat.api.workflows.service import WorkflowService
15+
from askui.utils.api_utils import ListQuery, ListResponse
16+
17+
router = APIRouter(prefix="/workflows", tags=["workflows"])
18+
19+
20+
@router.get("")
21+
def list_workflows(
22+
askui_workspace: Annotated[WorkspaceId | None, Header()],
23+
tags: Annotated[list[str] | None, Query()] = None,
24+
query: ListQuery = ListQueryDep,
25+
workflow_service: WorkflowService = WorkflowServiceDep,
26+
) -> ListResponse[Workflow]:
27+
"""
28+
List workflows with optional tag filtering.
29+
30+
Args:
31+
askui_workspace: The workspace ID from header
32+
tags: Optional list of tags to filter by
33+
query: Standard list query parameters (limit, after, before, order)
34+
workflow_service: Injected workflow service
35+
36+
Returns:
37+
ListResponse containing workflows matching the criteria
38+
"""
39+
return workflow_service.list_(workspace_id=askui_workspace, query=query, tags=tags)
40+
41+
42+
@router.post("", status_code=status.HTTP_201_CREATED)
43+
def create_workflow(
44+
askui_workspace: Annotated[WorkspaceId | None, Header()],
45+
params: WorkflowCreateParams,
46+
workflow_service: WorkflowService = WorkflowServiceDep,
47+
) -> Workflow:
48+
"""
49+
Create a new workflow.
50+
51+
Args:
52+
askui_workspace: The workspace ID from header
53+
params: Workflow creation parameters (name, description, tags)
54+
workflow_service: Injected workflow service
55+
56+
Returns:
57+
The created workflow
58+
"""
59+
return workflow_service.create(workspace_id=askui_workspace, params=params)
60+
61+
62+
@router.get("/{workflow_id}")
63+
def retrieve_workflow(
64+
askui_workspace: Annotated[WorkspaceId | None, Header()],
65+
workflow_id: Annotated[WorkflowId, Path(...)],
66+
workflow_service: WorkflowService = WorkflowServiceDep,
67+
) -> Workflow:
68+
"""
69+
Retrieve a specific workflow by ID.
70+
71+
Args:
72+
askui_workspace: The workspace ID from header
73+
workflow_id: The workflow ID to retrieve
74+
workflow_service: Injected workflow service
75+
76+
Returns:
77+
The requested workflow
78+
79+
Raises:
80+
NotFoundError: If workflow doesn't exist or user doesn't have access
81+
"""
82+
return workflow_service.retrieve(
83+
workspace_id=askui_workspace, workflow_id=workflow_id
84+
)
85+
86+
87+
@router.patch("/{workflow_id}")
88+
def modify_workflow(
89+
askui_workspace: Annotated[WorkspaceId | None, Header()],
90+
workflow_id: Annotated[WorkflowId, Path(...)],
91+
params: WorkflowModifyParams,
92+
workflow_service: WorkflowService = WorkflowServiceDep,
93+
) -> Workflow:
94+
"""
95+
Modify an existing workflow.
96+
97+
Args:
98+
askui_workspace: The workspace ID from header
99+
workflow_id: The workflow ID to modify
100+
params: Workflow modification parameters (name, description, tags)
101+
workflow_service: Injected workflow service
102+
103+
Returns:
104+
The modified workflow
105+
106+
Raises:
107+
NotFoundError: If workflow doesn't exist or user doesn't have access
108+
"""
109+
return workflow_service.modify(
110+
workspace_id=askui_workspace, workflow_id=workflow_id, params=params
111+
)
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
from pathlib import Path
2+
from typing import Callable
3+
4+
from askui.chat.api.models import WorkspaceId
5+
from askui.chat.api.utils import build_workspace_filter_fn
6+
from askui.chat.api.workflows.models import (
7+
Workflow,
8+
WorkflowCreateParams,
9+
WorkflowId,
10+
WorkflowModifyParams,
11+
)
12+
from askui.utils.api_utils import (
13+
ConflictError,
14+
ListQuery,
15+
ListResponse,
16+
NotFoundError,
17+
list_resources,
18+
)
19+
20+
21+
def _build_workflow_filter_fn(
22+
workspace_id: WorkspaceId | None,
23+
tags: list[str] | None = None,
24+
) -> Callable[[Workflow], bool]:
25+
workspace_filter: Callable[[Workflow], bool] = build_workspace_filter_fn(
26+
workspace_id, Workflow
27+
)
28+
29+
def filter_fn(workflow: Workflow) -> bool:
30+
if not workspace_filter(workflow):
31+
return False
32+
if tags is not None:
33+
return any(tag in workflow.tags for tag in tags)
34+
return True
35+
36+
return filter_fn
37+
38+
39+
class WorkflowService:
40+
def __init__(self, base_dir: Path) -> None:
41+
self._base_dir = base_dir
42+
self._workflows_dir = base_dir / "workflows"
43+
44+
def _get_workflow_path(self, workflow_id: WorkflowId, new: bool = False) -> Path:
45+
workflow_path = self._workflows_dir / f"{workflow_id}.json"
46+
exists = workflow_path.exists()
47+
if new and exists:
48+
error_msg = f"Workflow {workflow_id} already exists"
49+
raise ConflictError(error_msg)
50+
if not new and not exists:
51+
error_msg = f"Workflow {workflow_id} not found"
52+
raise NotFoundError(error_msg)
53+
return workflow_path
54+
55+
def list_(
56+
self,
57+
workspace_id: WorkspaceId | None,
58+
query: ListQuery,
59+
tags: list[str] | None = None,
60+
) -> ListResponse[Workflow]:
61+
return list_resources(
62+
base_dir=self._workflows_dir,
63+
query=query,
64+
resource_type=Workflow,
65+
filter_fn=_build_workflow_filter_fn(workspace_id, tags=tags),
66+
)
67+
68+
def retrieve(
69+
self, workspace_id: WorkspaceId | None, workflow_id: WorkflowId
70+
) -> Workflow:
71+
try:
72+
workflow_path = self._get_workflow_path(workflow_id)
73+
workflow = Workflow.model_validate_json(workflow_path.read_text())
74+
75+
# Check workspace access
76+
if workspace_id is not None and workflow.workspace_id != workspace_id:
77+
error_msg = f"Workflow {workflow_id} not found"
78+
raise NotFoundError(error_msg)
79+
80+
except FileNotFoundError as e:
81+
error_msg = f"Workflow {workflow_id} not found"
82+
raise NotFoundError(error_msg) from e
83+
else:
84+
return workflow
85+
86+
def create(
87+
self, workspace_id: WorkspaceId | None, params: WorkflowCreateParams
88+
) -> Workflow:
89+
workflow = Workflow.create(workspace_id, params)
90+
self._save(workflow, new=True)
91+
return workflow
92+
93+
def modify(
94+
self,
95+
workspace_id: WorkspaceId | None,
96+
workflow_id: WorkflowId,
97+
params: WorkflowModifyParams,
98+
) -> Workflow:
99+
workflow = self.retrieve(workspace_id, workflow_id)
100+
modified = workflow.modify(params)
101+
self._save(modified)
102+
return modified
103+
104+
def _save(self, workflow: Workflow, new: bool = False) -> None:
105+
self._workflows_dir.mkdir(parents=True, exist_ok=True)
106+
workflow_file = self._get_workflow_path(workflow.id, new=new)
107+
workflow_file.write_text(workflow.model_dump_json(), encoding="utf-8")

0 commit comments

Comments
 (0)