diff --git a/.spec-sha b/.spec-sha index eb4a8f8..43b9581 100644 --- a/.spec-sha +++ b/.spec-sha @@ -1 +1 @@ -8c8c6f7f8bdffec317e65c2abf9e2af93692424f +a13dbaa5b545aaedb3547d956a25f2d62da5c5a9 diff --git a/guards.log b/guards.log index f9f955c..ae1a379 100644 --- a/guards.log +++ b/guards.log @@ -1,13 +1,13 @@ ........................................................................ [ 17%] ..................................................................s..... [ 34%] -........................................................................ [ 51%] -.........F.............................................................. [ 68%] +...........................................................F............ [ 51%] +........................................................................ [ 68%] ........................................................................ [ 85%] ............................................................... [100%] =================================== FAILURES =================================== -__________________ test_no_unexposed_sdk_params[voices.list] ___________________ +_________________ test_no_unexposed_sdk_params[workflows.list] _________________ -cmd = Cmd(group='voices', name='list', method='voices.list', help='List available voices.', subgroup=None, args=[], options=...ider', 'gender', 'category'], success_msg=None, destructive=False, redact=False, fallback_methods=(), gate='buildable') +cmd = Cmd(group='workflows', name='list', method='workflows.list', help='List workflows in the workspace.', subgroup=None, a...', 'last_run_status', 'updated_at'], success_msg=None, destructive=False, redact=False, fallback_methods=(), gate=None) @pytest.mark.parametrize("cmd", TABLE, ids=lambda c: ".".join(c.path)) def test_no_unexposed_sdk_params(cmd: Cmd) -> None: @@ -24,10 +24,10 @@ cmd = Cmd(group='voices', name='list', method='voices.list', help='List availabl f"for it. Either add an Opt(...) to the row in _spec.py, or record the decision in " f"_INTENTIONALLY_UNEXPOSED with the reason." ) -E AssertionError: voices.list: voices.list accepts ['provider_voice_id'] but the CLI has no flag for it. Either add an Opt(...) to the row in _spec.py, or record the decision in _INTENTIONALLY_UNEXPOSED with the reason. -E assert not {'provider_voice_id'} +E AssertionError: workflows.list: workflows.list accepts ['exclude_empty'] but the CLI has no flag for it. Either add an Opt(...) to the row in _spec.py, or record the decision in _INTENTIONALLY_UNEXPOSED with the reason. +E assert not {'exclude_empty'} tests/build/test_sdk_contract.py:233: AssertionError =========================== short test summary info ============================ -FAILED tests/build/test_sdk_contract.py::test_no_unexposed_sdk_params[voices.list] - AssertionError: voices.list: voices.list accepts ['provider_voice_id'] but the CLI has no flag for it. Either add an Opt(...) to the row in _spec.py, or record the decision in _INTENTIONALLY_UNEXPOSED with the reason. -assert not {'provider_voice_id'} +FAILED tests/build/test_sdk_contract.py::test_no_unexposed_sdk_params[workflows.list] - AssertionError: workflows.list: workflows.list accepts ['exclude_empty'] but the CLI has no flag for it. Either add an Opt(...) to the row in _spec.py, or record the decision in _INTENTIONALLY_UNEXPOSED with the reason. +assert not {'exclude_empty'} diff --git a/src/onepin/.fern/metadata.json b/src/onepin/.fern/metadata.json index 5e7f85f..893414a 100644 --- a/src/onepin/.fern/metadata.json +++ b/src/onepin/.fern/metadata.json @@ -30,7 +30,7 @@ } ] }, - "originGitCommit": "45e96724ab4ffc17fdc44d6f21e1f71206c318cc", + "originGitCommit": "5f8c05331687cedffed0c6ac9e97d81b9c338aa9", "originGitCommitIsDirty": false, "invokedBy": "ci", "ciProvider": "github" diff --git a/src/onepin/core/http_client.py b/src/onepin/core/http_client.py index 3e5a786..23b0f82 100644 --- a/src/onepin/core/http_client.py +++ b/src/onepin/core/http_client.py @@ -184,8 +184,33 @@ def _should_retry(response: httpx.Response) -> bool: ) +_AUTH_HEADERS: typing.FrozenSet[str] = frozenset() + + def _redact_headers(headers: typing.Dict[str, str]) -> typing.Dict[str, str]: - return {k: ("[REDACTED]" if k.lower() in _SENSITIVE_HEADERS else v) for k, v in headers.items()} + return { + k: ("[REDACTED]" if k.lower() in _SENSITIVE_HEADERS or k.lower() in _AUTH_HEADERS else v) + for k, v in headers.items() + } + + +def _merge_headers(*header_dicts: typing.Optional[typing.Dict[str, typing.Any]]) -> typing.Dict[str, typing.Any]: + """ + Merge header dicts left to right. Header names are case-insensitive, so a later + `x-api-key` replaces an earlier `X-API-Key` instead of producing two headers. + """ + merged: typing.Dict[str, typing.Any] = {} + keys_by_lower: typing.Dict[str, str] = {} + for header_dict in header_dicts: + if not header_dict: + continue + for key, value in header_dict.items(): + existing_key = keys_by_lower.get(key.lower()) + if existing_key is not None: + del merged[existing_key] + keys_by_lower[key.lower()] = key + merged[key] = value + return merged def _build_url(base_url: str, path: typing.Optional[str]) -> str: @@ -425,11 +450,11 @@ def request( _request_url = _build_url(base_url, path) _request_headers = jsonable_encoder( remove_none_from_dict( - { - **self.base_headers(), - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), - } + _merge_headers( + self.base_headers(), + headers, + request_options.get("additional_headers") if request_options is not None else None, + ) ) ) _request_headers = drop_content_type_without_body( @@ -594,11 +619,11 @@ def stream( _request_url = _build_url(base_url, path) _request_headers = jsonable_encoder( remove_none_from_dict( - { - **self.base_headers(), - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) if request_options is not None else {}), - } + _merge_headers( + self.base_headers(), + headers, + request_options.get("additional_headers") if request_options is not None else None, + ) ) ) _request_headers = drop_content_type_without_body( @@ -735,11 +760,11 @@ async def request( _request_url = _build_url(base_url, path) _request_headers = jsonable_encoder( remove_none_from_dict( - { - **_headers, - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) or {} if request_options is not None else {}), - } + _merge_headers( + _headers, + headers, + request_options.get("additional_headers") if request_options is not None else None, + ) ) ) _request_headers = drop_content_type_without_body( @@ -907,11 +932,11 @@ async def stream( _request_url = _build_url(base_url, path) _request_headers = jsonable_encoder( remove_none_from_dict( - { - **_headers, - **(headers if headers is not None else {}), - **(request_options.get("additional_headers", {}) if request_options is not None else {}), - } + _merge_headers( + _headers, + headers, + request_options.get("additional_headers") if request_options is not None else None, + ) ) ) _request_headers = drop_content_type_without_body( diff --git a/src/onepin/core/pagination.py b/src/onepin/core/pagination.py index 760b089..a4bc194 100644 --- a/src/onepin/core/pagination.py +++ b/src/onepin/core/pagination.py @@ -3,7 +3,20 @@ from __future__ import annotations from dataclasses import dataclass -from typing import AsyncIterator, Awaitable, Callable, Generic, Iterator, List, Optional, TypeVar +from typing import ( + Any, + AsyncIterator, + Awaitable, + Callable, + Generic, + Iterator, + List, + Mapping, + Optional, + Sequence, + TypeVar, + get_args, +) # Generic to represent the underlying type of the results within a page T = TypeVar("T") @@ -80,3 +93,73 @@ async def iter_pages(self) -> AsyncIterator[AsyncPager[T, R]]: async def next_page(self) -> Optional[AsyncPager[T, R]]: return await self.get_next() if self.get_next is not None else None + + +def get_nested_page_value(container: Any, path: Sequence[str], default: Any = None) -> Any: + """ + Read a nested paging value (e.g. `options.offset`) from a request parameter that may be + a dict, a model, None, or omitted (`...`). Returns `default` when any segment is missing. + """ + value = container + for key in path: + if value is None or value is ...: + return default + value = value.get(key) if isinstance(value, Mapping) else getattr(value, key, None) + return default if value is None or value is ... else value + + +def with_nested_page_value(container: Any, path: Sequence[str], value: Any, container_type: Any = None) -> Any: + """ + Return a copy of a request parameter with the nested paging value at `path` replaced, + creating omitted containers and keeping every other field. When `container_type` (or the + annotation of the parent model's field) is a model, created containers are built through that + model so field aliases (e.g. `page_offset` -> `pageOffset`) apply on the wire. + """ + if len(path) == 0: + return value + key, rest = path[0], path[1:] + if container is None or container is ...: + return _build_container(container_type, {key: with_nested_page_value(None, rest, value)}) + if isinstance(container, Mapping): + return {**container, key: with_nested_page_value(container.get(key), rest, value)} + updated = with_nested_page_value( + getattr(container, key, None), rest, value, _get_field_annotation(type(container), key) + ) + if hasattr(container, "model_copy"): + return container.model_copy(update={key: updated}) + return container.copy(update={key: updated}) + + +def _build_container(container_type: Any, data: Mapping[str, Any]) -> Any: + model = _find_model_class(container_type) + if model is None: + return data + try: + if hasattr(model, "model_validate"): + return model.model_validate(data) + return model.parse_obj(data) + except (TypeError, ValueError): + return data + + +def _find_model_class(annotation: Any) -> Any: + models = [] + pending = [annotation] + while pending: + candidate = pending.pop() + if isinstance(candidate, type): + if hasattr(candidate, "model_validate") or hasattr(candidate, "parse_obj"): + models.append(candidate) + else: + pending.extend(get_args(candidate)) + return models[0] if len(models) == 1 else None + + +def _get_field_annotation(model: Any, key: str) -> Any: + fields = getattr(model, "model_fields", None) + if isinstance(fields, Mapping): + return fields[key].annotation if key in fields else None + legacy_fields = getattr(model, "__fields__", None) + if isinstance(legacy_fields, Mapping) and key in legacy_fields: + return legacy_fields[key].outer_type_ + return None diff --git a/src/onepin/reference.md b/src/onepin/reference.md index 48e5e42..1278756 100644 --- a/src/onepin/reference.md +++ b/src/onepin/reference.md @@ -6668,8 +6668,9 @@ List workflows in the current workspace. Returns a counted, paginated list of workflows scoped to the `X-Workspace-Id` header. Each item includes aggregate stats (`runs_count`, `last_run_at`, -`last_run_status`, `run_status_counts`) computed over all runs for that -workflow. `run_status_counts` is a per-raw-`RunStatus` map whose values sum to +`last_run_status`, `run_status_counts`, `last_activity_at`) computed over all +runs (and, for `last_activity_at`, the chat) of that workflow. +`run_status_counts` is a per-raw-`RunStatus` map whose values sum to `runs_count` and are NOT affected by the `status` filter below, so a collapsed row can render an accurate per-tab total without a separate runs query. @@ -6677,15 +6678,15 @@ row can render an accurate per-tab total without a separate runs query. most recent run. `completed` matches only workflows whose latest run succeeded (completed-only), and `failed` matches only workflows whose latest run failed — the two buckets are disjoint. A workflow whose latest run was `cancelled` matches -neither bucket and surfaces only in the unfiltered list. `running` matches active -(running or paused) workflows. `draft` matches workflows with no runs yet. -`paused` is accepted but currently returns no results. +neither bucket and surfaces only in the unfiltered list. `running` matches a latest +run that is pending, running or paused; `active` matches pending or running only, +and `paused` matches paused only. `draft` matches workflows with no runs yet. **Multi-sort:** `sort` and `order` are parallel query lists. `?sort=runs_count&sort=name&order=desc&order=asc` orders primarily by `runs_count DESC`, then by `name ASC`. When `order` has fewer entries than `sort`, missing positions use per-field defaults (`name=asc`, -`updated_at=desc`, `runs_count=desc`). Omitting `sort` defaults to +`updated_at=desc`, `runs_count=desc`, `last_activity_at=desc`). Omitting `sort` defaults to `updated_at DESC`. A stable `id ASC` tiebreaker is always appended so offset/limit pagination is consistent when sort keys tie. @@ -6699,6 +6700,9 @@ keys off the latest run only; `has_failed_run=true` matches workflows with a completed still matches if an earlier run failed. It composes with the other filters (AND). `cancelled` runs do not count as failures. +**Empty workflows:** `exclude_empty=true` hides workflows that are still +placeholder-named with no nodes, no user chat message and no runs. + `pagination.total` reflects the filtered count for the current query. @@ -6738,7 +6742,7 @@ client.workflows.list()
-**status:** `typing.Optional[WorkflowListStatus]` — UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `paused` is accepted for forward compatibility and currently returns no rows. +**status:** `typing.Optional[WorkflowListStatus]` — UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `running` matches a latest run that is pending, running or paused; `active` is the same minus paused, and `paused` is paused only.
@@ -6802,6 +6806,14 @@ client.workflows.list()
+**exclude_empty:** `typing.Optional[bool]` — Hide empty workflows: still placeholder-named, no nodes, no user message in the chat, and no runs. Any one of those ending lists the workflow again. Counted in `pagination.total`. + +
+
+ +
+
+ **offset:** `typing.Optional[int]` — Zero-based pagination offset.
diff --git a/src/onepin/types/workflow_list_item.py b/src/onepin/types/workflow_list_item.py index 45a83d7..239fd42 100644 --- a/src/onepin/types/workflow_list_item.py +++ b/src/onepin/types/workflow_list_item.py @@ -64,6 +64,11 @@ class WorkflowListItem(UniversalBaseModel): Raw RunStatus of the most recent run. One of: `pending`, `running`, `completed`, `failed`, `cancelled`, `paused`. Null if never run. """ + last_activity_at: dt.datetime = pydantic.Field() + """ + Latest of the last chat message (any role, the opener greeting excluded) and the last run; `created_at` when neither exists. What `sort=last_activity_at` orders by. + """ + run_status_counts: typing.Optional[RunStatusCounts] = pydantic.Field(default=None) """ Number of runs per raw RunStatus, computed over ALL runs of this workflow (same population as `runs_count`; NOT affected by the list-level `status` filter). The sum of all values equals `runs_count`; statuses with no runs are 0. diff --git a/src/onepin/types/workflow_list_status.py b/src/onepin/types/workflow_list_status.py index 8f86b37..1dd7589 100644 --- a/src/onepin/types/workflow_list_status.py +++ b/src/onepin/types/workflow_list_status.py @@ -2,4 +2,6 @@ import typing -WorkflowListStatus = typing.Union[typing.Literal["draft", "running", "completed", "failed", "paused"], typing.Any] +WorkflowListStatus = typing.Union[ + typing.Literal["draft", "running", "active", "completed", "failed", "paused"], typing.Any +] diff --git a/src/onepin/types/workflow_run_list_item.py b/src/onepin/types/workflow_run_list_item.py index ff886f6..8f98844 100644 --- a/src/onepin/types/workflow_run_list_item.py +++ b/src/onepin/types/workflow_run_list_item.py @@ -42,6 +42,10 @@ class WorkflowRunListItem(UniversalBaseModel): triggered_by: typing.Optional[TriggeredByOut] = None output_line_count: typing.Optional[int] = None delivered_audio_ms: typing.Optional[int] = None + script_first_line: typing.Optional[str] = pydantic.Field(default=None) + """ + The run's first script line, cut at 500 characters. Null until the run's source step has parsed the script. + """ if IS_PYDANTIC_V2: model_config: typing.ClassVar[pydantic.ConfigDict] = pydantic.ConfigDict(extra="allow", frozen=True) # type: ignore # Pydantic v2 diff --git a/src/onepin/workflows/client.py b/src/onepin/workflows/client.py index 78da03d..4550a3f 100644 --- a/src/onepin/workflows/client.py +++ b/src/onepin/workflows/client.py @@ -62,6 +62,7 @@ def list( last_run_before: typing.Optional[dt.datetime] = None, has_failed_run: typing.Optional[bool] = None, include_definition: typing.Optional[bool] = None, + exclude_empty: typing.Optional[bool] = None, offset: typing.Optional[int] = None, limit: typing.Optional[int] = None, workspace_id: typing.Optional[str] = None, @@ -72,8 +73,9 @@ def list( Returns a counted, paginated list of workflows scoped to the `X-Workspace-Id` header. Each item includes aggregate stats (`runs_count`, `last_run_at`, - `last_run_status`, `run_status_counts`) computed over all runs for that - workflow. `run_status_counts` is a per-raw-`RunStatus` map whose values sum to + `last_run_status`, `run_status_counts`, `last_activity_at`) computed over all + runs (and, for `last_activity_at`, the chat) of that workflow. + `run_status_counts` is a per-raw-`RunStatus` map whose values sum to `runs_count` and are NOT affected by the `status` filter below, so a collapsed row can render an accurate per-tab total without a separate runs query. @@ -81,15 +83,15 @@ def list( most recent run. `completed` matches only workflows whose latest run succeeded (completed-only), and `failed` matches only workflows whose latest run failed — the two buckets are disjoint. A workflow whose latest run was `cancelled` matches - neither bucket and surfaces only in the unfiltered list. `running` matches active - (running or paused) workflows. `draft` matches workflows with no runs yet. - `paused` is accepted but currently returns no results. + neither bucket and surfaces only in the unfiltered list. `running` matches a latest + run that is pending, running or paused; `active` matches pending or running only, + and `paused` matches paused only. `draft` matches workflows with no runs yet. **Multi-sort:** `sort` and `order` are parallel query lists. `?sort=runs_count&sort=name&order=desc&order=asc` orders primarily by `runs_count DESC`, then by `name ASC`. When `order` has fewer entries than `sort`, missing positions use per-field defaults (`name=asc`, - `updated_at=desc`, `runs_count=desc`). Omitting `sort` defaults to + `updated_at=desc`, `runs_count=desc`, `last_activity_at=desc`). Omitting `sort` defaults to `updated_at DESC`. A stable `id ASC` tiebreaker is always appended so offset/limit pagination is consistent when sort keys tie. @@ -103,12 +105,15 @@ def list( completed still matches if an earlier run failed. It composes with the other filters (AND). `cancelled` runs do not count as failures. + **Empty workflows:** `exclude_empty=true` hides workflows that are still + placeholder-named with no nodes, no user chat message and no runs. + `pagination.total` reflects the filtered count for the current query. Parameters ---------- status : typing.Optional[WorkflowListStatus] - UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `paused` is accepted for forward compatibility and currently returns no rows. + UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `running` matches a latest run that is pending, running or paused; `active` is the same minus paused, and `paused` is paused only. search : typing.Optional[str] Case-insensitive search over name and description. @@ -131,6 +136,9 @@ def list( include_definition : typing.Optional[bool] Include each workflow's full `definition` graph in the response. Off by default because the graphs dominate the payload and a list view does not render them; turn it on to compare what the listed workflows do without a per-workflow GET. + exclude_empty : typing.Optional[bool] + Hide empty workflows: still placeholder-named, no nodes, no user message in the chat, and no runs. Any one of those ending lists the workflow again. Counted in `pagination.total`. + offset : typing.Optional[int] Zero-based pagination offset. @@ -165,6 +173,7 @@ def list( last_run_before=last_run_before, has_failed_run=has_failed_run, include_definition=include_definition, + exclude_empty=exclude_empty, offset=offset, limit=limit, workspace_id=workspace_id, @@ -1522,6 +1531,7 @@ async def list( last_run_before: typing.Optional[dt.datetime] = None, has_failed_run: typing.Optional[bool] = None, include_definition: typing.Optional[bool] = None, + exclude_empty: typing.Optional[bool] = None, offset: typing.Optional[int] = None, limit: typing.Optional[int] = None, workspace_id: typing.Optional[str] = None, @@ -1532,8 +1542,9 @@ async def list( Returns a counted, paginated list of workflows scoped to the `X-Workspace-Id` header. Each item includes aggregate stats (`runs_count`, `last_run_at`, - `last_run_status`, `run_status_counts`) computed over all runs for that - workflow. `run_status_counts` is a per-raw-`RunStatus` map whose values sum to + `last_run_status`, `run_status_counts`, `last_activity_at`) computed over all + runs (and, for `last_activity_at`, the chat) of that workflow. + `run_status_counts` is a per-raw-`RunStatus` map whose values sum to `runs_count` and are NOT affected by the `status` filter below, so a collapsed row can render an accurate per-tab total without a separate runs query. @@ -1541,15 +1552,15 @@ async def list( most recent run. `completed` matches only workflows whose latest run succeeded (completed-only), and `failed` matches only workflows whose latest run failed — the two buckets are disjoint. A workflow whose latest run was `cancelled` matches - neither bucket and surfaces only in the unfiltered list. `running` matches active - (running or paused) workflows. `draft` matches workflows with no runs yet. - `paused` is accepted but currently returns no results. + neither bucket and surfaces only in the unfiltered list. `running` matches a latest + run that is pending, running or paused; `active` matches pending or running only, + and `paused` matches paused only. `draft` matches workflows with no runs yet. **Multi-sort:** `sort` and `order` are parallel query lists. `?sort=runs_count&sort=name&order=desc&order=asc` orders primarily by `runs_count DESC`, then by `name ASC`. When `order` has fewer entries than `sort`, missing positions use per-field defaults (`name=asc`, - `updated_at=desc`, `runs_count=desc`). Omitting `sort` defaults to + `updated_at=desc`, `runs_count=desc`, `last_activity_at=desc`). Omitting `sort` defaults to `updated_at DESC`. A stable `id ASC` tiebreaker is always appended so offset/limit pagination is consistent when sort keys tie. @@ -1563,12 +1574,15 @@ async def list( completed still matches if an earlier run failed. It composes with the other filters (AND). `cancelled` runs do not count as failures. + **Empty workflows:** `exclude_empty=true` hides workflows that are still + placeholder-named with no nodes, no user chat message and no runs. + `pagination.total` reflects the filtered count for the current query. Parameters ---------- status : typing.Optional[WorkflowListStatus] - UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `paused` is accepted for forward compatibility and currently returns no rows. + UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `running` matches a latest run that is pending, running or paused; `active` is the same minus paused, and `paused` is paused only. search : typing.Optional[str] Case-insensitive search over name and description. @@ -1591,6 +1605,9 @@ async def list( include_definition : typing.Optional[bool] Include each workflow's full `definition` graph in the response. Off by default because the graphs dominate the payload and a list view does not render them; turn it on to compare what the listed workflows do without a per-workflow GET. + exclude_empty : typing.Optional[bool] + Hide empty workflows: still placeholder-named, no nodes, no user message in the chat, and no runs. Any one of those ending lists the workflow again. Counted in `pagination.total`. + offset : typing.Optional[int] Zero-based pagination offset. @@ -1633,6 +1650,7 @@ async def main() -> None: last_run_before=last_run_before, has_failed_run=has_failed_run, include_definition=include_definition, + exclude_empty=exclude_empty, offset=offset, limit=limit, workspace_id=workspace_id, diff --git a/src/onepin/workflows/raw_client.py b/src/onepin/workflows/raw_client.py index e7bd5b6..b058d81 100644 --- a/src/onepin/workflows/raw_client.py +++ b/src/onepin/workflows/raw_client.py @@ -56,6 +56,7 @@ def list( last_run_before: typing.Optional[dt.datetime] = None, has_failed_run: typing.Optional[bool] = None, include_definition: typing.Optional[bool] = None, + exclude_empty: typing.Optional[bool] = None, offset: typing.Optional[int] = None, limit: typing.Optional[int] = None, workspace_id: typing.Optional[str] = None, @@ -66,8 +67,9 @@ def list( Returns a counted, paginated list of workflows scoped to the `X-Workspace-Id` header. Each item includes aggregate stats (`runs_count`, `last_run_at`, - `last_run_status`, `run_status_counts`) computed over all runs for that - workflow. `run_status_counts` is a per-raw-`RunStatus` map whose values sum to + `last_run_status`, `run_status_counts`, `last_activity_at`) computed over all + runs (and, for `last_activity_at`, the chat) of that workflow. + `run_status_counts` is a per-raw-`RunStatus` map whose values sum to `runs_count` and are NOT affected by the `status` filter below, so a collapsed row can render an accurate per-tab total without a separate runs query. @@ -75,15 +77,15 @@ def list( most recent run. `completed` matches only workflows whose latest run succeeded (completed-only), and `failed` matches only workflows whose latest run failed — the two buckets are disjoint. A workflow whose latest run was `cancelled` matches - neither bucket and surfaces only in the unfiltered list. `running` matches active - (running or paused) workflows. `draft` matches workflows with no runs yet. - `paused` is accepted but currently returns no results. + neither bucket and surfaces only in the unfiltered list. `running` matches a latest + run that is pending, running or paused; `active` matches pending or running only, + and `paused` matches paused only. `draft` matches workflows with no runs yet. **Multi-sort:** `sort` and `order` are parallel query lists. `?sort=runs_count&sort=name&order=desc&order=asc` orders primarily by `runs_count DESC`, then by `name ASC`. When `order` has fewer entries than `sort`, missing positions use per-field defaults (`name=asc`, - `updated_at=desc`, `runs_count=desc`). Omitting `sort` defaults to + `updated_at=desc`, `runs_count=desc`, `last_activity_at=desc`). Omitting `sort` defaults to `updated_at DESC`. A stable `id ASC` tiebreaker is always appended so offset/limit pagination is consistent when sort keys tie. @@ -97,12 +99,15 @@ def list( completed still matches if an earlier run failed. It composes with the other filters (AND). `cancelled` runs do not count as failures. + **Empty workflows:** `exclude_empty=true` hides workflows that are still + placeholder-named with no nodes, no user chat message and no runs. + `pagination.total` reflects the filtered count for the current query. Parameters ---------- status : typing.Optional[WorkflowListStatus] - UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `paused` is accepted for forward compatibility and currently returns no rows. + UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `running` matches a latest run that is pending, running or paused; `active` is the same minus paused, and `paused` is paused only. search : typing.Optional[str] Case-insensitive search over name and description. @@ -125,6 +130,9 @@ def list( include_definition : typing.Optional[bool] Include each workflow's full `definition` graph in the response. Off by default because the graphs dominate the payload and a list view does not render them; turn it on to compare what the listed workflows do without a per-workflow GET. + exclude_empty : typing.Optional[bool] + Hide empty workflows: still placeholder-named, no nodes, no user message in the chat, and no runs. Any one of those ending lists the workflow again. Counted in `pagination.total`. + offset : typing.Optional[int] Zero-based pagination offset. @@ -153,6 +161,7 @@ def list( "last_run_before": serialize_datetime(last_run_before) if last_run_before is not None else None, "has_failed_run": has_failed_run, "include_definition": include_definition, + "exclude_empty": exclude_empty, "offset": offset, "limit": limit, }, @@ -2085,6 +2094,7 @@ async def list( last_run_before: typing.Optional[dt.datetime] = None, has_failed_run: typing.Optional[bool] = None, include_definition: typing.Optional[bool] = None, + exclude_empty: typing.Optional[bool] = None, offset: typing.Optional[int] = None, limit: typing.Optional[int] = None, workspace_id: typing.Optional[str] = None, @@ -2095,8 +2105,9 @@ async def list( Returns a counted, paginated list of workflows scoped to the `X-Workspace-Id` header. Each item includes aggregate stats (`runs_count`, `last_run_at`, - `last_run_status`, `run_status_counts`) computed over all runs for that - workflow. `run_status_counts` is a per-raw-`RunStatus` map whose values sum to + `last_run_status`, `run_status_counts`, `last_activity_at`) computed over all + runs (and, for `last_activity_at`, the chat) of that workflow. + `run_status_counts` is a per-raw-`RunStatus` map whose values sum to `runs_count` and are NOT affected by the `status` filter below, so a collapsed row can render an accurate per-tab total without a separate runs query. @@ -2104,15 +2115,15 @@ async def list( most recent run. `completed` matches only workflows whose latest run succeeded (completed-only), and `failed` matches only workflows whose latest run failed — the two buckets are disjoint. A workflow whose latest run was `cancelled` matches - neither bucket and surfaces only in the unfiltered list. `running` matches active - (running or paused) workflows. `draft` matches workflows with no runs yet. - `paused` is accepted but currently returns no results. + neither bucket and surfaces only in the unfiltered list. `running` matches a latest + run that is pending, running or paused; `active` matches pending or running only, + and `paused` matches paused only. `draft` matches workflows with no runs yet. **Multi-sort:** `sort` and `order` are parallel query lists. `?sort=runs_count&sort=name&order=desc&order=asc` orders primarily by `runs_count DESC`, then by `name ASC`. When `order` has fewer entries than `sort`, missing positions use per-field defaults (`name=asc`, - `updated_at=desc`, `runs_count=desc`). Omitting `sort` defaults to + `updated_at=desc`, `runs_count=desc`, `last_activity_at=desc`). Omitting `sort` defaults to `updated_at DESC`. A stable `id ASC` tiebreaker is always appended so offset/limit pagination is consistent when sort keys tie. @@ -2126,12 +2137,15 @@ async def list( completed still matches if an earlier run failed. It composes with the other filters (AND). `cancelled` runs do not count as failures. + **Empty workflows:** `exclude_empty=true` hides workflows that are still + placeholder-named with no nodes, no user chat message and no runs. + `pagination.total` reflects the filtered count for the current query. Parameters ---------- status : typing.Optional[WorkflowListStatus] - UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `paused` is accepted for forward compatibility and currently returns no rows. + UI workflow status filter. `completed` matches workflows whose latest run succeeded (completed-only); `failed` matches failed-only — the two are disjoint. A workflow whose latest run was cancelled matches neither and appears only in the unfiltered list. `running` matches a latest run that is pending, running or paused; `active` is the same minus paused, and `paused` is paused only. search : typing.Optional[str] Case-insensitive search over name and description. @@ -2154,6 +2168,9 @@ async def list( include_definition : typing.Optional[bool] Include each workflow's full `definition` graph in the response. Off by default because the graphs dominate the payload and a list view does not render them; turn it on to compare what the listed workflows do without a per-workflow GET. + exclude_empty : typing.Optional[bool] + Hide empty workflows: still placeholder-named, no nodes, no user message in the chat, and no runs. Any one of those ending lists the workflow again. Counted in `pagination.total`. + offset : typing.Optional[int] Zero-based pagination offset. @@ -2182,6 +2199,7 @@ async def list( "last_run_before": serialize_datetime(last_run_before) if last_run_before is not None else None, "has_failed_run": has_failed_run, "include_definition": include_definition, + "exclude_empty": exclude_empty, "offset": offset, "limit": limit, }, diff --git a/src/onepin/workflows/types/list_workflows_request_sort_item.py b/src/onepin/workflows/types/list_workflows_request_sort_item.py index ef228b5..c3f41ea 100644 --- a/src/onepin/workflows/types/list_workflows_request_sort_item.py +++ b/src/onepin/workflows/types/list_workflows_request_sort_item.py @@ -2,4 +2,6 @@ import typing -ListWorkflowsRequestSortItem = typing.Union[typing.Literal["name", "updated_at", "runs_count"], typing.Any] +ListWorkflowsRequestSortItem = typing.Union[ + typing.Literal["name", "updated_at", "runs_count", "last_activity_at"], typing.Any +]