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
+]