feat(sdk): add idempotent workspace bootstrap helpers#111
feat(sdk): add idempotent workspace bootstrap helpers#111noodleonthecape wants to merge 2 commits intomainfrom
Conversation
|
Preview deployed!
This preview shares the staging database and will be cleaned up when the PR is merged or closed. Run E2E testsnpm run e2e -- https://pr111-api.relaycast.dev --ciOpen observer dashboard |
There was a problem hiding this comment.
Pull request overview
Adds SDK-level helpers to support idempotent “workspace bootstrap” flows across TypeScript, Rust, and Python, so callers can enable workspace streaming and ensure channels exist + are joined without duplicating startup conflict-handling logic.
Changes:
- TypeScript: add
workspace.stream.ensureEnabled()andAgentClient.channels.ensureJoined()plus tests. - Rust: add
ensure_workspace_stream_enabled()andensure_channel_joined()(with exported outcome type) plus tests. - Python: add
workspace.stream.ensure_enabled()(sync/async) andchannels.ensure_joined()(sync/async) plus tests and model support.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/sdk-typescript/src/relay.ts | Adds workspace.stream.ensureEnabled() helper (GET current config, PUT if disabled). |
| packages/sdk-typescript/src/agent.ts | Adds channels.ensureJoined() helper for create + join bootstrap flow. |
| packages/sdk-typescript/src/tests/relay.test.ts | Adds tests for workspace.stream.ensureEnabled(). |
| packages/sdk-typescript/src/tests/agent.test.ts | Adds tests for AgentClient.channels.ensureJoined(). |
| packages/sdk-rust/src/relay.rs | Adds ensure_workspace_stream_enabled() and associated unit tests. |
| packages/sdk-rust/src/lib.rs | Re-exports EnsureChannelJoinedOutcome. |
| packages/sdk-rust/src/agent.rs | Adds EnsureChannelJoinedOutcome, ensure_channel_joined(), and unit tests. |
| packages/sdk-python/tests/test_relay.py | Adds tests for workspace.stream.ensure_enabled(). |
| packages/sdk-python/tests/test_agent.py | Adds tests for channels.ensure_joined(). |
| packages/sdk-python/src/relay_sdk/relay.py | Adds workspace stream namespaces (sync + async) and wiring under workspace.stream. |
| packages/sdk-python/src/relay_sdk/models.py | Adds WorkspaceStreamConfig model. |
| packages/sdk-python/src/relay_sdk/agent.py | Adds channels.ensure_joined() (sync + async). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if (!(error instanceof Error) || !("statusCode" in error) || (error as { statusCode?: number }).statusCode !== 409) { | ||
| throw error; | ||
| } | ||
| } | ||
|
|
||
| try { | ||
| await this.client.post(`/v1/channels/${encodeURIComponent(name)}/join`); | ||
| joined = true; | ||
| } catch (error) { | ||
| if (!(error instanceof Error) || !("statusCode" in error) || (error as { statusCode?: number }).statusCode !== 409) { | ||
| throw error; | ||
| } | ||
| } |
There was a problem hiding this comment.
ensureJoined currently treats channel join conflicts (409) as the idempotency signal and sets joined = true on any 200 response. The server join endpoint is already idempotent and returns { already_member: boolean } with 200 (no 409), so this helper will report joined: true even when the agent was already a member. Consider using the join response’s alreadyMember field to set joined accurately, and drop/limit the 409 handling to the create step (or to specific API error codes).
| if (!(error instanceof Error) || !("statusCode" in error) || (error as { statusCode?: number }).statusCode !== 409) { | |
| throw error; | |
| } | |
| } | |
| try { | |
| await this.client.post(`/v1/channels/${encodeURIComponent(name)}/join`); | |
| joined = true; | |
| } catch (error) { | |
| if (!(error instanceof Error) || !("statusCode" in error) || (error as { statusCode?: number }).statusCode !== 409) { | |
| throw error; | |
| } | |
| } | |
| if ( | |
| !(error instanceof Error) || | |
| !('statusCode' in error) || | |
| (error as { statusCode?: number }).statusCode !== 409 | |
| ) { | |
| throw error; | |
| } | |
| } | |
| const joinResponse = await this.client.post<JoinChannelResponse>( | |
| `/v1/channels/${encodeURIComponent(name)}/join`, | |
| ); | |
| joined = !joinResponse.alreadyMember; |
| describe('workspace.stream.ensureEnabled', () => { | ||
| it('returns current config without PUT when already enabled', async () => { | ||
| global.fetch = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify({ | ||
| ok: true, | ||
| data: { enabled: true, default_enabled: true, override: true }, | ||
| }), { status: 200 })); | ||
|
|
||
| const { RelayCast } = await import('../relay.js'); | ||
| const relay = new RelayCast({ apiKey: 'rk_live_test' }); | ||
| const result = await relay.workspace.stream.ensureEnabled(); | ||
|
|
||
| expect(result.enabled).toBe(true); | ||
| expect(global.fetch).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('enables stream when disabled', async () => { | ||
| global.fetch = vi.fn() | ||
| .mockResolvedValueOnce(new Response(JSON.stringify({ | ||
| ok: true, | ||
| data: { enabled: false, default_enabled: false, override: null }, | ||
| }), { status: 200 })) | ||
| .mockResolvedValueOnce(new Response(JSON.stringify({ | ||
| ok: true, | ||
| data: { enabled: true, default_enabled: false, override: true }, | ||
| }), { status: 200 })); | ||
|
|
||
| const { RelayCast } = await import('../relay.js'); | ||
| const relay = new RelayCast({ apiKey: 'rk_live_test' }); | ||
| const result = await relay.workspace.stream.ensureEnabled(); | ||
|
|
||
| expect(result.enabled).toBe(true); | ||
| expect(global.fetch).toHaveBeenCalledTimes(2); | ||
| }); |
There was a problem hiding this comment.
These new tests overwrite global.fetch but never restore it, which can leak state into other test files running in the same worker and cause order-dependent failures. Add a beforeEach/afterEach (or afterAll) to save and restore the original global.fetch.
| /// Ensure a channel exists and that the current agent is joined to it. | ||
| /// | ||
| /// Conflict responses for both create and join are treated as successful | ||
| /// no-ops so callers can use this for idempotent startup/bootstrap flows. | ||
| pub async fn ensure_channel_joined( | ||
| &self, | ||
| request: CreateChannelRequest, | ||
| ) -> Result<EnsureChannelJoinedOutcome> { | ||
| let channel_name = request.name.clone(); | ||
|
|
||
| let created = match self.create_channel(request).await { | ||
| Ok(_) => true, | ||
| Err(error) if error.is_conflict() => false, | ||
| Err(error) => return Err(error), | ||
| }; | ||
|
|
||
| let joined = match self.join_channel(&channel_name).await { | ||
| Ok(_) => true, | ||
| Err(error) if error.is_conflict() => false, | ||
| Err(error) => return Err(error), | ||
| }; | ||
|
|
||
| Ok(EnsureChannelJoinedOutcome { created, joined }) | ||
| } |
There was a problem hiding this comment.
ensure_channel_joined marks joined = true for any successful join call, but the join endpoint is already idempotent and returns an already_member flag (200) rather than a 409 conflict. If the intent is to report whether a join actually occurred, parse the join response and set joined = !already_member (and consider updating the doc comment which currently describes 409 behavior for join).
| respx.post(f"{BASE}/v1/channels").mock(return_value=httpx.Response(409, json={"ok": False, "error": {"code": "channel_exists", "message": "exists"}})) | ||
| respx.post(f"{BASE}/v1/channels/general/join").mock(return_value=httpx.Response(409, json={"ok": False, "error": {"code": "already_joined", "message": "joined"}})) |
There was a problem hiding this comment.
These tests currently mock 409s with channel_exists / already_joined, but the server’s create conflict code is channel_already_exists and join is 200 with already_member. Adjusting the mocks/expectations will keep the tests aligned with real API behavior.
| respx.post(f"{BASE}/v1/channels").mock(return_value=httpx.Response(409, json={"ok": False, "error": {"code": "channel_exists", "message": "exists"}})) | |
| respx.post(f"{BASE}/v1/channels/general/join").mock(return_value=httpx.Response(409, json={"ok": False, "error": {"code": "already_joined", "message": "joined"}})) | |
| respx.post(f"{BASE}/v1/channels").mock(return_value=httpx.Response(409, json={"ok": False, "error": {"code": "channel_already_exists", "message": "exists"}})) | |
| respx.post(f"{BASE}/v1/channels/general/join").mock(return_value=httpx.Response(200, json={"ok": False, "error": {"code": "already_member", "message": "joined"}})) |
|
|
||
|
|
||
|
|
There was a problem hiding this comment.
This hunk introduces multiple blank lines (some with trailing whitespace) between test sections. Please remove the extra whitespace-only lines to keep diffs clean and avoid issues with whitespace-sensitive tooling.
| .mockResolvedValueOnce(new Response(JSON.stringify({ ok: false, error: { code: 'channel_exists', message: 'exists' } }), { status: 409 })) | ||
| .mockResolvedValueOnce(new Response(JSON.stringify({ ok: false, error: { code: 'already_joined', message: 'joined' } }), { status: 409 })); | ||
|
|
||
| const client = new AgentClient(new HttpClient({ apiKey: 'at_live_test', baseUrl: 'https://api.relaycast.dev' })); | ||
| await expect(client.channels.ensureJoined('general')).resolves.toEqual({ created: false, joined: false }); |
There was a problem hiding this comment.
These tests mock 409 responses with error codes channel_exists / already_joined, but the server uses channel_already_exists for create conflicts and the join endpoint returns 200 with already_member instead of 409. Updating the mocks/expectations to match the real API will make the test meaningful and prevent false confidence.
| .mockResolvedValueOnce(new Response(JSON.stringify({ ok: false, error: { code: 'channel_exists', message: 'exists' } }), { status: 409 })) | |
| .mockResolvedValueOnce(new Response(JSON.stringify({ ok: false, error: { code: 'already_joined', message: 'joined' } }), { status: 409 })); | |
| const client = new AgentClient(new HttpClient({ apiKey: 'at_live_test', baseUrl: 'https://api.relaycast.dev' })); | |
| await expect(client.channels.ensureJoined('general')).resolves.toEqual({ created: false, joined: false }); | |
| .mockResolvedValueOnce(new Response(JSON.stringify({ ok: false, error: { code: 'channel_already_exists', message: 'exists' } }), { status: 409 })) | |
| .mockResolvedValueOnce(new Response(JSON.stringify({ ok: false, error: { code: 'already_member', message: 'joined' } }), { status: 200 })); | |
| const client = new AgentClient(new HttpClient({ apiKey: 'at_live_test', baseUrl: 'https://api.relaycast.dev' })); | |
| await expect(client.channels.ensureJoined('general')).resolves.toEqual({ created: false, joined: true }); |
| #[tokio::test] | ||
| async fn ensure_channel_joined_treats_conflicts_as_success() { | ||
| let server = MockServer::start(); | ||
| let create = server.mock(|when: When, then: Then| { | ||
| when.method(POST).path("/v1/channels"); | ||
| then.status(409).json_body_obj(&serde_json::json!({ | ||
| "ok": false, | ||
| "error": {"code": "channel_exists", "message": "exists"} | ||
| })); | ||
| }); | ||
| let join = server.mock(|when: When, then: Then| { | ||
| when.method(POST).path("/v1/channels/general/join"); | ||
| then.status(409).json_body_obj(&serde_json::json!({ | ||
| "ok": false, | ||
| "error": {"code": "already_joined", "message": "joined"} | ||
| })); | ||
| }); | ||
|
|
||
| let agent = client(&server.base_url()); | ||
| let outcome = agent.ensure_channel_joined(CreateChannelRequest { | ||
| name: "general".into(), | ||
| topic: None, | ||
| metadata: None, | ||
| }).await.expect("ensure joined succeeds"); | ||
|
|
||
| assert_eq!(outcome, EnsureChannelJoinedOutcome { created: false, joined: false }); | ||
| create.assert(); | ||
| join.assert(); | ||
| } |
There was a problem hiding this comment.
These tests model channel create/join conflicts as 409 responses with codes channel_exists / already_joined. On the server, create conflicts are channel_already_exists, and join is 200 with already_member rather than 409. Aligning the mocks with the real API will ensure the test exercises the helper correctly.
| def ensure_joined(self, name: str, *, topic: str | None = None, metadata: dict[str, Any] | None = None) -> dict[str, bool]: | ||
| created = False | ||
| joined = False | ||
| try: | ||
| self._client.post("/v1/channels", CreateChannelRequest(name=name, topic=topic, metadata=metadata).model_dump(exclude_none=True)) | ||
| created = True | ||
| except Exception as err: | ||
| if not isinstance(err, Exception) or not getattr(err, 'status', None) == 409: | ||
| raise | ||
| try: | ||
| self._client.post(f"/v1/channels/{_enc(name)}/join") | ||
| joined = True | ||
| except Exception as err: | ||
| if not isinstance(err, Exception) or not getattr(err, 'status', None) == 409: | ||
| raise | ||
| return {"created": created, "joined": joined} |
There was a problem hiding this comment.
ensure_joined catches broad Exception and uses 409 as the join idempotency signal. The SDK’s HttpClient raises RelayError, and the join endpoint is already idempotent (200 with already_member). Consider catching RelayError specifically and using the join response’s already_member to compute whether a join actually happened (and remove the join-side 409 swallow).
| async def ensure_joined(self, name: str, *, topic: str | None = None, metadata: dict[str, Any] | None = None) -> dict[str, bool]: | ||
| created = False | ||
| joined = False | ||
| try: | ||
| await self._client.post("/v1/channels", CreateChannelRequest(name=name, topic=topic, metadata=metadata).model_dump(exclude_none=True)) | ||
| created = True | ||
| except Exception as err: | ||
| if not isinstance(err, Exception) or not getattr(err, 'status', None) == 409: | ||
| raise | ||
| try: | ||
| await self._client.post(f"/v1/channels/{_enc(name)}/join") | ||
| joined = True | ||
| except Exception as err: | ||
| if not isinstance(err, Exception) or not getattr(err, 'status', None) == 409: | ||
| raise | ||
| return {"created": created, "joined": joined} |
There was a problem hiding this comment.
Same issue for the async variant: it catches broad Exception and treats 409 as an expected join outcome, but join is 200 with already_member. Catch RelayError and use the response payload to determine whether the join was a no-op.
|
|
||
|
|
There was a problem hiding this comment.
This hunk adds extra blank/whitespace-only lines before the new test class. Consider removing them to keep formatting consistent and avoid whitespace-only diff noise.
Summary
ensure_workspace_stream_enabledin Rust /workspace.stream.ensureEnabled()in TypeScript /workspace.stream.ensure_enabled()in PythonWhy
Relay's broker was carrying SDK-shaped bootstrap logic locally:
That behavior belongs in the SDKs so relay and other clients can share it.
Validation
npm run build --workspace=@relaycast/typesnpx vitest run packages/sdk-typescript/src/__tests__/agent.test.ts packages/sdk-typescript/src/__tests__/relay.test.tspackages/sdk-pythonrequires Python >=3.10.cargois not installed on the host.