Skip to content

Commit 4516911

Browse files
sezeryavuzclaude
andcommitted
Align SDK with backend: retire org 'member' role, default workflow visibility to organization, document SSE ownership 404s
Three backend changes now live on staging, folded straight into the unpublished 1.0.0 surface (no back-compat / SemVer handling, no version bump): 1. Org 'member' role retired — orgs are owner/admin only. Tighten the REQUEST side: organizations.invite() role default + type -> Literal["admin"]; and update_user_role() role -> Literal["admin"]. The backend now 422s role="member". Response/read models keep role: Optional[str] so historical 'member' strings still parse. The auth.organizations(role=...) GET filter stays str | None. 2. Workflow visibility is org-level — workflows.create() default flips from 'private' to 'organization' (a real client-side default change). 'private' no longer restricts a workflow to its creator; all four enum values (private|organization|public|system) are kept. List/filter params and the response visibility field are unchanged. 3. SSE / run-thread ownership now returns 404 (not found OR not owned by your org; identical 404, no existence leak). Infra already handles it: _streaming.py calls raise_for_status on the SSE connect, so listen() 404 -> NotFoundError (not StreamError, no reconnect loop). Docs-only additions on executions/composer/assistant listen|get_state|resume|cancel. Tests: invite defaults to/sends admin; update_user_role sends admin; create defaults to 'organization' and still accepts 'private'; SSE listen 404 -> NotFoundError with no reconnect loop (executions + composer). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e38500c commit 4516911

8 files changed

Lines changed: 140 additions & 13 deletions

File tree

