Skip to content

Commit db6228d

Browse files
authored
chore(webapp,core,sdk): upgrade @s2-dev/streamstore to 0.25 and migrate S2 hosts (#4349)
1 parent f9c8d51 commit db6228d

32 files changed

Lines changed: 3521 additions & 57 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/sdk": patch
3+
---
4+
5+
Fix a preloaded `chat.agent` run dropping an in-flight message when it retries after an out-of-memory error. The message being processed when the run hit the OOM is now recovered and re-run on the retry, instead of being skipped while the run waited for a new message.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/core": patch
3+
---
4+
5+
Fix a chunk occasionally dropped when a chat.agent run takes over from the warm first turn. The realtime stream writer now reports the inclusive last-written position as the resume cursor, so the agent's first record after the handover is no longer skipped.
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@trigger.dev/core": patch
3+
"@trigger.dev/sdk": patch
4+
"trigger.dev": patch
5+
---
6+
7+
`AgentChat.reconnect()` now settles promptly when reconnecting to an idle chat instead of holding the connection open for the full long-poll window. Also upgrades the S2 streamstore client to 0.25 and moves realtime streams to S2's current hosts.

.github/workflows/e2e-webapp.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ jobs:
1515
e2eTests:
1616
name: "🧪 E2E Tests: Webapp"
1717
runs-on: warp-ubuntu-latest-x64-16x
18-
timeout-minutes: 20
18+
timeout-minutes: 30
1919
env:
2020
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
2121
steps:
@@ -80,6 +80,8 @@ jobs:
8080
docker pull postgres:14
8181
docker pull redis:7.2
8282
docker pull testcontainers/ryuk:0.11.0
83+
docker pull ghcr.io/s2-streamstore/s2:0.40.0@sha256:b26249e2ede0949755f5af8028185dc2bcfc3aa2db21eb9610543d144eb6ee9d
84+
docker pull minio/minio:latest
8385
echo "Image pre-pull complete"
8486
8587
- name: 📥 Download deps
@@ -91,6 +93,9 @@ jobs:
9193
- name: 🏗️ Build Webapp
9294
run: pnpm run build --filter webapp
9395

96+
- name: 🎭 Install Playwright Chromium
97+
run: cd apps/webapp && pnpm exec playwright install chromium
98+
9499
- name: 🧪 Run Webapp E2E Tests
95100
run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.config.ts --reporter=default
96101
env:

apps/webapp/app/env.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2122,6 +2122,8 @@ const EnvironmentSchema = z
21222122
REALTIME_STREAMS_S2_BASIN: z.string().optional(),
21232123
REALTIME_STREAMS_S2_ACCESS_TOKEN: z.string().optional(),
21242124
REALTIME_STREAMS_S2_ENDPOINT: z.string().optional(),
2125+
REALTIME_STREAMS_S2_ACCOUNT_URL: z.string().default("https://a.s2.dev/v1"),
2126+
REALTIME_STREAMS_S2_BASIN_URL: z.string().default("https://{basin}.b.s2.dev/v1"),
21252127
REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS: z.enum(["true", "false"]).default("false"),
21262128
REALTIME_STREAMS_S2_ACCESS_TOKEN_EXPIRATION_IN_MS: z.coerce
21272129
.number()

apps/webapp/app/services/realtime/s2realtimeStreams.server.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export type S2RealtimeStreamsOptions = {
4444

4545
// Custom endpoint for s2-lite (self-hosted)
4646
endpoint?: string; // e.g., "http://localhost:4566/v1"
47+
/** Account-level API base for account/basin ops. Defaults to S2 cloud. */
48+
accountUrl?: string;
49+
/** Per-basin API base, with a `{basin}` placeholder. Defaults to S2 cloud. */
50+
basinUrl?: string;
4751

4852
// Skip access token issuance (s2-lite doesn't support /access-tokens)
4953
skipAccessTokens?: boolean;
@@ -74,6 +78,15 @@ export type S2RealtimeStreamsOptions = {
7478
const S2_TOKEN_OPS = ["append", "create-stream", "trim"] as const;
7579
const S2_TOKEN_OPS_FINGERPRINT = [...S2_TOKEN_OPS].sort().join(",");
7680

81+
/**
82+
* Placeholder handed back as the S2 access token when `skipAccessTokens` is set
83+
* and no token is configured (self-hosted s2-lite ignores the token entirely).
84+
* The SDK's session-stream writer rejects an empty access token as "no S2
85+
* credentials" and never opens the writer, so the token must be non-empty even
86+
* when it is semantically unused.
87+
*/
88+
const SKIP_ACCESS_TOKENS_SENTINEL = "s2-skip-access-tokens";
89+
7790
type S2IssueAccessTokenResponse = { access_token: string };
7891
type S2AppendInput = { records: { body: string }[] };
7992
type S2AppendAck = {
@@ -107,8 +120,10 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
107120

108121
constructor(opts: S2RealtimeStreamsOptions) {
109122
this.basin = opts.basin;
110-
this.baseUrl = opts.endpoint ?? `https://${this.basin}.b.aws.s2.dev/v1`;
111-
this.accountUrl = opts.endpoint ?? `https://aws.s2.dev/v1`;
123+
this.baseUrl =
124+
opts.endpoint ??
125+
(opts.basinUrl ?? `https://{basin}.b.s2.dev/v1`).replace("{basin}", this.basin);
126+
this.accountUrl = opts.endpoint ?? opts.accountUrl ?? `https://a.s2.dev/v1`;
112127
this.endpoint = opts.endpoint;
113128
this.token = opts.accessToken;
114129
this.streamPrefix = opts.streamPrefix ?? "";
@@ -168,7 +183,7 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
168183
relativeName: string
169184
): Promise<{ responseHeaders?: Record<string, string> }> {
170185
const accessToken = this.skipAccessTokens
171-
? this.token
186+
? this.token || SKIP_ACCESS_TOKENS_SENTINEL
172187
: await this.getS2AccessToken(randomUUID());
173188

174189
return {
@@ -178,7 +193,7 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor {
178193
"X-S2-Basin": this.basin,
179194
"X-S2-Flush-Interval-Ms": this.flushIntervalMs.toString(),
180195
"X-S2-Max-Retries": this.maxRetries.toString(),
181-
...(this.endpoint ? { "X-S2-Endpoint": this.endpoint } : {}),
196+
"X-S2-Endpoint": this.baseUrl,
182197
},
183198
};
184199
}

apps/webapp/app/services/realtime/streamBasinProvisioner.server.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ type CreateBasinOptions = {
185185
};
186186

187187
async function s2CreateBasin(name: string, opts: CreateBasinOptions): Promise<void> {
188-
const url = `https://aws.s2.dev/v1/basins`;
188+
const url = `${env.REALTIME_STREAMS_S2_ACCOUNT_URL}/basins`;
189189
const body = {
190190
basin: name,
191191
config: {
@@ -222,7 +222,7 @@ type ReconfigureBasinOptions = {
222222
};
223223

224224
async function s2ReconfigureBasin(name: string, opts: ReconfigureBasinOptions): Promise<void> {
225-
const url = `https://aws.s2.dev/v1/basins/${encodeURIComponent(name)}`;
225+
const url = `${env.REALTIME_STREAMS_S2_ACCOUNT_URL}/basins/${encodeURIComponent(name)}`;
226226
const body = {
227227
default_stream_config: {
228228
retention_policy: { age: parseDuration(opts.retentionPolicy) },

apps/webapp/app/services/realtime/v1StreamsGlobal.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ export function getRealtimeStreamInstance(
7070
basin: resolvedBasin,
7171
accessToken: env.REALTIME_STREAMS_S2_ACCESS_TOKEN ?? "",
7272
endpoint: env.REALTIME_STREAMS_S2_ENDPOINT,
73+
accountUrl: env.REALTIME_STREAMS_S2_ACCOUNT_URL,
74+
basinUrl: env.REALTIME_STREAMS_S2_BASIN_URL,
7375
skipAccessTokens: env.REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS === "true",
7476
streamPrefix: streamPrefixFor(environment, resolvedBasin),
7577
logLevel: env.REALTIME_STREAMS_S2_LOG_LEVEL,

apps/webapp/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@
104104
"@remix-run/react": "2.17.5",
105105
"@remix-run/router": "^1.23.3",
106106
"@remix-run/server-runtime": "2.17.5",
107-
"@s2-dev/streamstore": "^0.22.10",
107+
"@s2-dev/streamstore": "^0.25.0",
108108
"@sentry/remix": "9.46.0",
109109
"@slack/web-api": "7.16.0",
110110
"@socket.io/redis-adapter": "^8.3.0",
@@ -222,6 +222,7 @@
222222
"@internal/clickhouse": "workspace:*",
223223
"@internal/replication": "workspace:*",
224224
"@internal/testcontainers": "workspace:*",
225+
"@playwright/test": "^1.36.2",
225226
"@remix-run/dev": "2.17.5",
226227
"@remix-run/testing": "^2.17.5",
227228
"@sentry/cli": "2.50.2",
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import { apiClientManager, resourceCatalog } from "@trigger.dev/core/v3";
2+
import type { LocalsKey } from "@trigger.dev/core/v3";
3+
import type { LanguageModel } from "ai";
4+
import {
5+
installSessionWaitpointBackend,
6+
runInMockTaskContext,
7+
StandardSessionStreamManager,
8+
} from "@trigger.dev/core/v3/test";
9+
10+
export type RunRealChatAgentOptions = {
11+
agentId: string;
12+
baseUrl: string;
13+
addressingKey: string;
14+
/**
15+
* The environment secret key. The agent writes `.out` and reads `.in` as the
16+
* backend (PRIVATE auth) — the `.out` channel rejects client session tokens.
17+
*/
18+
secretKey: string;
19+
model: LanguageModel;
20+
modelLocal: LocalsKey<LanguageModel>;
21+
runId?: string;
22+
/**
23+
* Boot as a continuation of a previous run for the same session. Gates the
24+
* snapshot + `.out`/`.in` replay boot path, so the agent restores prior
25+
* history instead of treating the chat as brand new.
26+
*/
27+
continuation?: boolean;
28+
previousRunId?: string;
29+
/**
30+
* Idle window (seconds) before the turn loop falls through from the SSE
31+
* once() to the suspending `session.in.wait()`. Set this low to force the
32+
* suspend/resume path in a test.
33+
*/
34+
idleTimeoutInSeconds?: number;
35+
/**
36+
* `ctx.attempt.number`. A value greater than 1 makes the boot treat the run
37+
* as a retry (`couldHavePriorState`), restoring from the snapshot + `.in`
38+
* replay. Used to model an OOM retry re-dispatch.
39+
*/
40+
attemptNumber?: number;
41+
};
42+
43+
export type RunningAgent = {
44+
done: Promise<void>;
45+
close: () => Promise<void>;
46+
};
47+
48+
/**
49+
* Run the real `chat.agent` turn loop in-process, wired to a running webapp:
50+
* `apiClientManager` + a real `StandardSessionStreamManager` point the agent's
51+
* `.in`/`.out` at the webapp's Session streams (real S2 + SSE), the model is
52+
* injected via locals (so it survives without serialization), and turns are
53+
* driven by appending to `.in` over HTTP. Callers keep each message inside the
54+
* idle window and `close()` promptly so the run-engine suspend path is never
55+
* reached.
56+
*/
57+
export function runRealChatAgent(opts: RunRealChatAgentOptions): RunningAgent {
58+
apiClientManager.setGlobalAPIClientConfiguration({
59+
baseURL: opts.baseUrl,
60+
accessToken: opts.secretKey,
61+
});
62+
const apiClient = apiClientManager.clientOrThrow();
63+
const manager = new StandardSessionStreamManager(apiClient, opts.baseUrl);
64+
const { backend, runtimeManager, restore } = installSessionWaitpointBackend(apiClient);
65+
66+
const taskEntry = resourceCatalog.getTask(opts.agentId);
67+
if (!taskEntry) {
68+
restore();
69+
throw new Error(`runRealChatAgent: agent "${opts.agentId}" is not registered`);
70+
}
71+
const runFn = taskEntry.fns.run as (
72+
payload: unknown,
73+
params: { ctx: unknown; signal: AbortSignal }
74+
) => Promise<unknown>;
75+
76+
const runSignal = new AbortController();
77+
runSignal.signal.addEventListener("abort", () => {
78+
try {
79+
backend.disable();
80+
} catch {}
81+
});
82+
const runId = opts.runId ?? `run_${opts.addressingKey}`;
83+
84+
const idle =
85+
opts.idleTimeoutInSeconds !== undefined
86+
? { idleTimeoutInSeconds: opts.idleTimeoutInSeconds }
87+
: {};
88+
89+
const done = (
90+
runInMockTaskContext(
91+
async (drivers) => {
92+
drivers.locals.set(opts.modelLocal, opts.model);
93+
const payload = opts.continuation
94+
? {
95+
chatId: opts.addressingKey,
96+
continuation: true,
97+
metadata: {},
98+
...idle,
99+
...(opts.previousRunId ? { previousRunId: opts.previousRunId } : {}),
100+
}
101+
: { chatId: opts.addressingKey, trigger: "preload", metadata: {}, ...idle };
102+
await runFn(payload, { ctx: drivers.ctx, signal: runSignal.signal });
103+
},
104+
{
105+
ctx: {
106+
run: { id: runId },
107+
...(opts.attemptNumber !== undefined ? { attempt: { number: opts.attemptNumber } } : {}),
108+
},
109+
sessionStreamManager: manager,
110+
runtimeManager,
111+
}
112+
) as Promise<void>
113+
).finally(restore);
114+
115+
return {
116+
done,
117+
close: async () => {
118+
try {
119+
await fetch(
120+
`${opts.baseUrl}/realtime/v1/sessions/${encodeURIComponent(opts.addressingKey)}/in/append`,
121+
{
122+
method: "POST",
123+
headers: {
124+
Authorization: `Bearer ${opts.secretKey}`,
125+
"Content-Type": "application/json",
126+
"X-Part-Id": "close",
127+
},
128+
body: JSON.stringify({
129+
kind: "message",
130+
payload: { chatId: opts.addressingKey, trigger: "close" },
131+
}),
132+
}
133+
);
134+
} catch {}
135+
runSignal.abort();
136+
await done.catch(() => {});
137+
},
138+
};
139+
}
140+
141+
export type ChatAgentSessionOptions = Omit<
142+
RunRealChatAgentOptions,
143+
"runId" | "continuation" | "previousRunId"
144+
>;
145+
146+
export type ChatAgentSession = {
147+
/** How many runs the session has spawned so far (1 fresh + N continuations). */
148+
runCount: () => number;
149+
/** Close the currently-active run and stop spawning continuations. */
150+
close: () => Promise<void>;
151+
};
152+
153+
/**
154+
* A session-scoped orchestrator that stands in for the run-engine's run
155+
* lifecycle: it starts a run, and whenever that run exits on its own
156+
* (`chat.endRun()` / `chat.requestUpgrade()`), spawns the next run as a
157+
* continuation (new run id, `continuation: true`, `previousRunId` threaded)
158+
* for the same session. That mirrors the server triggering a fresh run on the
159+
* next append after the previous run went terminal, and lets each continuation
160+
* restore prior history from the persisted snapshot. Runs never overlap: the
161+
* next spawn is chained on the previous run's `done` (after its manager
162+
* teardown), so the process-global managers are never installed twice at once.
163+
*/
164+
export function runChatAgentSession(opts: ChatAgentSessionOptions): ChatAgentSession {
165+
let closed = false;
166+
let index = 0;
167+
let current: RunningAgent | undefined;
168+
let previousRunId: string | undefined;
169+
170+
const spawn = () => {
171+
index += 1;
172+
const runId = `run_${opts.addressingKey}_${index}`;
173+
current = runRealChatAgent({
174+
...opts,
175+
runId,
176+
continuation: index > 1,
177+
previousRunId,
178+
});
179+
previousRunId = runId;
180+
const settle = () => {
181+
if (!closed) {
182+
spawn();
183+
}
184+
};
185+
current.done.then(settle, settle);
186+
};
187+
188+
spawn();
189+
190+
return {
191+
runCount: () => index,
192+
close: async () => {
193+
closed = true;
194+
await current?.close();
195+
},
196+
};
197+
}

0 commit comments

Comments
 (0)