-
-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat(sdk,core,webapp,react-hooks): named side channels on a Session #4815
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
ericallam
wants to merge
17
commits into
main
Choose a base branch
from
feature/tri-13516-session-side-channels-named-two-way-cross-run-realtime
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,633
−150
Open
Changes from all commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
946c900
feat(core,webapp): address session realtime streams by named channel
ericallam 25fcd1b
feat(sdk,core): session.channel() handle, channel spans, and native r…
ericallam dde4f17
test(core): session stream manager isolates named channels
ericallam d80c490
chore: changeset for session side channels
ericallam 86cf30f
chore(webapp): drop channel list-streams (unsupported on self-hosted …
ericallam 9b7be3d
feat(react-hooks): add useSessionStreamChannel for named session chan…
ericallam 5b1f61b
feat(sdk): chat.channel() and chat.session() for the current run's Se…
ericallam 436e455
refactor(sdk,core): sessions.defineChannel + typed channel handles
ericallam 225edfe
fix(react-hooks,sdk): review + CI fixes for session channels
ericallam 2f77568
fix(react-hooks): make useSessionStreamChannel sessionId optional for…
ericallam 5705e51
docs(ai-chat): document session side channels
ericallam b8dd764
docs(ai-chat): cover non-agent channel usage (task + backend)
ericallam 85912a3
chore(core): format subscribeToSessionStream channel URL line
ericallam d66a43b
chore: satisfy code-quality (oxlint no-duplicates, knip)
ericallam 3ba8508
feat(webapp): render session channel streams in the span inspector
ericallam b18ca54
fix(webapp): apply native retention to a channel's .in stream
ericallam f993d00
refactor(sdk,core,webapp): channels inherit basin retention
ericallam File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,16 @@ | ||
| --- | ||
| "@trigger.dev/react-hooks": patch | ||
| "@trigger.dev/core": patch | ||
| "@trigger.dev/sdk": patch | ||
| --- | ||
|
|
||
| Named side channels on a Session: durable, two-way realtime streams that outlive a single run and are shared across runs. Open a channel with `sessions.open(id).channel(name)` (or `chat.channel(name)` inside a `chat.agent`) to get an `.in`/`.out` pair addressed by name rather than the reserved default pair. Writing a side channel's `.in` does not wake or trigger a run, so a channel can carry out-of-band data (a stream of frames, a control signal) that many clients read while the agent produces it. | ||
|
|
||
| ```ts | ||
| // Inside a chat.agent: stream frames on a named channel, wakes nothing | ||
| const frames = chat.channel("screenshots"); | ||
| await frames.out.append(frame); | ||
| frames.in.on((control) => { /* client control, no suspend */ }); | ||
| ``` | ||
|
|
||
| Declare channel record types once with `sessions.defineChannel(...)` and infer them on both the producer and the consumer, including `useSessionStreamChannel` in React. Channels get a default retention that keeps them bounded, overridable per channel. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
145 changes: 145 additions & 0 deletions
145
apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.append.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,145 @@ | ||
| import { json } from "@remix-run/server-runtime"; | ||
| import { tryCatch } from "@trigger.dev/core/utils"; | ||
| import { nanoid } from "nanoid"; | ||
| import { z } from "zod"; | ||
| import { logger } from "~/services/logger.server"; | ||
| import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; | ||
| import { | ||
| SESSION_CHANNEL_NAME_REGEX, | ||
| sessionChannelResources, | ||
| } from "~/services/realtime/sessionChannels.server"; | ||
| import { | ||
| canonicalSessionAddressingKey, | ||
| resolveSessionWithWriterFallback, | ||
| } from "~/services/realtime/sessions.server"; | ||
| import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; | ||
| import { | ||
| claimSessionStreamPart, | ||
| releaseSessionStreamPart, | ||
| } from "~/services/sessionStreamWaitpointCache.server"; | ||
| import { anyResource, createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; | ||
| import { ServiceValidationError } from "~/v3/services/common.server"; | ||
|
|
||
| const ParamsSchema = z.object({ | ||
| session: z.string(), | ||
| channel: z.string().regex(SESSION_CHANNEL_NAME_REGEX), | ||
| io: z.enum(["out", "in"]), | ||
| }); | ||
|
|
||
| const MAX_APPEND_BODY_BYTES = 1024 * 1024; | ||
|
|
||
| const { action, loader } = createActionApiRoute( | ||
| { | ||
| params: ParamsSchema, | ||
| method: "POST", | ||
| maxContentLength: MAX_APPEND_BODY_BYTES, | ||
| allowJWT: true, | ||
| corsStrategy: "all", | ||
| findResource: async (params, auth) => | ||
| resolveSessionWithWriterFallback(auth.environment.id, params.session), | ||
| authorization: { | ||
| action: "write", | ||
| resource: (params, _s, _h, _b, session) => { | ||
| const ids = new Set<string>([params.session]); | ||
| if (session) { | ||
| ids.add(session.friendlyId); | ||
| if (session.externalId) ids.add(session.externalId); | ||
| } | ||
| return anyResource(sessionChannelResources(params.channel, ids)); | ||
| }, | ||
| }, | ||
| }, | ||
| async ({ request, params, authentication, resource: session }) => { | ||
| if (!session) { | ||
| return new Response("Session not found", { status: 404 }); | ||
| } | ||
|
|
||
| if (session.closedAt) { | ||
| return json({ ok: false, error: "Cannot append to a closed session" }, { status: 400 }); | ||
| } | ||
|
|
||
| if (session.expiresAt && session.expiresAt.getTime() < Date.now()) { | ||
| return json({ ok: false, error: "Cannot append to an expired session" }, { status: 400 }); | ||
| } | ||
|
|
||
| if (params.io === "out" && authentication.type !== "PRIVATE") { | ||
| return json( | ||
| { ok: false, error: "Appending to the out channel requires secret key authentication" }, | ||
| { status: 403 } | ||
| ); | ||
| } | ||
|
|
||
| const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { | ||
| session, | ||
| }); | ||
|
|
||
| if (!(realtimeStream instanceof S2RealtimeStreams)) { | ||
| return json( | ||
| { ok: false, error: "Session channels require the S2 realtime backend" }, | ||
| { status: 501 } | ||
| ); | ||
| } | ||
|
|
||
| const addressingKey = canonicalSessionAddressingKey(session, params.session); | ||
| const claimKey = `${addressingKey}:channels:${params.channel}`; | ||
|
|
||
| const part = await request.text(); | ||
|
|
||
| const clientPartId = request.headers.get("X-Part-Id"); | ||
| const partId = clientPartId ?? nanoid(7); | ||
|
|
||
| const wonClaim = clientPartId | ||
| ? await claimSessionStreamPart( | ||
| authentication.environment.id, | ||
| claimKey, | ||
| params.io, | ||
| clientPartId | ||
| ) | ||
| : true; | ||
|
|
||
| let appendSeq: number | undefined; | ||
| if (wonClaim) { | ||
| const [appendError, seq] = await tryCatch( | ||
| realtimeStream.appendPartToSessionStream( | ||
| part, | ||
| partId, | ||
| addressingKey, | ||
| params.io, | ||
| params.channel | ||
| ) | ||
| ); | ||
| appendSeq = seq ?? undefined; | ||
|
|
||
| if (appendError) { | ||
| if (clientPartId) { | ||
| await releaseSessionStreamPart( | ||
| authentication.environment.id, | ||
| claimKey, | ||
| params.io, | ||
| clientPartId | ||
| ); | ||
| } | ||
| if (appendError instanceof ServiceValidationError) { | ||
| return json( | ||
| { ok: false, error: appendError.message }, | ||
| { status: appendError.status ?? 422 } | ||
| ); | ||
| } | ||
| logger.error("Failed to append to session channel stream", { | ||
| sessionId: session.id, | ||
| io: params.io, | ||
| channel: params.channel, | ||
| error: appendError, | ||
| }); | ||
| return json( | ||
| { ok: false, error: "Something went wrong, please try again." }, | ||
| { status: 500 } | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| return json({ ok: true, seq: appendSeq }, { status: 200 }); | ||
| } | ||
| ); | ||
|
|
||
| export { action, loader }; | ||
81 changes: 81 additions & 0 deletions
81
apps/webapp/app/routes/realtime.v1.sessions.$session.channels.$channel.$io.records.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import { json } from "@remix-run/server-runtime"; | ||
| import { z } from "zod"; | ||
| import { $replica } from "~/db.server"; | ||
| import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; | ||
| import { | ||
| SESSION_CHANNEL_NAME_REGEX, | ||
| sessionChannelResources, | ||
| } from "~/services/realtime/sessionChannels.server"; | ||
| import { | ||
| canonicalSessionAddressingKey, | ||
| isSessionFriendlyIdForm, | ||
| resolveSessionByIdOrExternalId, | ||
| } from "~/services/realtime/sessions.server"; | ||
| import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; | ||
| import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; | ||
|
|
||
| const ParamsSchema = z.object({ | ||
| session: z.string(), | ||
| channel: z.string().regex(SESSION_CHANNEL_NAME_REGEX), | ||
| io: z.enum(["out", "in"]), | ||
| }); | ||
|
|
||
| const SearchSchema = z.object({ | ||
| afterEventId: z.string().regex(/^\d+$/).optional(), | ||
| }); | ||
|
|
||
| export const loader = createLoaderApiRoute( | ||
| { | ||
| params: ParamsSchema, | ||
| searchParams: SearchSchema, | ||
| allowJWT: true, | ||
| corsStrategy: "all", | ||
| findResource: async (params, auth) => { | ||
| const row = await resolveSessionByIdOrExternalId( | ||
| $replica, | ||
| auth.environment.id, | ||
| params.session | ||
| ); | ||
| if (!row && isSessionFriendlyIdForm(params.session)) { | ||
| return undefined; | ||
| } | ||
| return { | ||
| row, | ||
| addressingKey: canonicalSessionAddressingKey(row, params.session), | ||
| }; | ||
| }, | ||
| authorization: { | ||
| action: "read", | ||
| resource: ({ row, addressingKey }, params) => { | ||
| const ids = new Set<string>([addressingKey]); | ||
| if (row) { | ||
| ids.add(row.friendlyId); | ||
| if (row.externalId) ids.add(row.externalId); | ||
| } | ||
| return anyResource(sessionChannelResources(params.channel, ids)); | ||
| }, | ||
| }, | ||
| }, | ||
| async ({ params, authentication, resource, searchParams }) => { | ||
| const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { | ||
| session: resource.row, | ||
| organization: resource.row ? null : authentication.environment.organization, | ||
| }); | ||
|
|
||
| if (!(realtimeStream instanceof S2RealtimeStreams)) { | ||
| return new Response("Session channels require the S2 realtime backend", { status: 501 }); | ||
| } | ||
|
|
||
| const afterSeqNum = | ||
| searchParams.afterEventId !== undefined ? Number(searchParams.afterEventId) : undefined; | ||
|
|
||
| const records = await realtimeStream.readSessionStreamRecords( | ||
| resource.addressingKey, | ||
| params.io, | ||
| afterSeqNum, | ||
| params.channel | ||
| ); | ||
|
|
||
| return json({ records }); | ||
| } | ||
| ); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔍 Channel .in append skips run-wake and input sanitization
The channel append handler omits the
ensureRunForSessionanddrainSessionStreamWaitpointssteps of the reserved.inappend (the intended no-wake design) and also skipsstripClientWebhookActionSource. Side-channel.inrecords therefore bypass the webhook-source sanitization applied to reserved.in.Was this helpful? React with 👍 or 👎 to provide feedback.