src/modulex/resources/assistant.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ def listen(
8080
Event types are carried in ``event.event`` (normalized from ``data['type']``).
8181
A ``user_input_request`` event means the run paused for HITL input — answer
8282
it with :meth:`resume`.
83+
84+
A 404 (``NotFoundError``) on connect means the chat/run was not found or is
85+
not owned by your org — iterating the stream raises it rather than yielding
86+
events (no reconnect loop, no existence leak).
8387
"""
8488
return self._stream_sse(
8589
f"/assistant/chat/{chat_id}/listen/{run_id}",
@@ -99,6 +103,9 @@ async def resume(
99103
100104
``llm`` is required (the executor rebuilds the chat model on resume).
101105
Returns a NEW ``run_id``; re-subscribe with :meth:`listen` on that run.
106+
107+
A 404 (``NotFoundError``) means the chat was not found or is not owned by
108+
your org (identical 404 in both cases — no existence leak).
102109
"""
103110
body: dict[str, Any] = {
104111
"request_id": request_id,
@@ -120,7 +127,11 @@ async def status(self, chat_id: str, *, organization_id: str | None = None) -> A
120127
)
121128

122129
async def cancel(self, chat_id: str, *, organization_id: str | None = None) -> AssistantCancelResponse:
123-
"""Cancel a running assistant run (also clears any pending HITL question)."""
130+
"""Cancel a running assistant run (also clears any pending HITL question).
131+
132+
A 404 (``NotFoundError``) means the chat was not found or is not owned by
133+
your org (identical 404 in both cases — no existence leak).
134+
"""
124135
return AssistantCancelResponse.model_validate(
125136
await self._post(f"/assistant/chat/{chat_id}/cancel", organization_id=organization_id)
126137
)

src/modulex/resources/composer.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@ def listen(
9090
Event types are carried in ``event.event`` (normalized from ``data['type']``).
9191
A ``user_input_request`` event means the run paused for HITL input — answer
9292
it with :meth:`resume`. See :data:`modulex.types.realtime.COMPOSER_EVENT_TYPES`.
93+
94+
A 404 (``NotFoundError``) on connect means the chat/run was not found or is
95+
not owned by your org — iterating the stream raises it rather than yielding
96+
events (no reconnect loop, no existence leak).
9397
"""
9498
return self._stream_sse(
9599
f"/composer/chat/{composer_chat_id}/listen/{run_id}",
@@ -112,6 +116,9 @@ async def resume(
112116
``llm`` is required in production (the executor rebuilds the chat model on
113117
resume); pass the same config used in :meth:`chat`. Returns a NEW ``run_id``;
114118
re-subscribe with :meth:`listen` on that run.
119+
120+
A 404 (``NotFoundError``) means the chat was not found or is not owned by
121+
your org (identical 404 in both cases — no existence leak).
115122
"""
116123
body: dict[str, Any] = {
117124
"request_id": request_id,
@@ -221,7 +228,11 @@ async def cancel(
221228
*,
222229
organization_id: str | None = None,
223230
) -> ComposerCancelResponse:
224-
"""Cancel an in-progress composer chat run."""
231+
"""Cancel an in-progress composer chat run.
232+
233+
A 404 (``NotFoundError``) means the chat was not found or is not owned by
234+
your org (identical 404 in both cases — no existence leak).
235+
"""
225236
return ComposerCancelResponse.model_validate(
226237
await self._post(
227238
f"/composer/chat/{composer_chat_id}/cancel",

src/modulex/resources/executions.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,11 @@ async def run(
7171
)
7272

7373
async def get_state(self, thread_id: str, *, organization_id: str | None = None) -> StateResponse:
74-
"""Return the current state of a workflow thread."""
74+
"""Return the current state of a workflow thread.
75+
76+
A 404 (``NotFoundError``) means the thread was not found or is not owned by
77+
your org (identical 404 in both cases — no existence leak).
78+
"""
7579
return StateResponse.model_validate(
7680
await self._get(f"/workflows/state/{thread_id}", organization_id=organization_id)
7781
)
@@ -87,7 +91,11 @@ async def resume(
8791
stream: bool = True,
8892
organization_id: str | None = None,
8993
) -> ResumeResponse:
90-
"""Resume a paused workflow thread with the provided resume value."""
94+
"""Resume a paused workflow thread with the provided resume value.
95+
96+
A 404 (``NotFoundError``) means the thread was not found or is not owned by
97+
your org (identical 404 in both cases — no existence leak).
98+
"""
9199
body: dict[str, Any] = {
92100
"run_id": run_id,
93101
"resume_value": resume_value,
@@ -108,7 +116,11 @@ async def cancel(
108116
reason: str | None = None,
109117
organization_id: str | None = None,
110118
) -> CancelResponse:
111-
"""Cancel an in-progress workflow run by its run ID."""
119+
"""Cancel an in-progress workflow run by its run ID.
120+
121+
A 404 (``NotFoundError``) means the run was not found or is not owned by
122+
your org (identical 404 in both cases — no existence leak).
123+
"""
112124
body: dict[str, Any] = {}
113125
if reason is not None:
114126
body["reason"] = reason
@@ -125,6 +137,10 @@ def listen(self, run_id: str, *, organization_id: str | None = None) -> EventSou
125137
126138
Event types are carried in ``event.event`` (normalized from ``data['type']``).
127139
See :data:`modulex.types.realtime.WORKFLOW_EVENT_TYPES`.
140+
141+
A 404 (``NotFoundError``) on connect means the run was not found or is not
142+
owned by your org — iterating the stream raises it rather than yielding
143+
events (no reconnect loop, no existence leak).
128144
"""
129145
return self._stream_sse(f"/workflows/listen/{run_id}", organization_id=organization_id)
130146

src/modulex/resources/organizations.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
from __future__ import annotations
44

5-
from typing import Any
5+
from typing import Any, Literal
66

77
from modulex._base import _BaseResource
88
from modulex.types.organizations import (
@@ -37,11 +37,16 @@ async def invite(
3737
self,
3838
invited_email: str,
3939
*,
40-
role: str = "member",
40+
role: Literal["admin"] = "admin",
4141
invitation_message: str | None = None,
4242
organization_id: str | None = None,
4343
) -> InviteResponse:
44-
"""Send an invitation email to a user to join the organization."""
44+
"""Send an invitation email to a user to join the organization.
45+
46+
Orgs are owner/admin only: the creator is ``owner`` and every invited user
47+
is ``admin``. ``role`` must be ``"admin"`` (the default) — the ``"member"``
48+
role has been retired and the backend rejects it with HTTP 422.
49+
"""
4550
body: dict[str, Any] = {"invited_email": invited_email, "role": role}
4651
if invitation_message is not None:
4752
body["invitation_message"] = invitation_message
@@ -134,11 +139,15 @@ async def update_user_role(
134139
self,
135140
org_id: str,
136141
user_id: str,
137-
role: str,
142+
role: Literal["admin"],
138143
*,
139144
organization_id: str | None = None,
140145
) -> RoleUpdateResponse:
141-
"""Update the role of a user within an organization."""
146+
"""Update the role of a user within an organization.
147+
148+
``role`` must be ``"admin"`` — the ``"member"`` role has been retired (orgs
149+
are owner/admin only) and the backend rejects it with HTTP 422.
150+
"""
142151
return RoleUpdateResponse.model_validate(
143152
await self._put(
144153
f"/organizations/{org_id}/users/{user_id}/role",

src/modulex/resources/workflows.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,18 @@ async def create(
2828
tags: list[str] | None = None,
2929
category: str | None = None,
3030
status: str = "draft",
31-
visibility: str = "private",
31+
visibility: str = "organization",
3232
input: dict[str, Any] | None = None,
3333
config: dict[str, Any] | None = None,
3434
organization_id: str | None = None,
3535
) -> WorkflowResponse:
36-
"""Create a new workflow from the given schema and metadata."""
36+
"""Create a new workflow from the given schema and metadata.
37+
38+
``visibility`` defaults to ``"organization"`` (visible org-wide). The
39+
``"private"`` value no longer restricts a workflow to its creator — it now
40+
behaves like ``"organization"``. All four values remain accepted:
41+
``private | organization | public | system``.
42+
"""
3743
body: dict[str, Any] = {
3844
"workflow_schema": workflow_schema,
3945
"status": status,

tests/test_organizations.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,33 @@ async def test_set_composer_llm(self, client: Modulex, mock_api: respx.MockRoute
5252
"model_id": "gpt-4o-mini",
5353
"credential_id": "cred-1",
5454
}
55+
56+
57+
@pytest.mark.asyncio
58+
class TestOrganizationRoles:
59+
"""Org membership is owner/admin only — the ``member`` role was retired."""
60+
61+
async def test_invite_defaults_to_admin(self, client: Modulex, mock_api: respx.MockRouter) -> None:
62+
route = mock_api.post("/organizations/invite").mock(
63+
return_value=httpx.Response(200, json={"success": True, "invitation": {"id": "inv-1", "role": "admin"}})
64+
)
65+
await client.organizations.invite("newuser@example.com")
66+
sent = _json.loads(route.calls.last.request.content)
67+
assert sent["invited_email"] == "newuser@example.com"
68+
assert sent["role"] == "admin" # default is admin, never the retired "member"
69+
70+
async def test_invite_sends_admin_role(self, client: Modulex, mock_api: respx.MockRouter) -> None:
71+
route = mock_api.post("/organizations/invite").mock(return_value=httpx.Response(200, json={"success": True}))
72+
await client.organizations.invite("newuser@example.com", role="admin", invitation_message="welcome")
73+
sent = _json.loads(route.calls.last.request.content)
74+
assert sent["role"] == "admin"
75+
assert sent["invitation_message"] == "welcome"
76+
77+
async def test_update_user_role_sends_admin(self, client: Modulex, mock_api: respx.MockRouter) -> None:
78+
route = mock_api.put("/organizations/org-1/users/user-1/role").mock(
79+
return_value=httpx.Response(200, json={"success": True, "new_role": "admin"})
80+
)
81+
result = await client.organizations.update_user_role("org-1", "user-1", "admin")
82+
sent = _json.loads(route.calls.last.request.content)
83+
assert sent == {"role": "admin"}
84+
assert result["new_role"] == "admin"

tests/test_streaming.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import respx
88

99
from modulex import Modulex
10-
from modulex._exceptions import AuthenticationError
10+
from modulex._exceptions import AuthenticationError, NotFoundError
1111
from modulex._streaming import SSEEvent
1212

1313
_SSE_HEADERS = {"content-type": "text/event-stream"}
@@ -86,3 +86,25 @@ async def test_connect_error_is_typed(self, client: Modulex, mock_api: respx.Moc
8686
with pytest.raises(AuthenticationError):
8787
async for _ in client.executions.listen("run-1"):
8888
pass
89+
90+
async def test_listen_ownership_404_raises_not_found(self, client: Modulex, mock_api: respx.MockRouter) -> None:
91+
"""A run not owned by your org returns 404 on connect → NotFoundError (no hang, no reconnect)."""
92+
route = mock_api.get("/workflows/listen/run-x").mock(
93+
return_value=httpx.Response(404, json={"detail": "Not found"})
94+
)
95+
with pytest.raises(NotFoundError):
96+
async for _ in client.executions.listen("run-x"):
97+
pass
98+
assert route.call_count == 1 # NotFoundError, not a StreamError-driven reconnect loop
99+
100+
async def test_composer_listen_ownership_404_raises_not_found(
101+
self, client: Modulex, mock_api: respx.MockRouter
102+
) -> None:
103+
"""Same ownership 404 contract holds for the composer/assistant listen family."""
104+
route = mock_api.get("/composer/chat/chat-x/listen/run-x").mock(
105+
return_value=httpx.Response(404, json={"detail": "Not found"})
106+
)
107+
with pytest.raises(NotFoundError):
108+
async for _ in client.composer.listen("chat-x", "run-x"):
109+
pass
110+
assert route.call_count == 1

tests/test_workflows.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import json as _json
6+
57
import httpx
68
import pytest
79
import respx
@@ -80,6 +82,26 @@ async def test_create(self, client: Modulex, mock_api: respx.MockRouter) -> None
8082
)
8183
assert result["id"] == "wf-new"
8284

85+
async def test_create_defaults_visibility_to_organization(
86+
self, client: Modulex, mock_api: respx.MockRouter
87+
) -> None:
88+
"""New workflows now default to org-wide visibility (was ``private``)."""
89+
route = mock_api.post("/workflows").mock(
90+
return_value=httpx.Response(201, json={"id": "wf-new", "name": "New Workflow"})
91+
)
92+
await client.workflows.create(workflow_schema={"nodes": [], "edges": []})
93+
sent = _json.loads(route.calls.last.request.content)
94+
assert sent["visibility"] == "organization"
95+
96+
async def test_create_accepts_private_visibility(self, client: Modulex, mock_api: respx.MockRouter) -> None:
97+
"""``private`` is still a valid value (one of the four kept enum values)."""
98+
route = mock_api.post("/workflows").mock(
99+
return_value=httpx.Response(201, json={"id": "wf-priv", "name": "Private WF"})
100+
)
101+
await client.workflows.create(workflow_schema={"nodes": [], "edges": []}, visibility="private")
102+
sent = _json.loads(route.calls.last.request.content)
103+
assert sent["visibility"] == "private"
104+
83105
async def test_update(self, client: Modulex, mock_api: respx.MockRouter) -> None:
84106
mock_api.put("/workflows/wf-123").mock(
85107
return_value=httpx.Response(

0 commit comments

Comments
 (0)