Skip to content

feat(sdk): add idempotent workspace bootstrap helpers#111

Open
noodleonthecape wants to merge 2 commits intomainfrom
noodle/relaycast-sdk-bootstrap
Open

feat(sdk): add idempotent workspace bootstrap helpers#111
noodleonthecape wants to merge 2 commits intomainfrom
noodle/relaycast-sdk-bootstrap

Conversation

@noodleonthecape
Copy link
Copy Markdown

Summary

  • add reusable Relaycast SDK helpers for idempotent workspace bootstrap flows
  • expose ensure_workspace_stream_enabled in Rust / workspace.stream.ensureEnabled() in TypeScript / workspace.stream.ensure_enabled() in Python
  • expose idempotent channel ensure-join helpers in Rust / TypeScript / Python so callers no longer need to duplicate 409-handling startup logic

Why

Relay's broker was carrying SDK-shaped bootstrap logic locally:

  • ensure workspace stream is enabled before opening the websocket
  • create default/extra channels if missing
  • join those channels, treating conflict responses as harmless no-ops

That behavior belongs in the SDKs so relay and other clients can share it.

Validation

  • npm run build --workspace=@relaycast/types
  • npx vitest run packages/sdk-typescript/src/__tests__/agent.test.ts packages/sdk-typescript/src/__tests__/relay.test.ts
  • Python tests could not be executed in this environment because the available interpreter is Python 3.8.9 while packages/sdk-python requires Python >=3.10.
  • Rust tests could not be executed in this environment because cargo is not installed on the host.

@github-actions
Copy link
Copy Markdown

Preview deployed!

Environment URL
API https://pr111-api.relaycast.dev
Health https://pr111-api.relaycast.dev/health
Observer https://pr111-observer.relaycast.dev/observer

This preview shares the staging database and will be cleaned up when the PR is merged or closed.

Run E2E tests

npm run e2e -- https://pr111-api.relaycast.dev --ci

Open observer dashboard

https://pr111-observer.relaycast.dev/observer

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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() and AgentClient.channels.ensureJoined() plus tests.
  • Rust: add ensure_workspace_stream_enabled() and ensure_channel_joined() (with exported outcome type) plus tests.
  • Python: add workspace.stream.ensure_enabled() (sync/async) and channels.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.

Comment on lines +453 to +465
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;
}
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
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;

Copilot uses AI. Check for mistakes.
Comment on lines +1144 to +1176
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);
});
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +587 to +610
/// 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 })
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +629 to +630
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"}}))
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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"}}))

Copilot uses AI. Check for mistakes.
Comment on lines +247 to +249



Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change

Copilot uses AI. Check for mistakes.
Comment on lines +27 to +31
.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 });
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
.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 });

Copilot uses AI. Check for mistakes.
Comment on lines +930 to +958
#[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();
}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +123 to +138
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}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copilot uses AI. Check for mistakes.
Comment on lines +457 to +472
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}
Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +617 to +618


Copy link

Copilot AI Mar 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants