diff --git a/.spec-sha b/.spec-sha index eb4a8f8..990207b 100644 --- a/.spec-sha +++ b/.spec-sha @@ -1 +1 @@ -8c8c6f7f8bdffec317e65c2abf9e2af93692424f +7f5e61af37f1710647ae79fecc56f81d00c7cfd5 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..b6f2ef2 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( @@ -463,7 +488,12 @@ def request( files=request_files, timeout=timeout, ) - except (httpx.ConnectError, httpx.RemoteProtocolError): + except (httpx.ConnectError, httpx.RemoteProtocolError) as error: + # A protocol error may occur after the server applied the request, even while reading a 200 body. + if isinstance(error, httpx.RemoteProtocolError) and not ( + request_options is not None and request_options.get("retry_remote_protocol_errors", False) + ): + raise if retries < max_retries: time.sleep(_retry_timeout_from_retries(retries=retries)) return self.request( @@ -594,11 +624,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 +765,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( @@ -773,7 +803,12 @@ async def request( files=request_files, timeout=timeout, ) - except (httpx.ConnectError, httpx.RemoteProtocolError): + except (httpx.ConnectError, httpx.RemoteProtocolError) as error: + # A protocol error may occur after the server applied the request, even while reading a 200 body. + if isinstance(error, httpx.RemoteProtocolError) and not ( + request_options is not None and request_options.get("retry_remote_protocol_errors", False) + ): + raise if retries < max_retries: await asyncio.sleep(_retry_timeout_from_retries(retries=retries)) return await self.request( @@ -907,11 +942,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/core/pydantic_utilities.py b/src/onepin/core/pydantic_utilities.py index 70816b9..4e63e0f 100644 --- a/src/onepin/core/pydantic_utilities.py +++ b/src/onepin/core/pydantic_utilities.py @@ -14,6 +14,7 @@ Callable, ClassVar, Dict, + FrozenSet, List, Mapping, Optional, @@ -37,8 +38,8 @@ IS_PYDANTIC_V2 = pydantic.VERSION.startswith("2.") if IS_PYDANTIC_V2: - _datetime_adapter = pydantic.TypeAdapter(dt.datetime) # type: ignore[attr-defined] - _date_adapter = pydantic.TypeAdapter(dt.date) # type: ignore[attr-defined] + _datetime_adapter = pydantic.TypeAdapter(dt.datetime) # type: ignore[attr-defined,var-annotated] + _date_adapter = pydantic.TypeAdapter(dt.date) # type: ignore[attr-defined,var-annotated] def parse_datetime(value: Any) -> dt.datetime: # type: ignore[misc] if isinstance(value, dt.datetime): @@ -129,7 +130,7 @@ def _decimal_encoder(dec_value: Any) -> Any: from pydantic.typing import is_union as is_union # type: ignore[no-redef] from .datetime_utils import serialize_datetime -from .serialization import convert_and_respect_annotation_metadata +from .serialization import convert_and_respect_annotation_metadata, get_field_to_alias_mapping from typing_extensions import TypeAlias T = TypeVar("T") @@ -237,6 +238,59 @@ def _coerce_keys_to_aliases(model: type, data: Any) -> Any: return rewritten +_declared_keys_cache: "weakref.WeakKeyDictionary[type, Tuple[FrozenSet[str], FrozenSet[str]]]" = ( + weakref.WeakKeyDictionary() +) + + +def _get_declared_keys(model: type) -> Tuple[FrozenSet[str], FrozenSet[str]]: + """ + Keys that refer to a declared field (field names, Pydantic aliases and FieldMetadata aliases), together with + their casefolded forms. Computed once per model class. + """ + cached = _declared_keys_cache.get(model) + if cached is None: + fields: Mapping[str, Any] = ( + getattr(model, "model_fields", {}) if IS_PYDANTIC_V2 else getattr(model, "__fields__", {}) + ) + keys: Set[str] = set(fields) + for field in fields.values(): + alias = getattr(field, "alias", None) + if isinstance(alias, str): + keys.add(alias) + keys.update(get_field_to_alias_mapping(model).values()) + cached = (frozenset(keys), frozenset(key.casefold() for key in keys)) + _declared_keys_cache[model] = cached + return cached + + +def _allows_extra_fields(model: type) -> bool: + if IS_PYDANTIC_V2: + return getattr(model, "model_config", {}).get("extra") == "allow" + extra = getattr(getattr(model, "__config__", None), "extra", None) + return getattr(extra, "value", extra) == "allow" + + +def _drop_extras_colliding_with_fields(model: type, data: Any) -> Any: + """ + Drop unknown keys that match a declared field case-insensitively (e.g. `VALUE` next to `value`). Kept as + extras, they would be sent again after the declared field, and a case-insensitive decoder such as Go's + encoding/json would read them instead of it. + """ + if not isinstance(data, Mapping) or not _allows_extra_fields(model): + return data + + declared_keys, declared_casefolded = _get_declared_keys(model) + colliding = { + key + for key in data + if isinstance(key, str) and key not in declared_keys and key.casefold() in declared_casefolded + } + if not colliding: + return data + return {key: value for key, value in data.items() if key not in colliding} + + def parse_obj_as(type_: Type[T], object_: Any) -> T: # convert_and_respect_annotation_metadata is required for TypedDict aliasing. # @@ -279,7 +333,7 @@ class UniversalBaseModel(pydantic.BaseModel): @pydantic.model_validator(mode="before") # type: ignore[attr-defined] @classmethod def _coerce_field_names_to_aliases(cls, data: Any) -> Any: - return _coerce_keys_to_aliases(cls, data) + return _coerce_keys_to_aliases(cls, _drop_extras_colliding_with_fields(cls, data)) @pydantic.model_serializer(mode="plain", when_used="json") # type: ignore[attr-defined] def serialize_model(self) -> Any: # type: ignore[name-defined] @@ -295,7 +349,7 @@ class Config: @pydantic.root_validator(pre=True) def _coerce_field_names_to_aliases(cls, values: Any) -> Any: - return _coerce_keys_to_aliases(cls, values) # type: ignore[arg-type] + return _coerce_keys_to_aliases(cls, _drop_extras_colliding_with_fields(cls, values)) # type: ignore[arg-type] @classmethod def model_construct(cls: Type["Model"], _fields_set: Optional[Set[str]] = None, **values: Any) -> "Model": diff --git a/src/onepin/core/request_options.py b/src/onepin/core/request_options.py index caa6f66..bd6bc01 100644 --- a/src/onepin/core/request_options.py +++ b/src/onepin/core/request_options.py @@ -20,6 +20,8 @@ class RequestOptions(typing.TypedDict, total=False): - max_retries: int. The max number of retries to attempt if the API call fails. + - retry_remote_protocol_errors: bool. Retry server disconnects and incomplete responses. Defaults to False, since the server may already have applied the request. Enable only when replaying the request is safe; bounded by max_retries. + - additional_headers: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's header dict - additional_query_parameters: typing.Dict[str, typing.Any]. A dictionary containing additional parameters to spread into the request's query parameters dict @@ -32,6 +34,7 @@ class RequestOptions(typing.TypedDict, total=False): timeout: NotRequired[float] timeout_in_seconds: NotRequired[int] max_retries: NotRequired[int] + retry_remote_protocol_errors: NotRequired[bool] additional_headers: NotRequired[typing.Dict[str, typing.Any]] additional_query_parameters: NotRequired[typing.Dict[str, typing.Any]] additional_body_parameters: NotRequired[typing.Dict[str, typing.Any]] diff --git a/src/onepin/reference.md b/src/onepin/reference.md index 48e5e42..117becd 100644 --- a/src/onepin/reference.md +++ b/src/onepin/reference.md @@ -5468,7 +5468,8 @@ The two-step upload flow: `category` controls which file formats are accepted: - `script` — text-based formats (txt, pdf) -- `dictionary` — audio formats (mp3, wav, m4a, ogg, webm) +- `dictionary` — pronunciation audio (mp3, wav, m4a, ogg, webm) +- `audio` — bring-your-own-audio source clips (wav, mp3) The presigned URL expires within a short window (see `upload_url` TTL in the response). If the URL expires before the PUT completes, discard this upload @@ -5527,7 +5528,7 @@ client.uploads.create(
-**category:** `UploadRequestCategory` — File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for audio formats (mp3, wav, m4a, ogg, webm). +**category:** `UploadRequestCategory` — File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for pronunciation audio (mp3, wav, m4a, ogg, webm); `audio` for bring-your-own-audio source clips (wav, mp3).
@@ -6668,8 +6669,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 +6679,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 +6701,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 +6743,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 +6807,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/node_type.py b/src/onepin/types/node_type.py index b3d8f98..791449a 100644 --- a/src/onepin/types/node_type.py +++ b/src/onepin/types/node_type.py @@ -5,6 +5,7 @@ NodeType = typing.Union[ typing.Literal[ "source_script", + "source_audio", "operator_translator", "operator_normalizer", "operator_generator", diff --git a/src/onepin/types/pause_reason.py b/src/onepin/types/pause_reason.py index 1c897ee..33aa4f4 100644 --- a/src/onepin/types/pause_reason.py +++ b/src/onepin/types/pause_reason.py @@ -3,5 +3,8 @@ import typing PauseReason = typing.Union[ - typing.Literal["user", "provider_outage", "provider_rate_limited", "provider_billing", "node_timeout"], typing.Any + typing.Literal[ + "user", "provider_outage", "provider_rate_limited", "provider_billing", "node_timeout", "insufficient_credits" + ], + typing.Any, ] diff --git a/src/onepin/types/upload_out.py b/src/onepin/types/upload_out.py index ee14da9..2ad76dd 100644 --- a/src/onepin/types/upload_out.py +++ b/src/onepin/types/upload_out.py @@ -30,7 +30,7 @@ class UploadOut(UniversalBaseModel): category: str = pydantic.Field() """ - Upload category: `script` or `dictionary`. + Upload category: `script`, `dictionary`, or `audio`. """ content_type: str = pydantic.Field() 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/uploads/client.py b/src/onepin/uploads/client.py index 568f29a..f8c75d2 100644 --- a/src/onepin/uploads/client.py +++ b/src/onepin/uploads/client.py @@ -50,7 +50,8 @@ def create( `category` controls which file formats are accepted: - `script` — text-based formats (txt, pdf) - - `dictionary` — audio formats (mp3, wav, m4a, ogg, webm) + - `dictionary` — pronunciation audio (mp3, wav, m4a, ogg, webm) + - `audio` — bring-your-own-audio source clips (wav, mp3) The presigned URL expires within a short window (see `upload_url` TTL in the response). If the URL expires before the PUT completes, discard this upload @@ -67,7 +68,7 @@ def create( Original filename including extension (e.g. `script.txt`). Must include a file extension. category : UploadRequestCategory - File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for audio formats (mp3, wav, m4a, ogg, webm). + File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for pronunciation audio (mp3, wav, m4a, ogg, webm); `audio` for bring-your-own-audio source clips (wav, mp3). workspace_id : typing.Optional[str] @@ -315,7 +316,8 @@ async def create( `category` controls which file formats are accepted: - `script` — text-based formats (txt, pdf) - - `dictionary` — audio formats (mp3, wav, m4a, ogg, webm) + - `dictionary` — pronunciation audio (mp3, wav, m4a, ogg, webm) + - `audio` — bring-your-own-audio source clips (wav, mp3) The presigned URL expires within a short window (see `upload_url` TTL in the response). If the URL expires before the PUT completes, discard this upload @@ -332,7 +334,7 @@ async def create( Original filename including extension (e.g. `script.txt`). Must include a file extension. category : UploadRequestCategory - File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for audio formats (mp3, wav, m4a, ogg, webm). + File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for pronunciation audio (mp3, wav, m4a, ogg, webm); `audio` for bring-your-own-audio source clips (wav, mp3). workspace_id : typing.Optional[str] diff --git a/src/onepin/uploads/raw_client.py b/src/onepin/uploads/raw_client.py index c49a39e..f8febcb 100644 --- a/src/onepin/uploads/raw_client.py +++ b/src/onepin/uploads/raw_client.py @@ -46,7 +46,8 @@ def create( `category` controls which file formats are accepted: - `script` — text-based formats (txt, pdf) - - `dictionary` — audio formats (mp3, wav, m4a, ogg, webm) + - `dictionary` — pronunciation audio (mp3, wav, m4a, ogg, webm) + - `audio` — bring-your-own-audio source clips (wav, mp3) The presigned URL expires within a short window (see `upload_url` TTL in the response). If the URL expires before the PUT completes, discard this upload @@ -63,7 +64,7 @@ def create( Original filename including extension (e.g. `script.txt`). Must include a file extension. category : UploadRequestCategory - File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for audio formats (mp3, wav, m4a, ogg, webm). + File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for pronunciation audio (mp3, wav, m4a, ogg, webm); `audio` for bring-your-own-audio source clips (wav, mp3). workspace_id : typing.Optional[str] @@ -394,7 +395,8 @@ async def create( `category` controls which file formats are accepted: - `script` — text-based formats (txt, pdf) - - `dictionary` — audio formats (mp3, wav, m4a, ogg, webm) + - `dictionary` — pronunciation audio (mp3, wav, m4a, ogg, webm) + - `audio` — bring-your-own-audio source clips (wav, mp3) The presigned URL expires within a short window (see `upload_url` TTL in the response). If the URL expires before the PUT completes, discard this upload @@ -411,7 +413,7 @@ async def create( Original filename including extension (e.g. `script.txt`). Must include a file extension. category : UploadRequestCategory - File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for audio formats (mp3, wav, m4a, ogg, webm). + File category. Determines which formats are accepted: `script` for text formats (txt, pdf); `dictionary` for pronunciation audio (mp3, wav, m4a, ogg, webm); `audio` for bring-your-own-audio source clips (wav, mp3). workspace_id : typing.Optional[str] diff --git a/src/onepin/uploads/types/upload_request_category.py b/src/onepin/uploads/types/upload_request_category.py index a02008b..8c6015b 100644 --- a/src/onepin/uploads/types/upload_request_category.py +++ b/src/onepin/uploads/types/upload_request_category.py @@ -2,4 +2,4 @@ import typing -UploadRequestCategory = typing.Union[typing.Literal["script", "dictionary"], typing.Any] +UploadRequestCategory = typing.Union[typing.Literal["script", "dictionary", "audio"], typing.Any] 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 +]