Merge branch 'main' into mollifier-phase-2
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Add `ai.toolExecute(task)` so you can wire a Trigger subtask in as the `execute` handler of an AI SDK `tool()` while defining `description` and `inputSchema` yourself — useful when you want full control over the tool surface and just need Trigger's subtask machinery for the body.
|
||||
|
||||
```ts
|
||||
const myTool = tool({
|
||||
description: "...",
|
||||
inputSchema: z.object({ ... }),
|
||||
execute: ai.toolExecute(mySubtask),
|
||||
});
|
||||
```
|
||||
|
||||
`ai.tool(task)` (`toolFromTask`) keeps doing the all-in-one wrap and now aligns its return type with AI SDK's `ToolSet`. Minimum `ai` peer raised to `^6.0.116` to avoid cross-version `ToolSet` mismatches in monorepos.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fix `LocalsKey<T>` type incompatibility across dual-package builds. The phantom value-type brand no longer uses a module-level `unique symbol`, so a single TypeScript compilation that resolves the type from both the ESM and CJS outputs (which can happen under certain pnpm hoisting layouts) no longer sees two structurally-incompatible variants of the same type.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
The CLI MCP server's agent-chat tools (`start_agent_chat`, `send_agent_message`, `close_agent_chat`) now run on the new Sessions primitive, so AI assistants driving a `chat.agent` get the same idempotent-by-`chatId`, durable-across-runs behavior the browser transport gets. Required PAT scopes go from `write:inputStreams` to `read:sessions` + `write:sessions`.
|
||||
@@ -0,0 +1,287 @@
|
||||
# Review guide — chat.agent on Sessions, row-agnostic addressing
|
||||
|
||||
Scope: the 12 uncommitted files. **No new behaviour beyond the public surface
|
||||
already on this branch** — this is plumbing cleanup that:
|
||||
|
||||
1. Eliminates the transport's session-creation step
|
||||
2. Makes `chatId` the universal addressing string everywhere
|
||||
3. Makes the server-side stream/append/wait routes row-agnostic
|
||||
|
||||
## The two design moves
|
||||
|
||||
**Move 1 — agent owns session lifecycle.** `chat.agent` and
|
||||
`chat.customAgent` upsert the backing `Session` row at bind, fire-and-forget,
|
||||
keyed on `externalId = payload.chatId`. The transport, server-side
|
||||
`AgentChat`, and `chat.createTriggerAction` no longer create sessions at all.
|
||||
Browsers cannot mint sessions either (`POST /api/v1/sessions` is now
|
||||
secret-key-only). One owner, one path.
|
||||
|
||||
**Move 2 — `chatId` is the only address.** The transport, server-side
|
||||
`AgentChat`, JWT scopes, and S2 stream paths all use `chatId` directly. The
|
||||
Session's friendlyId is informational. To make this safe, the three stream
|
||||
routes (`.in/.out` PUT, GET, POST append, plus the run-engine `wait`
|
||||
endpoint) became "row-optional" and derive a *canonical addressing key*
|
||||
(`row.externalId ?? row.friendlyId`, fallback to the URL param when the row
|
||||
hasn't been upserted yet). Same canonical key is used to build the S2 stream
|
||||
path, the waitpoint cache key, and the JWT resource set — so any caller
|
||||
addressing by either form converges on the same physical stream.
|
||||
|
||||
Together these remove an entire class of "did the row land yet?" races. The
|
||||
transport can subscribe to `/sessions/{chatId}/out` before the agent boots,
|
||||
the agent's `void sessions.create({externalId: chatId})` lands a moment
|
||||
later, and any earlier reads/writes are already on the right S2 key.
|
||||
|
||||
---
|
||||
|
||||
## Read in this order
|
||||
|
||||
### 1. `apps/webapp/app/services/realtime/sessions.server.ts` (+34 lines)
|
||||
|
||||
The new primitive. Two helpers:
|
||||
|
||||
- `isSessionFriendlyIdForm(value)` — `value.startsWith("session_")`. Used to
|
||||
decide whether a missing row is a hard 404 (opaque friendlyId) or a soft
|
||||
"row will land later" (externalId form).
|
||||
- `canonicalSessionAddressingKey(row, paramSession)` — `row.externalId ??
|
||||
row.friendlyId` if the row exists, else `paramSession`. **This is the load-
|
||||
bearing function.** Read its docstring.
|
||||
|
||||
**Question to ask:** can two callers addressing the "same" session ever get
|
||||
different canonical keys? Only if the row exists for one and not the other,
|
||||
*and* the URL forms differ — but in that case the row-less caller used the
|
||||
externalId form (friendlyId-form would have 404'd earlier), and the row-ful
|
||||
caller computes `row.externalId ?? row.friendlyId`. If the row's externalId
|
||||
matches the URL, they converge. If it doesn't, there's no row to find by
|
||||
that string anyway. The interesting edge is "row exists with no externalId",
|
||||
addressed via friendlyId — both sides read `row.friendlyId`. ✓
|
||||
|
||||
### 2. `apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts` (+47/-12)
|
||||
|
||||
PUT initialize + GET subscribe (SSE). Both use the helper. The interesting
|
||||
part is the loader's `findResource` + `authorization.resource`:
|
||||
|
||||
```ts
|
||||
findResource: async (params, auth) => {
|
||||
const row = await resolveSessionByIdOrExternalId(...);
|
||||
if (!row && isSessionFriendlyIdForm(params.session)) return undefined; // 404
|
||||
return { row, addressingKey: canonicalSessionAddressingKey(row, params.session) };
|
||||
},
|
||||
authorization: {
|
||||
resource: ({ row, addressingKey }) => {
|
||||
const ids = new Set<string>([addressingKey]);
|
||||
if (row) {
|
||||
ids.add(row.friendlyId);
|
||||
if (row.externalId) ids.add(row.externalId);
|
||||
}
|
||||
return { sessions: [...ids] };
|
||||
},
|
||||
superScopes: ["read:sessions", "read:all", "admin"],
|
||||
},
|
||||
```
|
||||
|
||||
**Why three IDs in the resource set?** `checkAuthorization` is "any-match"
|
||||
across the resource values. We want a JWT scoped to *either* form to
|
||||
authorize *either* URL form. Smoke test verified the 4-cell matrix passes.
|
||||
|
||||
**The PUT path** (action handler) is simpler — it just resolves the row,
|
||||
builds an addressing key, and hands it to `initializeSessionStream`. Worth
|
||||
noting the `closedAt` check is now `maybeSession?.closedAt` — no row means
|
||||
no closedAt to enforce.
|
||||
|
||||
### 3. `apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts` (+22/-13)
|
||||
|
||||
POST append (browser writes a record to `.in` or server writes to `.out`).
|
||||
Same row-optional pattern. Both the S2 append and the waitpoint drain use
|
||||
`addressingKey`.
|
||||
|
||||
**Question to ask:** what fires the waitpoint? An agent's
|
||||
`session.in.wait()` registers a waitpoint keyed on `(addressingKey, io)` via
|
||||
the wait endpoint (file 4). The append handler drains by the *same* key —
|
||||
even if the agent registered with externalId form and the transport
|
||||
appended via friendlyId form, both compute the same canonical key, so they
|
||||
converge. ✓
|
||||
|
||||
### 4. `apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts` (+18/-13)
|
||||
|
||||
The agent's `.in.wait()` endpoint. Run-engine creates the waitpoint, then
|
||||
registers it in Redis under `(addressingKey, io)`. The race-check that runs
|
||||
right after creation reads from S2 by the same key. Three call sites —
|
||||
`addSessionStreamWaitpoint`, `readSessionStreamRecords`,
|
||||
`removeSessionStreamWaitpoint` — all consistent.
|
||||
|
||||
### 5. `apps/webapp/app/routes/api.v1.sessions.ts` (+4/-2)
|
||||
|
||||
**Security tightening.** Removed `allowJWT: true` and `corsStrategy: "all"`
|
||||
from the `POST /api/v1/sessions` action — secret-key only now.
|
||||
|
||||
**Question to ask:** was the JWT path actually used? Until this branch, the
|
||||
transport called it via `ensureSession` (now deleted). After this branch,
|
||||
nobody reaches it from the browser. `chat.createTriggerAction` (server
|
||||
secret key) is the only browser-adjacent path.
|
||||
|
||||
### 6. `packages/trigger-sdk/src/v3/ai.ts` (+62/-39)
|
||||
|
||||
Two near-identical edits — one in `chatAgent`, one in `chatCustomAgent`.
|
||||
Both bind on `payload.chatId` and fire-and-forget the upsert:
|
||||
|
||||
```ts
|
||||
locals.set(chatSessionHandleKey, sessions.open(payload.chatId));
|
||||
void sessions
|
||||
.create({ type: "chat.agent", externalId: payload.chatId })
|
||||
.catch(() => { /* best effort */ });
|
||||
```
|
||||
|
||||
**Question to ask:** why `void`-and-`catch`? Awaiting the upsert would gate
|
||||
the agent's bind on a network round-trip that doesn't unblock anything
|
||||
user-visible — `.in/.out` routes are row-agnostic and the waitpoint cache
|
||||
is keyed on the addressing string, not the row id. If the upsert genuinely
|
||||
fails, the next bind retries the same idempotent call (`sessions.create`
|
||||
upserts on `externalId`, so concurrent triggers on one chatId converge to
|
||||
one row). The row matters for downstream metadata + listing, not for live
|
||||
addressing.
|
||||
|
||||
The PAT scope minting in `chatAgent` (two call sites — preload and
|
||||
sendMessage) now uses `payload.chatId` for the `sessions:` resource. That
|
||||
matches what the transport/AgentChat use as the JWT resource and what the
|
||||
JWT's resource set in the loader includes. Cross-form addressing works
|
||||
either way (smoke-tested), but using `chatId` keeps the chain tight.
|
||||
|
||||
`createChatTriggerAction` is the most visibly trimmed: no pre-create, no
|
||||
threading `sessionId` into payload, scope mint uses `chatId`. Return type
|
||||
no longer carries `sessionId` — note `TriggerChatTaskResult.sessionId` was
|
||||
already declared optional, so this isn't a public-API break.
|
||||
|
||||
**Stale docstring to flag:** `chat.ts:59` and `chat.ts:112` still describe
|
||||
PAT scopes as `read:sessions:{sessionId}` and
|
||||
`write:sessions:{sessionId}`. Functionally either ID works (row lookup
|
||||
canonicalises), but the doc text is now out of date — it should say
|
||||
`{chatId}`. Worth a tidy-up before merge but not blocking.
|
||||
|
||||
### 7. `packages/trigger-sdk/src/v3/chat.ts` (+63/-117)
|
||||
|
||||
**The biggest mechanical edit.** Net -54 lines from deleting `ensureSession`
|
||||
and untangling its callers.
|
||||
|
||||
What disappeared:
|
||||
- `private async ensureSession(chatId)` — gone
|
||||
- The "lazy upsert from the browser if no triggerTask callback" branch in
|
||||
`sendMessages` and `preload` — gone
|
||||
- The "throw if neither path surfaced a sessionId" guard — gone
|
||||
- All `state.sessionId` URL params replaced with `chatId`
|
||||
- `subscribeToSessionStream`'s `chatId?` (optional) is now `chatId` (required)
|
||||
|
||||
What stayed:
|
||||
- `state.sessionId` in `ChatSessionState` — optional, informational
|
||||
- The `restore from external storage` branch in the constructor still
|
||||
hydrates `sessionId` if persisted, just doesn't *require* it
|
||||
- `notifySessionChange` still surfaces `sessionId` if known
|
||||
|
||||
**Question to ask:** does the transport ever still need the friendlyId? The
|
||||
only place is the `onSessionChange` callback's payload (so consumers
|
||||
persisting state can save it for later display). The transport itself never
|
||||
puts it in a URL or a waitpoint key.
|
||||
|
||||
The `sendMessages` path is worth re-reading: when state.runId is set, it
|
||||
appends to `.in/append` and subscribes to `.out`. If the append fails with
|
||||
a non-auth error, it falls through to triggering a new run (legacy "run is
|
||||
dead" detection — unchanged from pre-Sessions, doesn't depend on
|
||||
addressing).
|
||||
|
||||
### 8. `packages/trigger-sdk/src/v3/chat-client.ts` (+34/-33)
|
||||
|
||||
Server-side `AgentChat`. Mirrors the transport changes — every URL uses
|
||||
`this.chatId`. `triggerNewRun` no longer pre-creates a session. `ChatSession`
|
||||
and internal `SessionState` types now have optional `sessionId`.
|
||||
|
||||
The shape of the diff is identical to the transport: delete the upsert,
|
||||
swap addressing identifiers, optionalise the friendlyId. If you've read
|
||||
`chat.ts` carefully, this one is mostly mechanical confirmation that both
|
||||
client surfaces (browser transport + server-side AgentChat) speak the same
|
||||
addressing protocol.
|
||||
|
||||
### 9. Test infrastructure — `sessions.ts` (+18) + `mock-chat-agent.ts` (+25)
|
||||
|
||||
`__setSessionCreateImplForTests` mirrors the existing
|
||||
`__setSessionOpenImplForTests`. `mockChatAgent` installs a no-op create stub
|
||||
returning a synthetic `CreatedSessionResponseBody` so the agent's bind-time
|
||||
`void sessions.create(...)` doesn't try to hit a real API. Cleanup runs in
|
||||
the same `.finally` as the open override.
|
||||
|
||||
**Question to ask:** is the synthetic response shape correct? It mirrors
|
||||
`CreatedSessionResponseBody` — `id`, `externalId`, `type`, `tags`,
|
||||
`metadata`, `closedAt`, `closedReason`, `expiresAt`, `createdAt`,
|
||||
`updatedAt`, `isCached`. Tests don't currently assert on this object, so
|
||||
the bar is "doesn't crash + matches the type". Met.
|
||||
|
||||
### 10. `packages/trigger-sdk/src/v3/chat.test.ts` (+13/-12)
|
||||
|
||||
Three classes of test edits, all consequences:
|
||||
|
||||
- Stream URL assertion: `chat-1` (the chatId) instead of
|
||||
`session_streamurl` (the friendlyId)
|
||||
- `renewRunAccessToken` callback: `sessionId: undefined` (was
|
||||
`DEFAULT_SESSION_ID` because the mocked trigger doesn't surface it)
|
||||
- Token resolve count: `1` (was `2` — second resolve was for `ensureSession`)
|
||||
- One `onSessionChange` matchObject loses `sessionId`
|
||||
|
||||
### 11. `apps/webapp/app/routes/_app.../playground/.../route.tsx` (1 line)
|
||||
|
||||
`sessionId: string` → `sessionId?: string` in the playground sidebar prop
|
||||
to track the transport type change.
|
||||
|
||||
---
|
||||
|
||||
## Edge cases I checked, so you don't have to
|
||||
|
||||
- **Cross-form JWT auth (curl matrix).** JWT scoped to externalId can call
|
||||
externalId URL ✓ and friendlyId URL ✓. JWT scoped to friendlyId can call
|
||||
externalId URL ✓ and friendlyId URL ✓. Smoke-tested.
|
||||
- **Row materialises after subscribe.** Transport opens
|
||||
`GET /sessions/{chatId}/out` before agent's bind upsert lands → 200 OK,
|
||||
`addressingKey = chatId` (paramSession fallback). Once the row lands
|
||||
with `externalId = chatId`, addressingKey resolves to the same value via
|
||||
`row.externalId`. Same S2 key throughout.
|
||||
- **Concurrent triggers on one chatId.** Two browser tabs trigger two runs
|
||||
→ two binds → two `sessions.create({externalId: chatId})` calls. Upsert
|
||||
semantics: both return the same row.
|
||||
- **Closed session enforcement.** Still enforced when a row exists.
|
||||
`maybeSession?.closedAt` is null-safe; no row = no close-state to honour.
|
||||
- **Agent run cancellation.** Frontend doesn't auto-detect — unchanged from
|
||||
pre-Sessions; messages sit in S2 until the next trigger (the existing
|
||||
run-PAT auth-error path is the only reaper). Out of scope for this branch.
|
||||
- **Idle timeout in dev.** Runs stay `EXECUTING_WITH_WAITPOINTS` past the
|
||||
configured idle because dev runs don't snapshot/restore; the in-process
|
||||
idle clock advances locally without touching the row. Expected, not a
|
||||
regression.
|
||||
|
||||
## Things explicitly **not** in this branch
|
||||
|
||||
- Run-state subscription on the transport side (the "run died, re-trigger
|
||||
silently" UX gap)
|
||||
- Session auto-close on agent exit (still client-driven by design)
|
||||
- Any change to `Session` schema, `sessions.create` semantics, or
|
||||
`chatAccessTokenTTL`
|
||||
- Docstring updates for `read:sessions:{sessionId}` / `write:sessions:{sessionId}`
|
||||
in `chat.ts:59` and `chat.ts:112` (functional but textually stale —
|
||||
follow-up nit)
|
||||
|
||||
---
|
||||
|
||||
## What I'd be ready to answer cold
|
||||
|
||||
- Why fire-and-forget upsert (vs. `await`) in the agent's bind step
|
||||
- Why the route's authorization resource set has three IDs (cross-form JWT
|
||||
auth)
|
||||
- Why `POST /api/v1/sessions` lost `allowJWT` (security tightening — no
|
||||
caller needs it after the transport's `ensureSession` is gone)
|
||||
- What converges two callers using different URL forms onto the same S2
|
||||
stream (`canonicalSessionAddressingKey`, identical computation on both
|
||||
sides for any given row)
|
||||
- What makes `sessions.create` race-safe under concurrent triggers
|
||||
(`externalId` upsert)
|
||||
- Why `state.sessionId` stayed on `ChatSessionState` at all (pure
|
||||
informational, surfaced via `onSessionChange` for consumer persistence;
|
||||
zero addressing role)
|
||||
- Why the chat-client (server-side AgentChat) and chat (transport) edits
|
||||
look near-identical (they implement the same client protocol against the
|
||||
same row-agnostic routes)
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
paths:
|
||||
- "**/package.json"
|
||||
---
|
||||
|
||||
# Installing Packages
|
||||
|
||||
When adding a new dependency to any package.json in the monorepo:
|
||||
|
||||
1. **Look up the latest version** on npm before adding:
|
||||
```bash
|
||||
pnpm view <package-name> version
|
||||
```
|
||||
If unsure which version to use (e.g. major version compatibility), confirm with the user.
|
||||
|
||||
2. **Edit the package.json directly** — do NOT use `pnpm add` as it can cause issues in the monorepo. Add the dependency with the correct version range (typically `^x.y.z`).
|
||||
|
||||
3. **Run `pnpm i` from the repo root** after editing to install and update the lockfile:
|
||||
```bash
|
||||
pnpm i
|
||||
```
|
||||
Always run from the repo root, not from the package directory.
|
||||
@@ -34,6 +34,9 @@ jobs:
|
||||
- '!.changeset/**'
|
||||
- '!hosting/**'
|
||||
- '!.github/**'
|
||||
- '!references/**'
|
||||
- '!**/*.md'
|
||||
- '!**/.env.example'
|
||||
- '.github/workflows/pr_checks.yml'
|
||||
- '.github/workflows/typecheck.yml'
|
||||
webapp:
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
area: webapp
|
||||
type: improvement
|
||||
---
|
||||
|
||||
Migrate the dashboard Agent tab (span inspector) to subscribe to the backing Session's `.out` and `.in` channels instead of the run-scoped chat output + chat-messages input streams. Pairs with the SDK + MCP migrations on the ai-chat branch.
|
||||
|
||||
- `SpanPresenter.server.ts` extracts `agentSession` from the run payload (prefers `sessionId`, falls back to `chatId` for pre-Sessions agent runs — matches `resolveSessionByIdOrExternalId`).
|
||||
- Span route threads `agentSession` through `AgentViewAuth` and gates `agentView` creation on having one.
|
||||
- New dashboard resource route `resources.orgs.../runs.$runParam/realtime/v1/sessions/$sessionId/$io` proxies `S2RealtimeStreams.streamResponseFromSessionStream` under dashboard session auth. The run param binds resource hierarchy; the session identity is verified against the environment.
|
||||
- `AgentView.tsx` subscribes to `/out` and `/in` URLs, drops local `CHAT_STREAM_KEY`/`CHAT_MESSAGES_STREAM_ID` constants, and parses the `.in` stream as `ChatInputChunk` (`{kind: "message", payload}` for user turns; `{kind: "stop"}` ignored). Output-stream parsing is unchanged — session v2 SSE already delivers UIMessageChunk objects from `record.body.data`.
|
||||
- Smoke: opened a prior `test-agent` run in the dashboard, Agent tab rendered user + assistant messages end-to-end with zero console errors. Both SSE endpoints (`/out`, `/in`) returned 200.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: improvement
|
||||
---
|
||||
|
||||
Downgrade the "Google auth conflict" log from `error` to `warn`. This branch handles an expected user-state mismatch (Google ID belongs to one user, email is on another) by returning the existing auth user — there's no exception to chase, so it shouldn't page on the Sentry error channel.
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Playground action now forwards `maxDuration`, `version` (as `lockToVersion`), and `region` from the sidebar form into the Session's `triggerConfig`. Previously the form fields rendered as working controls but were silently dropped (`void`-suppressed) because `SessionTriggerConfig` didn't accept them — runs ignored the user's max duration, version pin, and region selection. With the schema extended in core, the playground now plumbs them through to `ensureRunForSession`.
|
||||
|
||||
Also fixes stale `clientData` in the playground transport: the JSON editor's value was captured at construction and never updated, so per-turn `metadata` merges used the original value across the whole conversation. Added a `useEffect` that calls `transport.setClientData(...)` whenever `clientDataJson` changes.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
Add an Agent view to the run details page for runs whose `taskKind` annotation is `AGENT`. The view renders the agent's `UIMessage` conversation by subscribing to the backing Session's `.out` and `.in` channels — the same data source as the Agent Playground content view. Switching is via a `Trace view` / `Agent view` segmented control above the run body, and the selected view is reflected in the URL via `?view=agent` so it's shareable.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: improvement
|
||||
---
|
||||
|
||||
Upgrade streamdown from v1.4.0 to v2.5.0. Custom Shiki syntax highlighting theme matching our CodeMirror dark theme colors. Consolidate duplicated lazy StreamdownRenderer into a shared component.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: improvement
|
||||
---
|
||||
|
||||
Cache task defaults in Redis so the trigger API skips per-request database lookups, restoring the fast trigger path when callers pass queue and TTL options.
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
AdjustmentsHorizontalIcon,
|
||||
ArrowPathRoundedSquareIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
ArrowsRightLeftIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
ClockIcon,
|
||||
Cog8ToothIcon,
|
||||
CogIcon,
|
||||
CpuChipIcon,
|
||||
CubeIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FolderIcon,
|
||||
@@ -69,7 +71,9 @@ import {
|
||||
organizationTeamPath,
|
||||
queryPath,
|
||||
regionsPath,
|
||||
v3AgentsPath,
|
||||
v3ApiKeysPath,
|
||||
v3PlaygroundPath,
|
||||
v3BatchesPath,
|
||||
v3BillingPath,
|
||||
v3BuiltInDashboardPath,
|
||||
@@ -88,6 +92,7 @@ import {
|
||||
v3QueuesPath,
|
||||
v3RunsPath,
|
||||
v3SchedulesPath,
|
||||
v3SessionsPath,
|
||||
v3TestPath,
|
||||
v3UsagePath,
|
||||
v3WaitpointTokensPath,
|
||||
@@ -467,6 +472,31 @@ export function SideMenu({
|
||||
initialCollapsed={getSectionCollapsed(user.dashboardPreferences.sideMenu, "ai")}
|
||||
onCollapseToggle={handleSectionToggle("ai")}
|
||||
>
|
||||
<SideMenuItem
|
||||
name="Agents"
|
||||
icon={CpuChipIcon}
|
||||
activeIconColor="text-indigo-500"
|
||||
inactiveIconColor="text-indigo-500"
|
||||
to={v3AgentsPath(organization, project, environment)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Sessions"
|
||||
icon={ArrowsRightLeftIcon}
|
||||
activeIconColor="text-teal-500"
|
||||
inactiveIconColor="text-teal-500"
|
||||
to={v3SessionsPath(organization, project, environment)}
|
||||
data-action="sessions"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Playground"
|
||||
icon={BeakerIcon}
|
||||
activeIconColor="text-indigo-400"
|
||||
inactiveIconColor="text-indigo-400"
|
||||
to={v3PlaygroundPath(organization, project, environment)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Prompts"
|
||||
icon={AIPromptsIcon}
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { memo } from "react";
|
||||
import {
|
||||
AssistantResponse,
|
||||
ChatBubble,
|
||||
ToolUseRow,
|
||||
} from "~/components/runs/v3/ai/AIChatMessages";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AgentMessageView — renders an AI SDK UIMessage[] conversation.
|
||||
//
|
||||
// Extracted from the playground route so it can be reused on the run details
|
||||
// page when the user picks the Agent view.
|
||||
//
|
||||
// UIMessage part types (AI SDK):
|
||||
// text — markdown text content
|
||||
// reasoning — model reasoning/thinking
|
||||
// tool-{name} — tool call with input/output/state
|
||||
// source-url — citation link
|
||||
// source-document — citation document reference
|
||||
// file — file attachment (image, etc.)
|
||||
// step-start — visual separator between steps
|
||||
// data-{name} — custom data parts (rendered as a small popover)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AgentMessageView({ messages }: { messages: UIMessage[] }) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[800px] flex-col gap-2">
|
||||
{messages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Memoized so stable messages (anything older than the one currently
|
||||
// streaming) don't re-render on every chunk. This matters a lot during
|
||||
// `resumeStream()` history replay, where each re-render would otherwise
|
||||
// re-run Prism highlighting on every tool-call CodeBlock in the list.
|
||||
//
|
||||
// Default shallow prop comparison is fine: AI SDK's useChat keeps stable
|
||||
// references for messages that haven't changed, so only the last message
|
||||
// (the one receiving new chunks) re-renders.
|
||||
export const MessageBubble = memo(function MessageBubble({
|
||||
message,
|
||||
}: {
|
||||
message: UIMessage;
|
||||
}) {
|
||||
if (message.role === "user") {
|
||||
const text =
|
||||
message.parts
|
||||
?.filter((p) => p.type === "text")
|
||||
.map((p) => (p as { type: "text"; text: string }).text)
|
||||
.join("") ?? "";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 justify-end">
|
||||
<div className="max-w-[80%] rounded-lg bg-indigo-600 px-4 py-2.5 text-sm text-white">
|
||||
<div className="whitespace-pre-wrap [overflow-wrap:anywhere]">{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const hasContent = message.parts && message.parts.length > 0;
|
||||
if (!hasContent) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{message.parts?.map((part, i) => renderPart(part, i))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
export function renderPart(part: UIMessage["parts"][number], i: number) {
|
||||
const p = part as any;
|
||||
const type = part.type as string;
|
||||
|
||||
// Text — markdown rendered via AssistantResponse
|
||||
if (type === "text") {
|
||||
return p.text ? <AssistantResponse key={i} text={p.text} headerLabel="" /> : null;
|
||||
}
|
||||
|
||||
// Reasoning — amber-bordered italic block
|
||||
if (type === "reasoning") {
|
||||
return (
|
||||
<div key={i} className="border-l-2 border-amber-500/40 pl-2">
|
||||
<ChatBubble>
|
||||
<div className="whitespace-pre-wrap text-xs italic text-amber-200/70">
|
||||
{p.text ?? ""}
|
||||
</div>
|
||||
</ChatBubble>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tool call — type: "tool-{name}" with toolCallId, input, output, state
|
||||
if (type.startsWith("tool-")) {
|
||||
const toolName = type.slice(5);
|
||||
|
||||
// Sub-agent tool: output is a UIMessage with parts
|
||||
const isSubAgent =
|
||||
p.output != null && typeof p.output === "object" && Array.isArray(p.output.parts);
|
||||
|
||||
// For sub-agent tools, show the last text part as the "output" tab
|
||||
// (mirrors what toModelOutput typically sends to the parent LLM)
|
||||
// instead of dumping the full UIMessage JSON.
|
||||
let resultOutput: string | undefined;
|
||||
if (isSubAgent) {
|
||||
const lastText = (p.output.parts as any[])
|
||||
.filter((part: any) => part.type === "text" && part.text)
|
||||
.pop();
|
||||
resultOutput = lastText?.text ?? undefined;
|
||||
} else if (p.output != null) {
|
||||
resultOutput =
|
||||
typeof p.output === "string" ? p.output : JSON.stringify(p.output, null, 2);
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolUseRow
|
||||
key={i}
|
||||
tool={{
|
||||
toolCallId: p.toolCallId ?? `tool-${i}`,
|
||||
toolName,
|
||||
inputJson: JSON.stringify(p.input ?? {}, null, 2),
|
||||
resultOutput,
|
||||
resultSummary:
|
||||
p.state === "input-streaming" || p.state === "input-available"
|
||||
? "calling..."
|
||||
: p.state === "output-error"
|
||||
? `error: ${p.errorText ?? "unknown"}`
|
||||
: undefined,
|
||||
subAgent: isSubAgent
|
||||
? {
|
||||
parts: p.output.parts,
|
||||
isStreaming: p.state === "output-available" && p.preliminary === true,
|
||||
}
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Source URL — clickable citation link
|
||||
if (type === "source-url") {
|
||||
return (
|
||||
<div key={i} className="text-xs">
|
||||
<a
|
||||
href={p.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-400 underline hover:text-indigo-300"
|
||||
>
|
||||
{p.title || p.url}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Source document — citation label
|
||||
if (type === "source-document") {
|
||||
return (
|
||||
<div key={i} className="text-xs text-text-dimmed">
|
||||
{p.title}
|
||||
{p.mediaType ? ` (${p.mediaType})` : ""}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// File — render as image if image type, otherwise as download link
|
||||
if (type === "file") {
|
||||
const isImage = typeof p.mediaType === "string" && p.mediaType.startsWith("image/");
|
||||
if (isImage) {
|
||||
return (
|
||||
<img
|
||||
key={i}
|
||||
src={p.url}
|
||||
alt={p.filename ?? "file"}
|
||||
className="max-h-64 rounded border border-charcoal-650"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={i} className="text-xs">
|
||||
<a
|
||||
href={p.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-indigo-400 underline hover:text-indigo-300"
|
||||
>
|
||||
{p.filename ?? "Download file"}
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Step start — subtle dashed separator with centered label
|
||||
if (type === "step-start") {
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-2 py-0.5">
|
||||
<div className="flex-1 border-t border-dashed border-charcoal-650" />
|
||||
<span className="text-[10px] text-charcoal-500">step</span>
|
||||
<div className="flex-1 border-t border-dashed border-charcoal-650" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Data parts — type: "data-{name}", show as labeled JSON popover
|
||||
if (type.startsWith("data-")) {
|
||||
const dataName = type.slice(5);
|
||||
return <DataPartPopover key={i} name={dataName} data={p.data} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function DataPartPopover({ name, data }: { name: string; data: unknown }) {
|
||||
const formatted = JSON.stringify(data, null, 2);
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 rounded border border-charcoal-650 bg-charcoal-800 px-1.5 py-0.5 font-mono text-[10px] text-text-dimmed transition-colors hover:border-charcoal-500 hover:text-text-bright"
|
||||
>
|
||||
<span className="text-purple-400">{name}</span>
|
||||
<span className="text-charcoal-500">{"{}"}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto max-w-md p-0" align="start" sideOffset={4}>
|
||||
<div className="flex items-center justify-between border-b border-charcoal-650 px-2.5 py-1.5">
|
||||
<span className="text-[10px] font-medium text-text-dimmed">data-{name}</span>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<pre className="p-2.5 text-[11px] leading-relaxed text-text-bright">{formatted}</pre>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { SSEStreamSubscription } from "@trigger.dev/core/v3";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { AgentMessageView } from "~/components/runs/v3/agent/AgentMessageView";
|
||||
import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
|
||||
export type AgentViewAuth = {
|
||||
publicAccessToken: string;
|
||||
apiOrigin: string;
|
||||
/**
|
||||
* Session identifier the AgentView uses to address the backing
|
||||
* {@link Session} when subscribing to `.in` / `.out`. Accepts either
|
||||
* a `session_*` friendlyId or the transport-supplied externalId
|
||||
* (typically the browser's `chatId`) — the dashboard resource route
|
||||
* resolves either form via `resolveSessionByIdOrExternalId`.
|
||||
*/
|
||||
sessionId: string;
|
||||
/**
|
||||
* User messages extracted from the run's task payload at load time.
|
||||
* Empty array for runs started with `trigger: "preload"` — in that
|
||||
* case the first user message arrives over the session's `.in`
|
||||
* channel and is merged in by the AgentView subscription.
|
||||
*/
|
||||
initialMessages: UIMessage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Max state-update interval while assistant chunks are streaming. Matches
|
||||
* the `experimental_throttle: 100` we previously passed to `useChat`.
|
||||
* Chunks mutate a staging ref synchronously; a throttled flush copies the
|
||||
* ref into React state at most ~10x/sec so tool-call Prism highlighting
|
||||
* etc. doesn't re-run on every single text-delta.
|
||||
*/
|
||||
const STATE_FLUSH_THROTTLE_MS = 100;
|
||||
|
||||
/**
|
||||
* Sentinel timestamp for messages that came from the run's initial task
|
||||
* payload — they predate any stream activity, so 0 guarantees they sort
|
||||
* first regardless of stream race order.
|
||||
*/
|
||||
const INITIAL_PAYLOAD_TIMESTAMP = 0;
|
||||
|
||||
/**
|
||||
* Renders a Session's chat conversation as it unfolds.
|
||||
*
|
||||
* Subscribes to both channels of the {@link Session}:
|
||||
* - **`.out`** delivers assistant `UIMessageChunk`s (text deltas, tool
|
||||
* calls, reasoning, etc.) produced by the agent's
|
||||
* `chatStream.writer(...)` calls — objects, already parsed by the S2
|
||||
* SSE reader.
|
||||
* - **`.in`** delivers {@link ChatInputChunk}s sent by
|
||||
* {@link TriggerChatTransport} (or any other session writer). Each
|
||||
* chunk is a tagged union (`{kind: "message", payload}` for user
|
||||
* turns, `{kind: "stop"}` for stop signals) — the AgentView only
|
||||
* cares about `kind: "message"` and pulls `.payload.messages`.
|
||||
*
|
||||
* Both streams are read directly via `SSEStreamSubscription` through the
|
||||
* dashboard's session-authed resource routes — not through `useChat` or
|
||||
* `TriggerChatTransport`. This gives us per-chunk server-side timestamps
|
||||
* (S2 sequence numbers) from both streams, which we use to produce a
|
||||
* chronologically correct merged message list that works for replays,
|
||||
* multi-message turns, cross-run session resumes, and steering messages.
|
||||
*
|
||||
* Intended to be mounted inside a scrollable container — the component
|
||||
* does not own its own scrollbar.
|
||||
*/
|
||||
export function AgentView({ agentView }: { agentView: AgentViewAuth }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const messages = useAgentSessionMessages({
|
||||
sessionId: agentView.sessionId,
|
||||
apiOrigin: agentView.apiOrigin,
|
||||
orgSlug: organization.slug,
|
||||
projectSlug: project.slug,
|
||||
envSlug: environment.slug,
|
||||
initialMessages: agentView.initialMessages,
|
||||
});
|
||||
|
||||
// Sticky-bottom auto-scroll: walks up to find the inspector's scroll
|
||||
// container, then scrolls to bottom whenever `messages` changes — but
|
||||
// only if the user was at (or near) the bottom at the time. Scrolling
|
||||
// away pauses auto-scroll; scrolling back resumes it.
|
||||
const rootRef = useAutoScrollToBottom([messages]);
|
||||
|
||||
return (
|
||||
<div ref={rootRef} className="py-3">
|
||||
{messages.length === 0 ? (
|
||||
<div className="flex h-full min-h-[12rem] items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Spinner className="size-5" color="muted" />
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Loading conversation…
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AgentMessageView messages={messages} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useAgentSessionMessages — reads both realtime streams for a session and
|
||||
// maintains a chronologically ordered, merged message list.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Shape of each chunk on the session's `.in` channel. Mirrors the
|
||||
* `ChatInputChunk` tagged union produced by {@link TriggerChatTransport}:
|
||||
* - `kind: "message"` carries a `ChatTaskWirePayload` in `.payload`
|
||||
* (user-submitted messages or regenerate calls); we dedupe by id.
|
||||
* - `kind: "stop"` is a stop signal — no messages, nothing to render
|
||||
* here, so it's filtered.
|
||||
*
|
||||
* The server wraps records in `{data, id}` and writes `data` as a JSON
|
||||
* string; SSE v2 delivers the parsed string back. {@link parseChunkPayload}
|
||||
* re-parses to recover the object.
|
||||
*/
|
||||
type InputStreamChunk = {
|
||||
kind?: "message" | "stop";
|
||||
payload?: {
|
||||
messages?: Array<{ id?: string; role?: string; parts?: unknown[] }>;
|
||||
trigger?: string;
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Minimal typing for the chunks we care about on the chat output stream.
|
||||
* Covers the AI SDK `UIMessageChunk` variants that `renderPart` actually
|
||||
* knows how to display, plus the Trigger.dev control chunks that we filter.
|
||||
*/
|
||||
type OutputChunk = { type: string; [key: string]: unknown };
|
||||
|
||||
/**
|
||||
* Per-message orchestration state for the output stream accumulator. Mirrors
|
||||
* the active-part tracking that AI SDK's `processUIMessageStream` keeps in
|
||||
* its `state` object: a registry of streaming text/reasoning parts so deltas
|
||||
* can be matched to the right part by id, plus a way to clear them at step
|
||||
* boundaries (`finish-step`) so the next step's `text-start`/`reasoning-start`
|
||||
* with the same id starts a fresh part instead of appending to the previous
|
||||
* step's part.
|
||||
*/
|
||||
/**
|
||||
* Per-message orchestration state — index-based active-part tracking.
|
||||
*
|
||||
* Each map points from a part id (text or reasoning) to **the index of the
|
||||
* currently-streaming part with that id in `message.parts`**. We need
|
||||
* indexes (not just a `Set` of "active ids") because part ids are *only
|
||||
* unique within a step*: the SDK happily reuses `text-start id="0"` after
|
||||
* a `finish-step` boundary. Without index tracking, a `text-delta` for the
|
||||
* reused id would have to find the right part by id alone — and a search
|
||||
* would match BOTH the previous step's frozen part and the current step's
|
||||
* fresh one, which produces a duplication where the previous text gets
|
||||
* the new content appended to it AND a fresh part with the same content
|
||||
* also appears.
|
||||
*
|
||||
* Mirrors AI SDK's `processUIMessageStream`'s `state.activeTextParts` /
|
||||
* `state.activeReasoningParts` (which hold direct references in the
|
||||
* mutating canonical impl). We use indexes here because we do immutable
|
||||
* updates and need indices that survive `parts.map()` rewrites — adding
|
||||
* new parts and updating existing ones never reorders, so an index is
|
||||
* stable for the lifetime of the part.
|
||||
*/
|
||||
type MessageOrchestrationState = {
|
||||
activeTextPartIndexes: Map<string, number>;
|
||||
activeReasoningPartIndexes: Map<string, number>;
|
||||
};
|
||||
|
||||
/**
|
||||
* `SSEStreamSubscription`'s v2 batch path delivers `parsedBody.data` as-is
|
||||
* — but session channels diverge by direction:
|
||||
*
|
||||
* - `.in`: {@link TriggerChatTransport.serializeInputChunk} writes the
|
||||
* `ChatInputChunk` as a JSON **string**, so `data` is a string that
|
||||
* needs a second `JSON.parse` to recover the tagged union.
|
||||
* - `.out`: the agent's `chatStream.writer(...)` writes
|
||||
* {@link UIMessageChunk} **objects** directly; `data` arrives
|
||||
* already-parsed.
|
||||
*
|
||||
* This helper accepts both shapes defensively: a string is parsed; an
|
||||
* object is returned as-is. Returns `null` for unparseable payloads.
|
||||
*/
|
||||
function parseChunkPayload(raw: unknown): Record<string, unknown> | null {
|
||||
if (raw == null) return null;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (typeof raw === "object") return raw as Record<string, unknown>;
|
||||
return null;
|
||||
}
|
||||
|
||||
function createOrchestrationState(): MessageOrchestrationState {
|
||||
return {
|
||||
activeTextPartIndexes: new Map(),
|
||||
activeReasoningPartIndexes: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function useAgentSessionMessages({
|
||||
sessionId,
|
||||
apiOrigin,
|
||||
orgSlug,
|
||||
projectSlug,
|
||||
envSlug,
|
||||
initialMessages,
|
||||
}: {
|
||||
sessionId: string;
|
||||
apiOrigin: string;
|
||||
orgSlug: string;
|
||||
projectSlug: string;
|
||||
envSlug: string;
|
||||
initialMessages: UIMessage[];
|
||||
}): UIMessage[] {
|
||||
// Seed with the user messages from the run's task payload.
|
||||
const seedMessages = useMemo(
|
||||
() => initialMessages.filter((m) => m.role === "user"),
|
||||
[initialMessages]
|
||||
);
|
||||
|
||||
// `pendingRef` is the authoritative, eagerly-updated message state:
|
||||
// chunks mutate this synchronously as they arrive. A throttled flush
|
||||
// copies it into React state so UI updates are capped at ~10x/sec.
|
||||
const pendingRef = useRef<Map<string, UIMessage>>(
|
||||
new Map(seedMessages.map((m) => [m.id, m]))
|
||||
);
|
||||
const timestampsRef = useRef<Map<string, number>>(
|
||||
new Map(seedMessages.map((m) => [m.id, INITIAL_PAYLOAD_TIMESTAMP]))
|
||||
);
|
||||
// Side-table of orchestration state, keyed by assistant message id. Lives
|
||||
// outside the UIMessage so React doesn't see it as a renderable prop.
|
||||
const orchestrationRef = useRef<Map<string, MessageOrchestrationState>>(new Map());
|
||||
|
||||
// React state snapshot of pendingRef. Only updated via the throttled
|
||||
// `scheduleFlush`. The Map *reference* changes on every flush so React
|
||||
// detects the state update and the downstream `useMemo` recomputes.
|
||||
const [messagesById, setMessagesById] = useState<Map<string, UIMessage>>(
|
||||
() => new Map(pendingRef.current)
|
||||
);
|
||||
|
||||
// Throttled flush scheduler — leading edge within a single throttle
|
||||
// window: the first chunk after a quiet period flushes immediately, then
|
||||
// subsequent chunks coalesce until the next window opens.
|
||||
const lastFlushAtRef = useRef<number>(0);
|
||||
const pendingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const scheduleFlush = useRef<() => void>(() => {});
|
||||
scheduleFlush.current = () => {
|
||||
if (pendingTimerRef.current !== null) return; // already scheduled
|
||||
const now = Date.now();
|
||||
const sinceLast = now - lastFlushAtRef.current;
|
||||
const delay = Math.max(0, STATE_FLUSH_THROTTLE_MS - sinceLast);
|
||||
pendingTimerRef.current = setTimeout(() => {
|
||||
pendingTimerRef.current = null;
|
||||
lastFlushAtRef.current = Date.now();
|
||||
setMessagesById(new Map(pendingRef.current));
|
||||
}, delay);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
|
||||
const encodedSession = encodeURIComponent(sessionId);
|
||||
// Always use the page's own origin to avoid CORS preflight failures
|
||||
// when the configured `apiOrigin` (e.g. `localhost`) differs from the
|
||||
// origin the dashboard was loaded from (e.g. `127.0.0.1`). The dashboard
|
||||
// resource route is same-origin by construction.
|
||||
const origin = typeof window !== "undefined" ? window.location.origin : apiOrigin;
|
||||
const sessionBase =
|
||||
`${origin}/resources/orgs/${orgSlug}/projects/${projectSlug}/env/${envSlug}` +
|
||||
`/sessions/${encodedSession}/realtime/v1`;
|
||||
|
||||
const outputUrl = `${sessionBase}/out`;
|
||||
const inputUrl = `${sessionBase}/in`;
|
||||
|
||||
const commonSubOptions = {
|
||||
signal: abort.signal,
|
||||
timeoutInSeconds: 120,
|
||||
} as const;
|
||||
|
||||
// ---- Output stream: assistant messages ---------------------------------
|
||||
//
|
||||
// The output stream delivers UIMessageChunks interleaved with
|
||||
// Trigger-specific control chunks (`trigger:turn-complete`, etc.). We
|
||||
// filter the control chunks and fold everything else into an assistant
|
||||
// `UIMessage` via our own `applyOutputChunk` accumulator — the AI SDK's
|
||||
// `readUIMessageStream` helper is only available in `ai@6`, and the
|
||||
// webapp is pinned to `ai@4`, so we re-implement just the chunk types
|
||||
// that `renderPart` actually displays.
|
||||
//
|
||||
// We capture the **server timestamp of each assistant message's first
|
||||
// `start` chunk** so later sort-by-timestamp merges with the input
|
||||
// stream correctly.
|
||||
const runOutput = async () => {
|
||||
try {
|
||||
const sub = new SSEStreamSubscription(outputUrl, commonSubOptions);
|
||||
const raw = await sub.subscribe();
|
||||
const reader = raw.getReader();
|
||||
|
||||
let currentMessageId: string | null = null;
|
||||
|
||||
try {
|
||||
while (!abort.signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
|
||||
const chunk = parseChunkPayload(value.chunk) as OutputChunk | null;
|
||||
if (!chunk || typeof chunk.type !== "string") continue;
|
||||
if (chunk.type.startsWith("trigger:")) continue;
|
||||
|
||||
if (chunk.type === "start") {
|
||||
const messageId =
|
||||
typeof chunk.messageId === "string" && chunk.messageId.length > 0
|
||||
? chunk.messageId
|
||||
: `asst-${crypto.randomUUID()}`;
|
||||
currentMessageId = messageId;
|
||||
|
||||
if (!timestampsRef.current.has(messageId)) {
|
||||
timestampsRef.current.set(messageId, value.timestamp);
|
||||
}
|
||||
|
||||
const existing = pendingRef.current.get(messageId);
|
||||
if (existing) {
|
||||
// Same message id seen again — merge metadata only, keep
|
||||
// existing parts (canonical `processUIMessageStream` does
|
||||
// the same on a repeated `start`).
|
||||
if (chunk.messageMetadata != null) {
|
||||
pendingRef.current.set(messageId, {
|
||||
...existing,
|
||||
metadata: {
|
||||
...((existing as { metadata?: Record<string, unknown> }).metadata ?? {}),
|
||||
...(chunk.messageMetadata as Record<string, unknown>),
|
||||
},
|
||||
} as UIMessage);
|
||||
scheduleFlush.current();
|
||||
}
|
||||
} else {
|
||||
const message: UIMessage = {
|
||||
id: messageId,
|
||||
role: "assistant",
|
||||
parts: [],
|
||||
...(chunk.messageMetadata != null
|
||||
? { metadata: chunk.messageMetadata as UIMessage["metadata"] }
|
||||
: {}),
|
||||
} as UIMessage;
|
||||
pendingRef.current.set(messageId, message);
|
||||
orchestrationRef.current.set(messageId, createOrchestrationState());
|
||||
scheduleFlush.current();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentMessageId === null) continue;
|
||||
const existing = pendingRef.current.get(currentMessageId);
|
||||
if (!existing) continue;
|
||||
let orchestration = orchestrationRef.current.get(currentMessageId);
|
||||
if (!orchestration) {
|
||||
// Defensive: a chunk arrived for a message we never saw a
|
||||
// `start` for. Lazily create orchestration state so we can
|
||||
// still display the parts.
|
||||
orchestration = createOrchestrationState();
|
||||
orchestrationRef.current.set(currentMessageId, orchestration);
|
||||
}
|
||||
|
||||
const updated = applyOutputChunk(existing, chunk, orchestration);
|
||||
if (updated !== existing) {
|
||||
pendingRef.current.set(currentMessageId, updated);
|
||||
scheduleFlush.current();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Lock may already be released.
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (abort.signal.aborted) return;
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug("[AgentView] output stream subscription failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
// ---- Input channel: user messages (`ChatInputChunk`) -------------------
|
||||
//
|
||||
// The transport appends a `{kind: "message", payload}` ChatInputChunk
|
||||
// for every user turn (and `{kind: "stop"}` for stop signals). We pull
|
||||
// user messages out of `payload.messages` for `kind: "message"` chunks
|
||||
// and ignore the rest.
|
||||
const runInput = async () => {
|
||||
try {
|
||||
const sub = new SSEStreamSubscription(inputUrl, commonSubOptions);
|
||||
const raw = await sub.subscribe();
|
||||
const reader = raw.getReader();
|
||||
try {
|
||||
while (!abort.signal.aborted) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
|
||||
const chunk = parseChunkPayload(value.chunk) as InputStreamChunk | null;
|
||||
if (!chunk || chunk.kind !== "message") continue;
|
||||
const payload = chunk.payload;
|
||||
if (!payload || !Array.isArray(payload.messages)) continue;
|
||||
|
||||
const incomingUsers = payload.messages.filter(
|
||||
(m): m is UIMessage =>
|
||||
m != null && (m as { role?: string }).role === "user" && typeof m.id === "string"
|
||||
);
|
||||
if (incomingUsers.length === 0) continue;
|
||||
|
||||
let changed = false;
|
||||
for (const msg of incomingUsers) {
|
||||
if (pendingRef.current.has(msg.id)) continue;
|
||||
pendingRef.current.set(msg.id, msg);
|
||||
timestampsRef.current.set(msg.id, value.timestamp);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) scheduleFlush.current();
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
reader.releaseLock();
|
||||
} catch {
|
||||
// Lock may already be released.
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (abort.signal.aborted) return;
|
||||
// eslint-disable-next-line no-console
|
||||
console.debug("[AgentView] input stream subscription failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
void runOutput();
|
||||
void runInput();
|
||||
|
||||
return () => {
|
||||
abort.abort();
|
||||
if (pendingTimerRef.current !== null) {
|
||||
clearTimeout(pendingTimerRef.current);
|
||||
pendingTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [sessionId, apiOrigin, orgSlug, projectSlug, envSlug]);
|
||||
|
||||
return useMemo(() => {
|
||||
const timestamps = timestampsRef.current;
|
||||
const arr = Array.from(messagesById.values());
|
||||
arr.sort((a, b) => {
|
||||
const ta = timestamps.get(a.id) ?? 0;
|
||||
const tb = timestamps.get(b.id) ?? 0;
|
||||
if (ta !== tb) return ta - tb;
|
||||
// Tie-breaker for messages sharing a stream ID bucket (rare): fall
|
||||
// back to message id string order so the output is deterministic.
|
||||
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
|
||||
});
|
||||
return arr;
|
||||
}, [messagesById]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyOutputChunk — minimal UIMessageChunk → UIMessage accumulator.
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A pared-down re-implementation of AI SDK's `processUIMessageStream` (in
|
||||
// `ai@6`'s `index.mjs`). The webapp is pinned to `ai@4`, which doesn't ship
|
||||
// the v5+ chunk-stream helpers, so we vendor the bits we actually use.
|
||||
//
|
||||
// Scope vs. canonical:
|
||||
// - We render only the chunk shapes that `AgentMessageView`/`renderPart`
|
||||
// actually display: text, reasoning, tool-* (input-{start,delta,available}
|
||||
// + output-{available,error}), source-url, source-document, file,
|
||||
// step-start/finish-step, data-*, plus metadata/finish lifecycle.
|
||||
// - Unknown chunk types fall through as no-ops — defensive on purpose for a
|
||||
// read-only viewer.
|
||||
// - We **do not parse partial JSON for streaming tool inputs.** Canonical
|
||||
// uses `parsePartialJson` (which depends on a 300-line `fixJson` state
|
||||
// machine to repair incomplete JSON) so users see the input growing
|
||||
// character-by-character. We skip it: tool inputs stay `undefined`
|
||||
// throughout streaming and snap to the final value when
|
||||
// `tool-input-available` lands. Acceptable for a viewer; can be added
|
||||
// later by vendoring `fixJson` if the UX warrants it.
|
||||
//
|
||||
// `orchestration` carries per-message active-part trackers that mirror
|
||||
// canonical's `state.activeTextParts` / `state.activeReasoningParts`. They
|
||||
// let `text-delta` find the right text part by id and let `finish-step`
|
||||
// clear them so a new step can re-use the same id without colliding.
|
||||
//
|
||||
// Returns the same object reference when nothing changes so the caller can
|
||||
// skip unnecessary state flushes + React re-renders.
|
||||
|
||||
type AnyPart = { [key: string]: unknown; type: string };
|
||||
|
||||
function applyOutputChunk(
|
||||
msg: UIMessage,
|
||||
chunk: OutputChunk,
|
||||
orchestration: MessageOrchestrationState
|
||||
): UIMessage {
|
||||
const type = chunk.type;
|
||||
|
||||
// Text parts ---------------------------------------------------------------
|
||||
//
|
||||
// Track each streaming text part by its index in `msg.parts`. Part ids
|
||||
// are only unique *within a step* — the SDK happily reuses `text-start
|
||||
// id="0"` after a `finish-step` boundary — so a delta arriving for a
|
||||
// reused id needs to land on the *current* part, not every prior part
|
||||
// that ever shared that id. The index map gives us O(1) "which slot is
|
||||
// currently streaming this id" without any id-based search.
|
||||
if (type === "text-start") {
|
||||
const id = chunk.id as string;
|
||||
const newIndex = (msg.parts ?? []).length; // index AFTER push
|
||||
orchestration.activeTextPartIndexes.set(id, newIndex);
|
||||
return withNewPart(msg, {
|
||||
type: "text",
|
||||
id,
|
||||
text: "",
|
||||
state: "streaming",
|
||||
});
|
||||
}
|
||||
if (type === "text-delta") {
|
||||
const id = chunk.id as string;
|
||||
const index = orchestration.activeTextPartIndexes.get(id);
|
||||
if (index === undefined) return msg; // delta with no start — drop.
|
||||
return updatePartAt(msg, index, (p) => ({
|
||||
...p,
|
||||
text: ((p as { text?: string }).text ?? "") + String(chunk.delta ?? ""),
|
||||
}));
|
||||
}
|
||||
if (type === "text-end") {
|
||||
const id = chunk.id as string;
|
||||
const index = orchestration.activeTextPartIndexes.get(id);
|
||||
if (index === undefined) return msg;
|
||||
orchestration.activeTextPartIndexes.delete(id);
|
||||
return updatePartAt(msg, index, (p) => ({ ...p, state: "done" }));
|
||||
}
|
||||
|
||||
// Reasoning parts ----------------------------------------------------------
|
||||
if (type === "reasoning-start") {
|
||||
const id = chunk.id as string;
|
||||
const newIndex = (msg.parts ?? []).length;
|
||||
orchestration.activeReasoningPartIndexes.set(id, newIndex);
|
||||
return withNewPart(msg, {
|
||||
type: "reasoning",
|
||||
id,
|
||||
text: "",
|
||||
state: "streaming",
|
||||
});
|
||||
}
|
||||
if (type === "reasoning-delta") {
|
||||
const id = chunk.id as string;
|
||||
const index = orchestration.activeReasoningPartIndexes.get(id);
|
||||
if (index === undefined) return msg;
|
||||
return updatePartAt(msg, index, (p) => ({
|
||||
...p,
|
||||
text: ((p as { text?: string }).text ?? "") + String(chunk.delta ?? ""),
|
||||
}));
|
||||
}
|
||||
if (type === "reasoning-end") {
|
||||
const id = chunk.id as string;
|
||||
const index = orchestration.activeReasoningPartIndexes.get(id);
|
||||
if (index === undefined) return msg;
|
||||
orchestration.activeReasoningPartIndexes.delete(id);
|
||||
return updatePartAt(msg, index, (p) => ({ ...p, state: "done" }));
|
||||
}
|
||||
|
||||
// Tool call parts ----------------------------------------------------------
|
||||
if (type === "tool-input-start") {
|
||||
const toolName = String(chunk.toolName ?? "");
|
||||
return withNewPart(msg, {
|
||||
type: `tool-${toolName}`,
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName,
|
||||
state: "input-streaming",
|
||||
input: undefined,
|
||||
});
|
||||
}
|
||||
if (type === "tool-input-delta") {
|
||||
// We don't parse partial JSON, so streaming tool input deltas are a
|
||||
// no-op. The full input snaps in when `tool-input-available` arrives.
|
||||
return msg;
|
||||
}
|
||||
if (type === "tool-input-available") {
|
||||
const toolName = String(chunk.toolName ?? "");
|
||||
const existingIdx = indexOfPart(
|
||||
msg,
|
||||
(p) => (p as { toolCallId?: string }).toolCallId === chunk.toolCallId
|
||||
);
|
||||
if (existingIdx >= 0) {
|
||||
return updatePartAt(msg, existingIdx, (p) => ({
|
||||
...p,
|
||||
state: "input-available",
|
||||
input: chunk.input,
|
||||
}));
|
||||
}
|
||||
// Tool input arrived without a preceding tool-input-start (some
|
||||
// providers do this for fast tools) — synthesize a new part.
|
||||
return withNewPart(msg, {
|
||||
type: `tool-${toolName}`,
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName,
|
||||
state: "input-available",
|
||||
input: chunk.input,
|
||||
});
|
||||
}
|
||||
if (type === "tool-output-available") {
|
||||
return updatePart(msg, (p) =>
|
||||
(p as { toolCallId?: string }).toolCallId === chunk.toolCallId
|
||||
? {
|
||||
...p,
|
||||
state: "output-available",
|
||||
output: chunk.output,
|
||||
...(chunk.preliminary === true ? { preliminary: true } : {}),
|
||||
}
|
||||
: null
|
||||
);
|
||||
}
|
||||
if (type === "tool-output-error") {
|
||||
return updatePart(msg, (p) =>
|
||||
(p as { toolCallId?: string }).toolCallId === chunk.toolCallId
|
||||
? { ...p, state: "output-error", errorText: chunk.errorText }
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
// Source / file / step / data parts — pass through as a whole -------------
|
||||
if (type === "source-url" || type === "source-document" || type === "file") {
|
||||
return withNewPart(msg, chunk as unknown as AnyPart);
|
||||
}
|
||||
if (type === "start-step") {
|
||||
return withNewPart(msg, { type: "step-start" });
|
||||
}
|
||||
if (type === "finish-step") {
|
||||
// Step boundary — canonical clears the active part trackers so a new
|
||||
// step can re-use the same text/reasoning part IDs cleanly. The
|
||||
// message itself doesn't structurally change; the previous step's
|
||||
// parts stay frozen at their indexes in `msg.parts`.
|
||||
orchestration.activeTextPartIndexes.clear();
|
||||
orchestration.activeReasoningPartIndexes.clear();
|
||||
return msg;
|
||||
}
|
||||
if (type.startsWith("data-")) {
|
||||
return withNewPart(msg, chunk as unknown as AnyPart);
|
||||
}
|
||||
|
||||
// Metadata / lifecycle -----------------------------------------------------
|
||||
if (type === "finish" || type === "message-metadata") {
|
||||
if (chunk.messageMetadata == null) return msg;
|
||||
return {
|
||||
...msg,
|
||||
metadata: {
|
||||
...((msg as { metadata?: Record<string, unknown> }).metadata ?? {}),
|
||||
...(chunk.messageMetadata as Record<string, unknown>),
|
||||
},
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
// Abort / error / unknown — no structural change. (`start` is handled at
|
||||
// the orchestration level in the output reader, not here.)
|
||||
return msg;
|
||||
}
|
||||
|
||||
// --- Small immutable helpers for UIMessage.parts mutation -------------------
|
||||
|
||||
function withNewPart(msg: UIMessage, part: AnyPart): UIMessage {
|
||||
return {
|
||||
...msg,
|
||||
parts: [...((msg.parts ?? []) as AnyPart[]), part],
|
||||
} as UIMessage;
|
||||
}
|
||||
|
||||
function updatePart(
|
||||
msg: UIMessage,
|
||||
updater: (part: AnyPart) => AnyPart | null
|
||||
): UIMessage {
|
||||
const parts = (msg.parts ?? []) as AnyPart[];
|
||||
let changed = false;
|
||||
const next = parts.map((p) => {
|
||||
const updated = updater(p);
|
||||
if (updated === null) return p;
|
||||
changed = true;
|
||||
return updated;
|
||||
});
|
||||
return changed ? ({ ...msg, parts: next } as UIMessage) : msg;
|
||||
}
|
||||
|
||||
function indexOfPart(msg: UIMessage, predicate: (part: AnyPart) => boolean): number {
|
||||
const parts = (msg.parts ?? []) as AnyPart[];
|
||||
for (let i = 0; i < parts.length; i++) {
|
||||
if (predicate(parts[i]!)) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function updatePartAt(
|
||||
msg: UIMessage,
|
||||
index: number,
|
||||
updater: (part: AnyPart) => AnyPart
|
||||
): UIMessage {
|
||||
const parts = (msg.parts ?? []) as AnyPart[];
|
||||
if (index < 0 || index >= parts.length) return msg;
|
||||
const next = parts.slice();
|
||||
next[index] = updater(parts[index]!);
|
||||
return { ...msg, parts: next } as UIMessage;
|
||||
}
|
||||
@@ -235,6 +235,30 @@ const EnvironmentSchema = z
|
||||
CACHE_REDIS_TLS_DISABLED: z.string().default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
CACHE_REDIS_CLUSTER_MODE_ENABLED: z.string().default("0"),
|
||||
|
||||
TASK_META_CACHE_REDIS_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_HOST),
|
||||
TASK_META_CACHE_REDIS_PORT: z.coerce
|
||||
.number()
|
||||
.optional()
|
||||
.transform(
|
||||
(v) => v ?? (process.env.REDIS_PORT ? parseInt(process.env.REDIS_PORT) : undefined)
|
||||
),
|
||||
TASK_META_CACHE_REDIS_USERNAME: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_USERNAME),
|
||||
TASK_META_CACHE_REDIS_PASSWORD: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) => v ?? process.env.REDIS_PASSWORD),
|
||||
TASK_META_CACHE_REDIS_TLS_DISABLED: z
|
||||
.string()
|
||||
.default(process.env.REDIS_TLS_DISABLED ?? "false"),
|
||||
TASK_META_CACHE_CURRENT_ENV_TTL_SECONDS: z.coerce.number().default(86400),
|
||||
TASK_META_CACHE_BY_WORKER_TTL_SECONDS: z.coerce.number().default(2592000),
|
||||
|
||||
REALTIME_STREAMS_REDIS_HOST: z
|
||||
.string()
|
||||
.optional()
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useLayoutEffect, useRef } from "react";
|
||||
|
||||
const AT_BOTTOM_TOLERANCE_PX = 16;
|
||||
|
||||
/**
|
||||
* Chat-style sticky-bottom auto-scroll behavior.
|
||||
*
|
||||
* Behavior:
|
||||
* - On mount, finds the closest scrollable ancestor of the returned ref
|
||||
* (the inspector content panel, the playground messages panel, etc.).
|
||||
* - Tracks whether the user is currently "at the bottom" of that scroll
|
||||
* container via a passive scroll listener. Default is `true` so the very
|
||||
* first render of an existing conversation lands at the bottom, and the
|
||||
* "content fits without scrolling" case stays in auto-scroll mode.
|
||||
* - Whenever the dependency array changes (typically the messages array),
|
||||
* if the user was at the bottom, programmatically scrolls to the new
|
||||
* bottom. Uses `useLayoutEffect` so the scroll happens before paint and
|
||||
* there's no one-frame flicker showing new content above the viewport.
|
||||
* - Scrolling away from the bottom flips the ref to `false` → auto-scroll
|
||||
* pauses. Scrolling back into the bottom band (within
|
||||
* `AT_BOTTOM_TOLERANCE_PX`) flips it back to `true` → auto-scroll
|
||||
* resumes.
|
||||
*
|
||||
* The programmatic scroll fires its own scroll event, which immediately
|
||||
* re-runs the stickiness check and confirms we're still at the bottom
|
||||
* (distance ≈ 0 ≤ tolerance), so the ref stays `true`. No special
|
||||
* "ignore programmatic scroll" flag needed.
|
||||
*
|
||||
* @param deps Pass the rendered list (or any dependency that should
|
||||
* trigger a re-scroll). Typically `[messages]`.
|
||||
* @returns A ref to attach to the component's root element. The hook
|
||||
* walks up from this element's parent to locate the scroll
|
||||
* container, so the root must be mounted *inside* the
|
||||
* scrollable region.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function ChatPanel({ messages }) {
|
||||
* const rootRef = useAutoScrollToBottom([messages]);
|
||||
* return (
|
||||
* <div className="overflow-y-auto h-full">
|
||||
* <div ref={rootRef}>
|
||||
* {messages.map((m) => <Message key={m.id} message={m} />)}
|
||||
* </div>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useAutoScrollToBottom(deps: ReadonlyArray<unknown>) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
// Default true so initial mount + replay land at the bottom, and the
|
||||
// no-overflow case stays sticky once content starts to grow.
|
||||
const stickToBottomRef = useRef(true);
|
||||
|
||||
// Locate the scroll container on mount and attach a passive scroll
|
||||
// listener that updates `stickToBottomRef`.
|
||||
useEffect(() => {
|
||||
const findScrollContainer = (start: HTMLElement | null): HTMLElement | null => {
|
||||
let current: HTMLElement | null = start;
|
||||
while (current) {
|
||||
const style = getComputedStyle(current);
|
||||
const overflowY = style.overflowY;
|
||||
if (overflowY === "auto" || overflowY === "scroll") return current;
|
||||
current = current.parentElement;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const container = findScrollContainer(rootRef.current?.parentElement ?? null);
|
||||
if (!container) return;
|
||||
containerRef.current = container;
|
||||
|
||||
const updateStickiness = () => {
|
||||
const distanceFromBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
stickToBottomRef.current = distanceFromBottom <= AT_BOTTOM_TOLERANCE_PX;
|
||||
};
|
||||
|
||||
// Seed from current position so the first messages-effect uses an
|
||||
// accurate value rather than the default `true` if the user happened
|
||||
// to mount the view already scrolled.
|
||||
updateStickiness();
|
||||
|
||||
container.addEventListener("scroll", updateStickiness, { passive: true });
|
||||
return () => {
|
||||
container.removeEventListener("scroll", updateStickiness);
|
||||
containerRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// After each commit that changes the deps (typically the messages
|
||||
// array), if we were at the bottom, scroll to the new bottom.
|
||||
useLayoutEffect(() => {
|
||||
if (!stickToBottomRef.current) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
return rootRef;
|
||||
}
|
||||
@@ -233,7 +233,7 @@ export async function findOrCreateGoogleUser({
|
||||
// Check if email user and auth user are the same
|
||||
if (existingEmailUser.id !== existingUser.id) {
|
||||
// Different users: email is taken by one user, Google auth belongs to another
|
||||
logger.error(
|
||||
logger.warn(
|
||||
`Google auth conflict: Google ID ${authenticationProfile.id} belongs to user ${existingUser.id} but email ${email} is taken by user ${existingEmailUser.id}`,
|
||||
{
|
||||
email,
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import {
|
||||
type PrismaClientOrTransaction,
|
||||
type RuntimeEnvironmentType,
|
||||
type TaskTriggerSource,
|
||||
} from "@trigger.dev/database";
|
||||
import { ClickHouse } from "@internal/clickhouse";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
|
||||
|
||||
export type AgentListItem = {
|
||||
slug: string;
|
||||
filePath: string;
|
||||
createdAt: Date;
|
||||
triggerSource: TaskTriggerSource;
|
||||
config: unknown;
|
||||
};
|
||||
|
||||
export type AgentActiveState = {
|
||||
running: number;
|
||||
suspended: number;
|
||||
};
|
||||
|
||||
export class AgentListPresenter {
|
||||
constructor(
|
||||
private readonly clickhouse: ClickHouse,
|
||||
private readonly _replica: PrismaClientOrTransaction
|
||||
) {}
|
||||
|
||||
public async call({
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
environmentType,
|
||||
}: {
|
||||
organizationId: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
environmentType: RuntimeEnvironmentType;
|
||||
}) {
|
||||
const currentWorker = await findCurrentWorkerFromEnvironment(
|
||||
{
|
||||
id: environmentId,
|
||||
type: environmentType,
|
||||
},
|
||||
this._replica
|
||||
);
|
||||
|
||||
if (!currentWorker) {
|
||||
return {
|
||||
agents: [],
|
||||
activeStates: Promise.resolve({} as Record<string, AgentActiveState>),
|
||||
conversationSparklines: Promise.resolve({} as Record<string, number[]>),
|
||||
costSparklines: Promise.resolve({} as Record<string, number[]>),
|
||||
tokenSparklines: Promise.resolve({} as Record<string, number[]>),
|
||||
};
|
||||
}
|
||||
|
||||
const agents = await this._replica.backgroundWorkerTask.findMany({
|
||||
where: {
|
||||
workerId: currentWorker.id,
|
||||
triggerSource: "AGENT",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
filePath: true,
|
||||
triggerSource: true,
|
||||
config: true,
|
||||
createdAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
slug: "asc",
|
||||
},
|
||||
});
|
||||
|
||||
const slugs = agents.map((a) => a.slug);
|
||||
|
||||
if (slugs.length === 0) {
|
||||
return {
|
||||
agents,
|
||||
activeStates: Promise.resolve({} as Record<string, AgentActiveState>),
|
||||
conversationSparklines: Promise.resolve({} as Record<string, number[]>),
|
||||
costSparklines: Promise.resolve({} as Record<string, number[]>),
|
||||
tokenSparklines: Promise.resolve({} as Record<string, number[]>),
|
||||
};
|
||||
}
|
||||
|
||||
// All queries are deferred for streaming
|
||||
const activeStates = this.#getActiveStates(environmentId, slugs);
|
||||
const conversationSparklines = this.#getConversationSparklines(environmentId, slugs);
|
||||
const costSparklines = this.#getCostSparklines(environmentId, slugs);
|
||||
const tokenSparklines = this.#getTokenSparklines(environmentId, slugs);
|
||||
|
||||
return { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines };
|
||||
}
|
||||
|
||||
/** Count runs currently executing vs suspended per agent */
|
||||
async #getActiveStates(
|
||||
environmentId: string,
|
||||
slugs: string[]
|
||||
): Promise<Record<string, AgentActiveState>> {
|
||||
const queryFn = this.clickhouse.reader.query({
|
||||
name: "agentActiveStates",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
countIf(status = 'EXECUTING') AS running,
|
||||
countIf(status IN ('WAITING_TO_RESUME', 'QUEUED_EXECUTING')) AS suspended
|
||||
FROM trigger_dev.task_runs_v2
|
||||
WHERE environment_id = {environmentId: String}
|
||||
AND task_identifier IN {slugs: Array(String)}
|
||||
AND task_kind = 'AGENT'
|
||||
AND status IN ('EXECUTING', 'WAITING_TO_RESUME', 'QUEUED_EXECUTING')
|
||||
GROUP BY task_identifier`,
|
||||
params: z.object({
|
||||
environmentId: z.string(),
|
||||
slugs: z.array(z.string()),
|
||||
}),
|
||||
schema: z.object({
|
||||
task_identifier: z.string(),
|
||||
running: z.coerce.number(),
|
||||
suspended: z.coerce.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
const [error, rows] = await queryFn({ environmentId, slugs });
|
||||
if (error) {
|
||||
console.error("Agent active states query failed:", error);
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, AgentActiveState> = {};
|
||||
for (const row of rows) {
|
||||
result[row.task_identifier] = { running: row.running, suspended: row.suspended };
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 24h hourly sparkline of conversation (run) count per agent */
|
||||
async #getConversationSparklines(
|
||||
environmentId: string,
|
||||
slugs: string[]
|
||||
): Promise<Record<string, number[]>> {
|
||||
const queryFn = this.clickhouse.reader.query({
|
||||
name: "agentConversationSparklines",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
toStartOfHour(created_at) AS bucket,
|
||||
count() AS val
|
||||
FROM trigger_dev.task_runs_v2
|
||||
WHERE environment_id = {environmentId: String}
|
||||
AND task_identifier IN {slugs: Array(String)}
|
||||
AND task_kind = 'AGENT'
|
||||
AND created_at >= now() - INTERVAL 24 HOUR
|
||||
GROUP BY task_identifier, bucket
|
||||
ORDER BY task_identifier, bucket`,
|
||||
params: z.object({
|
||||
environmentId: z.string(),
|
||||
slugs: z.array(z.string()),
|
||||
}),
|
||||
schema: z.object({
|
||||
task_identifier: z.string(),
|
||||
bucket: z.string(),
|
||||
val: z.coerce.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
|
||||
}
|
||||
|
||||
/** 24h hourly sparkline of LLM cost per agent */
|
||||
async #getCostSparklines(
|
||||
environmentId: string,
|
||||
slugs: string[]
|
||||
): Promise<Record<string, number[]>> {
|
||||
const queryFn = this.clickhouse.reader.query({
|
||||
name: "agentCostSparklines",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
toStartOfHour(start_time) AS bucket,
|
||||
sum(total_cost) AS val
|
||||
FROM trigger_dev.llm_metrics_v1
|
||||
WHERE environment_id = {environmentId: String}
|
||||
AND task_identifier IN {slugs: Array(String)}
|
||||
AND start_time >= now() - INTERVAL 24 HOUR
|
||||
GROUP BY task_identifier, bucket
|
||||
ORDER BY task_identifier, bucket`,
|
||||
params: z.object({
|
||||
environmentId: z.string(),
|
||||
slugs: z.array(z.string()),
|
||||
}),
|
||||
schema: z.object({
|
||||
task_identifier: z.string(),
|
||||
bucket: z.string(),
|
||||
val: z.coerce.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
|
||||
}
|
||||
|
||||
/** 24h hourly sparkline of total tokens per agent */
|
||||
async #getTokenSparklines(
|
||||
environmentId: string,
|
||||
slugs: string[]
|
||||
): Promise<Record<string, number[]>> {
|
||||
const queryFn = this.clickhouse.reader.query({
|
||||
name: "agentTokenSparklines",
|
||||
query: `SELECT
|
||||
task_identifier,
|
||||
toStartOfHour(start_time) AS bucket,
|
||||
sum(total_tokens) AS val
|
||||
FROM trigger_dev.llm_metrics_v1
|
||||
WHERE environment_id = {environmentId: String}
|
||||
AND task_identifier IN {slugs: Array(String)}
|
||||
AND start_time >= now() - INTERVAL 24 HOUR
|
||||
GROUP BY task_identifier, bucket
|
||||
ORDER BY task_identifier, bucket`,
|
||||
params: z.object({
|
||||
environmentId: z.string(),
|
||||
slugs: z.array(z.string()),
|
||||
}),
|
||||
schema: z.object({
|
||||
task_identifier: z.string(),
|
||||
bucket: z.string(),
|
||||
val: z.coerce.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
return this.#buildSparklineMap(await queryFn({ environmentId, slugs }), slugs);
|
||||
}
|
||||
|
||||
/** Convert ClickHouse query result to sparkline map with zero-filled 24 hourly buckets */
|
||||
#buildSparklineMap(
|
||||
queryResult: [Error, null] | [null, { task_identifier: string; bucket: string; val: number }[]],
|
||||
slugs: string[]
|
||||
): Record<string, number[]> {
|
||||
const [error, rows] = queryResult;
|
||||
if (error) {
|
||||
console.error("Agent sparkline query failed:", error);
|
||||
return {};
|
||||
}
|
||||
return this.#buildSparklineFromRows(rows, slugs);
|
||||
}
|
||||
|
||||
#buildSparklineFromRows(
|
||||
rows: { task_identifier: string; bucket: string; val: number }[],
|
||||
slugs: string[]
|
||||
): Record<string, number[]> {
|
||||
const now = new Date();
|
||||
const startHour = new Date(
|
||||
Date.UTC(
|
||||
now.getUTCFullYear(),
|
||||
now.getUTCMonth(),
|
||||
now.getUTCDate(),
|
||||
now.getUTCHours() - 23,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
)
|
||||
);
|
||||
|
||||
const bucketKeys: string[] = [];
|
||||
for (let i = 0; i < 24; i++) {
|
||||
const h = new Date(startHour.getTime() + i * 3600_000);
|
||||
bucketKeys.push(h.toISOString().slice(0, 13).replace("T", " ") + ":00:00");
|
||||
}
|
||||
|
||||
const rowMap = new Map<string, number>();
|
||||
for (const row of rows) {
|
||||
rowMap.set(`${row.task_identifier}|${row.bucket}`, row.val);
|
||||
}
|
||||
|
||||
const result: Record<string, number[]> = {};
|
||||
for (const slug of slugs) {
|
||||
result[slug] = bucketKeys.map((key) => rowMap.get(`${slug}|${key}`) ?? 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
export const agentListPresenter = singleton("agentListPresenter", setupAgentListPresenter);
|
||||
|
||||
function setupAgentListPresenter() {
|
||||
return new AgentListPresenter(clickhouseClient, $replica);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { RuntimeEnvironmentType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { $replica } from "~/db.server";
|
||||
import { findCurrentWorkerFromEnvironment } from "~/v3/models/workerDeployment.server";
|
||||
import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
|
||||
export type PlaygroundAgent = {
|
||||
slug: string;
|
||||
filePath: string;
|
||||
triggerSource: TaskTriggerSource;
|
||||
config: unknown;
|
||||
payloadSchema: unknown;
|
||||
};
|
||||
|
||||
export type PlaygroundConversation = {
|
||||
id: string;
|
||||
chatId: string;
|
||||
title: string;
|
||||
agentSlug: string;
|
||||
runFriendlyId: string | null;
|
||||
runStatus: TaskRunStatus | null;
|
||||
clientData: unknown;
|
||||
messages: unknown;
|
||||
lastEventId: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export class PlaygroundPresenter {
|
||||
async listAgents({
|
||||
environmentId,
|
||||
environmentType,
|
||||
}: {
|
||||
environmentId: string;
|
||||
environmentType: RuntimeEnvironmentType;
|
||||
}): Promise<PlaygroundAgent[]> {
|
||||
const currentWorker = await findCurrentWorkerFromEnvironment(
|
||||
{ id: environmentId, type: environmentType },
|
||||
$replica
|
||||
);
|
||||
|
||||
if (!currentWorker) return [];
|
||||
|
||||
return $replica.backgroundWorkerTask.findMany({
|
||||
where: {
|
||||
workerId: currentWorker.id,
|
||||
triggerSource: "AGENT",
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
filePath: true,
|
||||
triggerSource: true,
|
||||
config: true,
|
||||
payloadSchema: true,
|
||||
},
|
||||
orderBy: { slug: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
async getAgent({
|
||||
environmentId,
|
||||
environmentType,
|
||||
agentSlug,
|
||||
}: {
|
||||
environmentId: string;
|
||||
environmentType: RuntimeEnvironmentType;
|
||||
agentSlug: string;
|
||||
}): Promise<PlaygroundAgent | null> {
|
||||
const currentWorker = await findCurrentWorkerFromEnvironment(
|
||||
{ id: environmentId, type: environmentType },
|
||||
$replica
|
||||
);
|
||||
|
||||
if (!currentWorker) return null;
|
||||
|
||||
return $replica.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
workerId: currentWorker.id,
|
||||
triggerSource: "AGENT",
|
||||
slug: agentSlug,
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
filePath: true,
|
||||
triggerSource: true,
|
||||
config: true,
|
||||
payloadSchema: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getRecentConversations({
|
||||
environmentId,
|
||||
agentSlug,
|
||||
userId,
|
||||
limit = 10,
|
||||
}: {
|
||||
environmentId: string;
|
||||
agentSlug: string;
|
||||
userId: string;
|
||||
limit?: number;
|
||||
}): Promise<PlaygroundConversation[]> {
|
||||
const conversations = await $replica.playgroundConversation.findMany({
|
||||
where: {
|
||||
runtimeEnvironmentId: environmentId,
|
||||
agentSlug,
|
||||
userId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
chatId: true,
|
||||
title: true,
|
||||
agentSlug: true,
|
||||
clientData: true,
|
||||
messages: true,
|
||||
lastEventId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
run: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return conversations.map((c) => ({
|
||||
id: c.id,
|
||||
chatId: c.chatId,
|
||||
title: c.title,
|
||||
agentSlug: c.agentSlug,
|
||||
runFriendlyId: c.run?.friendlyId ?? null,
|
||||
runStatus: c.run?.status ?? null,
|
||||
clientData: c.clientData,
|
||||
messages: c.messages,
|
||||
lastEventId: c.lastEventId,
|
||||
isActive: c.run?.status ? !isFinalRunStatus(c.run.status) : false,
|
||||
createdAt: c.createdAt,
|
||||
updatedAt: c.updatedAt,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export const playgroundPresenter = new PlaygroundPresenter();
|
||||
@@ -0,0 +1,153 @@
|
||||
import { type Span } from "@opentelemetry/api";
|
||||
import { type PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
import { env } from "~/env.server";
|
||||
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
|
||||
export type SessionDetail = NonNullable<Awaited<ReturnType<SessionPresenter["call"]>>>;
|
||||
|
||||
export class SessionPresenter {
|
||||
constructor(private readonly replica: PrismaClientOrTransaction) {}
|
||||
|
||||
public async call(args: {
|
||||
userId: string;
|
||||
environmentId: string;
|
||||
sessionParam: string;
|
||||
}) {
|
||||
return startActiveSpan(
|
||||
"SessionPresenter.call",
|
||||
(span) => this.#call(args, span),
|
||||
{
|
||||
attributes: {
|
||||
environmentId: args.environmentId,
|
||||
sessionParam: args.sessionParam,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async #call(
|
||||
{
|
||||
userId,
|
||||
environmentId,
|
||||
sessionParam,
|
||||
}: {
|
||||
userId: string;
|
||||
environmentId: string;
|
||||
sessionParam: string;
|
||||
},
|
||||
rootSpan: Span
|
||||
) {
|
||||
const session = await startActiveSpan(
|
||||
"SessionPresenter.resolveSession",
|
||||
() => resolveSessionByIdOrExternalId(this.replica, environmentId, sessionParam)
|
||||
);
|
||||
if (!session) {
|
||||
rootSpan.setAttribute("session.found", false);
|
||||
return null;
|
||||
}
|
||||
rootSpan.setAttribute("session.found", true);
|
||||
rootSpan.setAttribute("session.id", session.id);
|
||||
|
||||
const displayableEnvironment = await startActiveSpan(
|
||||
"SessionPresenter.findDisplayableEnvironment",
|
||||
() => findDisplayableEnvironment(environmentId, userId)
|
||||
);
|
||||
if (!displayableEnvironment) {
|
||||
throw new ServiceValidationError("No environment found");
|
||||
}
|
||||
|
||||
// Run history is append-only; latest first matches the runs list.
|
||||
// 50 covers the vast majority of sessions; longer histories link out
|
||||
// to the runs page via tag filter.
|
||||
const sessionRuns = await startActiveSpan(
|
||||
"SessionPresenter.findSessionRuns",
|
||||
async (span) => {
|
||||
const rows = await this.replica.sessionRun.findMany({
|
||||
where: { sessionId: session.id },
|
||||
orderBy: { triggeredAt: "desc" },
|
||||
take: 50,
|
||||
select: {
|
||||
id: true,
|
||||
runId: true,
|
||||
reason: true,
|
||||
triggeredAt: true,
|
||||
},
|
||||
});
|
||||
span.setAttribute("sessionRuns.count", rows.length);
|
||||
return rows;
|
||||
}
|
||||
);
|
||||
|
||||
const runIds = sessionRuns.map((r) => r.runId);
|
||||
const runs = await startActiveSpan(
|
||||
"SessionPresenter.findRuns",
|
||||
async (span) => {
|
||||
span.setAttribute("runIds.count", runIds.length);
|
||||
return runIds.length > 0
|
||||
? this.replica.taskRun.findMany({
|
||||
where: { id: { in: runIds } },
|
||||
select: { id: true, friendlyId: true, status: true },
|
||||
})
|
||||
: [];
|
||||
}
|
||||
);
|
||||
const runsById = new Map(runs.map((r) => [r.id, r] as const));
|
||||
|
||||
const currentRun = session.currentRunId
|
||||
? runsById.get(session.currentRunId) ??
|
||||
(await startActiveSpan(
|
||||
"SessionPresenter.findCurrentRunFallback",
|
||||
() =>
|
||||
this.replica.taskRun.findFirst({
|
||||
where: { id: session.currentRunId! },
|
||||
select: { id: true, friendlyId: true, status: true },
|
||||
})
|
||||
))
|
||||
: null;
|
||||
|
||||
// The dashboard SSE route is cookie-authed, so `publicAccessToken` is
|
||||
// unused — kept here to match the existing `AgentViewAuth` shape.
|
||||
const addressingKey = session.externalId ?? session.friendlyId;
|
||||
|
||||
return {
|
||||
id: session.id,
|
||||
friendlyId: session.friendlyId,
|
||||
externalId: session.externalId,
|
||||
type: session.type,
|
||||
taskIdentifier: session.taskIdentifier,
|
||||
tags: session.tags ? [...session.tags].sort((a, b) => a.localeCompare(b)) : [],
|
||||
metadata: session.metadata,
|
||||
triggerConfig: session.triggerConfig,
|
||||
streamBasinName: session.streamBasinName,
|
||||
closedAt: session.closedAt ? session.closedAt.toISOString() : undefined,
|
||||
closedReason: session.closedReason ?? undefined,
|
||||
expiresAt: session.expiresAt ? session.expiresAt.toISOString() : undefined,
|
||||
createdAt: session.createdAt.toISOString(),
|
||||
updatedAt: session.updatedAt.toISOString(),
|
||||
environment: displayableEnvironment,
|
||||
currentRun: currentRun
|
||||
? { friendlyId: currentRun.friendlyId, status: currentRun.status }
|
||||
: null,
|
||||
runs: sessionRuns.map((r) => {
|
||||
const run = runsById.get(r.runId);
|
||||
return {
|
||||
id: r.id,
|
||||
reason: r.reason,
|
||||
triggeredAt: r.triggeredAt.toISOString(),
|
||||
run: run
|
||||
? { friendlyId: run.friendlyId, status: run.status }
|
||||
: null,
|
||||
};
|
||||
}),
|
||||
agentView: {
|
||||
publicAccessToken: "",
|
||||
apiOrigin: env.API_ORIGIN || env.LOGIN_ORIGIN,
|
||||
sessionId: addressingKey,
|
||||
initialMessages: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
type MachinePreset,
|
||||
prettyPrintPacket,
|
||||
RunAnnotations,
|
||||
SemanticInternalAttributes,
|
||||
type TaskRunContext,
|
||||
TaskRunError,
|
||||
TriggerTraceContext,
|
||||
type V3TaskRunContext,
|
||||
} from "@trigger.dev/core/v3";
|
||||
|
||||
import { AttemptId, getMaxDuration, parseTraceparent } from "@trigger.dev/core/v3/isomorphic";
|
||||
import {
|
||||
extractIdempotencyKeyScope,
|
||||
@@ -240,6 +242,9 @@ export class SpanPresenter extends BasePresenter {
|
||||
|
||||
const externalTraceId = this.#getExternalTraceId(run.traceContext);
|
||||
|
||||
const taskKind = RunAnnotations.safeParse(run.annotations).data?.taskKind;
|
||||
const isAgentRun = taskKind === "AGENT";
|
||||
|
||||
let region: { name: string; location: string | null } | null = null;
|
||||
|
||||
if (run.runtimeEnvironment.type !== "DEVELOPMENT" && run.engine !== "V1") {
|
||||
@@ -256,6 +261,48 @@ export class SpanPresenter extends BasePresenter {
|
||||
region = workerGroup ?? null;
|
||||
}
|
||||
|
||||
// Only AGENT-tagged runs (chat.agent and friends) can be session-bound,
|
||||
// so skip the SessionRun lookup for the much larger set of standard runs.
|
||||
// Lookup is by the unique `runId` index, but the cheapest query is the
|
||||
// one we don't run.
|
||||
const sessionRun = isAgentRun
|
||||
? await this._replica.sessionRun.findFirst({
|
||||
where: { runId: run.id },
|
||||
select: {
|
||||
reason: true,
|
||||
triggeredAt: true,
|
||||
session: {
|
||||
select: {
|
||||
friendlyId: true,
|
||||
externalId: true,
|
||||
type: true,
|
||||
taskIdentifier: true,
|
||||
closedAt: true,
|
||||
expiresAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
const session = sessionRun
|
||||
? {
|
||||
friendlyId: sessionRun.session.friendlyId,
|
||||
externalId: sessionRun.session.externalId,
|
||||
type: sessionRun.session.type,
|
||||
taskIdentifier: sessionRun.session.taskIdentifier,
|
||||
status:
|
||||
sessionRun.session.closedAt != null
|
||||
? ("CLOSED" as const)
|
||||
: sessionRun.session.expiresAt != null &&
|
||||
sessionRun.session.expiresAt.getTime() < Date.now()
|
||||
? ("EXPIRED" as const)
|
||||
: ("ACTIVE" as const),
|
||||
reason: sessionRun.reason,
|
||||
triggeredAt: sessionRun.triggeredAt,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: run.id,
|
||||
friendlyId: run.friendlyId,
|
||||
@@ -297,6 +344,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
isFinished,
|
||||
isRunning: RUNNING_STATUSES.includes(run.status),
|
||||
isError: isFailedRunStatus(run.status),
|
||||
isAgentRun,
|
||||
payload,
|
||||
payloadType: run.payloadType,
|
||||
output,
|
||||
@@ -315,6 +363,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
metadata,
|
||||
maxDurationInSeconds: getMaxDuration(run.maxDurationInSeconds),
|
||||
batch: run.batch ? { friendlyId: run.batch.friendlyId } : undefined,
|
||||
session,
|
||||
engine: run.engine,
|
||||
region,
|
||||
workerQueue: run.workerQueue,
|
||||
@@ -455,6 +504,7 @@ export class SpanPresenter extends BasePresenter {
|
||||
payloadType: true,
|
||||
metadata: true,
|
||||
metadataType: true,
|
||||
annotations: true,
|
||||
maxAttempts: true,
|
||||
project: {
|
||||
include: {
|
||||
|
||||
+360
@@ -0,0 +1,360 @@
|
||||
import { BeakerIcon, CpuChipIcon, MagnifyingGlassIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction } from "@remix-run/node";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Suspense } from "react";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { TaskFileName } from "~/components/runs/v3/TaskPath";
|
||||
import { useFuzzyFilter } from "~/hooks/useFuzzyFilter";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
type AgentListItem,
|
||||
type AgentActiveState,
|
||||
agentListPresenter,
|
||||
} from "~/presenters/v3/AgentListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema, v3RunsPath, v3PlaygroundAgentPath } from "~/utils/pathBuilder";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: "Agents | Trigger.dev" }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Project not found" });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
|
||||
}
|
||||
|
||||
const result = await agentListPresenter.call({
|
||||
organizationId: project.organizationId,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
});
|
||||
|
||||
return typeddefer(result);
|
||||
};
|
||||
|
||||
export default function AgentsPage() {
|
||||
const { agents, activeStates, conversationSparklines, costSparklines, tokenSparklines } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const { filterText, setFilterText, filteredItems } = useFuzzyFilter({
|
||||
items: agents,
|
||||
keys: ["slug", "filePath"],
|
||||
});
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Agents" />
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<MainCenteredContainer>
|
||||
<div className="flex flex-col items-center gap-4 py-20">
|
||||
<CpuChipIcon className="size-12 text-indigo-500" />
|
||||
<Header2>No agents deployed</Header2>
|
||||
<Paragraph variant="small" className="max-w-md text-center">
|
||||
Create a chat agent using <code>chat.agent()</code> from{" "}
|
||||
<code>@trigger.dev/sdk/ai</code> and deploy it to see it here.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</MainCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Agents" />
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid h-full grid-rows-1">
|
||||
<div className="flex min-w-0 max-w-full flex-col">
|
||||
<div className="max-h-full overflow-hidden">
|
||||
<div className="flex items-center gap-1 p-2">
|
||||
<Input
|
||||
placeholder="Search agents"
|
||||
variant="tertiary"
|
||||
icon={MagnifyingGlassIcon}
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<Table containerClassName="max-h-full pb-[2.5rem]">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Type</TableHeaderCell>
|
||||
<TableHeaderCell>File</TableHeaderCell>
|
||||
<TableHeaderCell>Active</TableHeaderCell>
|
||||
<TableHeaderCell>Conversations (24h)</TableHeaderCell>
|
||||
<TableHeaderCell>Cost (24h)</TableHeaderCell>
|
||||
<TableHeaderCell>Tokens (24h)</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((agent) => {
|
||||
const path = v3RunsPath(organization, project, environment, {
|
||||
tasks: [agent.slug],
|
||||
});
|
||||
const agentType =
|
||||
(agent.config as { type?: string } | null)?.type ?? "unknown";
|
||||
|
||||
return (
|
||||
<TableRow key={agent.slug} className="group">
|
||||
<TableCell to={path} isTabbableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<CpuChipIcon className="size-[1.125rem] min-w-[1.125rem] text-indigo-500" />
|
||||
}
|
||||
content="Agent"
|
||||
/>
|
||||
<span>{agent.slug}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<Badge variant="extra-small">{formatAgentType(agentType)}</Badge>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<TaskFileName fileName={agent.filePath} variant="extra-extra-small" />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<Suspense fallback={<Spinner color="muted" />}>
|
||||
<TypedAwait resolve={activeStates} errorElement={<>–</>}>
|
||||
{(data) => {
|
||||
const state = data[agent.slug];
|
||||
if (!state || (state.running === 0 && state.suspended === 0)) {
|
||||
return (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="flex items-center gap-1.5 text-xs">
|
||||
{state.running > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span className="size-1.5 rounded-full bg-success" />
|
||||
<span>{state.running}</span>
|
||||
</span>
|
||||
)}
|
||||
{state.running > 0 && state.suspended > 0 && (
|
||||
<span className="text-text-dimmed">·</span>
|
||||
)}
|
||||
{state.suspended > 0 && (
|
||||
<span className="flex items-center gap-0.5">
|
||||
<span className="size-1.5 rounded-full bg-blue-500" />
|
||||
<span>{state.suspended}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1.5">
|
||||
<Suspense fallback={<SparklinePlaceholder />}>
|
||||
<TypedAwait resolve={conversationSparklines} errorElement={<>–</>}>
|
||||
{(data) => (
|
||||
<SparklineWithTotal
|
||||
data={data[agent.slug]}
|
||||
formatTotal={formatCount}
|
||||
/>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1.5">
|
||||
<Suspense fallback={<SparklinePlaceholder />}>
|
||||
<TypedAwait resolve={costSparklines} errorElement={<>–</>}>
|
||||
{(data) => (
|
||||
<SparklineWithTotal
|
||||
data={data[agent.slug]}
|
||||
formatTotal={formatCost}
|
||||
color="text-amber-400"
|
||||
barColor="#F59E0B"
|
||||
/>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="py-1.5">
|
||||
<Suspense fallback={<SparklinePlaceholder />}>
|
||||
<TypedAwait resolve={tokenSparklines} errorElement={<>–</>}>
|
||||
{(data) => (
|
||||
<SparklineWithTotal
|
||||
data={data[agent.slug]}
|
||||
formatTotal={formatTokens}
|
||||
color="text-purple-400"
|
||||
barColor="#A855F7"
|
||||
/>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
<PopoverMenuItem
|
||||
icon={RunsIcon}
|
||||
to={path}
|
||||
title="View runs"
|
||||
leadingIconClassName="text-runs"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
icon={BeakerIcon}
|
||||
to={v3PlaygroundAgentPath(organization, project, environment, agent.slug)}
|
||||
title="Playground"
|
||||
leadingIconClassName="text-indigo-400"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
hiddenButtons={
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={BeakerIcon}
|
||||
leadingIconClassName="text-text-bright"
|
||||
to={v3PlaygroundAgentPath(organization, project, environment, agent.slug)}
|
||||
>
|
||||
Playground
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<Paragraph variant="small" className="flex items-center justify-center">
|
||||
No agents match your filters
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function formatAgentType(type: string): string {
|
||||
switch (type) {
|
||||
case "ai-sdk-chat":
|
||||
return "AI SDK Chat";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
function formatCount(total: number): string {
|
||||
if (total === 0) return "0";
|
||||
if (total >= 1000) return `${(total / 1000).toFixed(1)}k`;
|
||||
return total.toString();
|
||||
}
|
||||
|
||||
function formatCost(total: number): string {
|
||||
if (total === 0) return "$0";
|
||||
if (total < 0.01) return `$${total.toFixed(4)}`;
|
||||
if (total < 1) return `$${total.toFixed(2)}`;
|
||||
return `$${total.toFixed(2)}`;
|
||||
}
|
||||
|
||||
function formatTokens(total: number): string {
|
||||
if (total === 0) return "0";
|
||||
if (total >= 1_000_000) return `${(total / 1_000_000).toFixed(1)}M`;
|
||||
if (total >= 1000) return `${(total / 1000).toFixed(1)}k`;
|
||||
return total.toString();
|
||||
}
|
||||
|
||||
function SparklinePlaceholder() {
|
||||
return <div className="h-6 w-24" />;
|
||||
}
|
||||
|
||||
function SparklineWithTotal({
|
||||
data,
|
||||
formatTotal,
|
||||
color = "text-text-bright",
|
||||
barColor = "#3B82F6",
|
||||
}: {
|
||||
data?: number[];
|
||||
formatTotal: (total: number) => string;
|
||||
color?: string;
|
||||
barColor?: string;
|
||||
}) {
|
||||
if (!data || data.every((v) => v === 0)) {
|
||||
return <span className="text-text-dimmed">–</span>;
|
||||
}
|
||||
|
||||
const total = data.reduce((sum, v) => sum + v, 0);
|
||||
const max = Math.max(...data);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-5 items-end gap-px">
|
||||
{data.map((value, i) => {
|
||||
const height = max > 0 ? Math.max((value / max) * 100, value > 0 ? 8 : 0) : 0;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="w-[3px] rounded-t-[1px]"
|
||||
style={{
|
||||
height: `${height}%`,
|
||||
backgroundColor: value > 0 ? barColor : "transparent",
|
||||
opacity: value > 0 ? 0.8 : 0,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={cn("text-xs tabular-nums", color)}>{formatTotal(total)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1238
File diff suppressed because it is too large
Load Diff
+189
@@ -0,0 +1,189 @@
|
||||
import { BookOpenIcon, CpuChipIcon } from "@heroicons/react/20/solid";
|
||||
import { json, type MetaFunction } from "@remix-run/node";
|
||||
import { Outlet, useNavigate, useParams, useLoaderData } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectItem,
|
||||
} from "~/components/primitives/Select";
|
||||
import { $replica } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { playgroundPresenter } from "~/presenters/v3/PlaygroundPresenter.server";
|
||||
import { RegionsPresenter } from "~/presenters/v3/RegionsPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { docsPath, EnvironmentParamSchema, v3PlaygroundAgentPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: "Playground | Trigger.dev" }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
|
||||
if (!project) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Project not found" });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, user.id);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, { status: 404, statusText: "Environment not found" });
|
||||
}
|
||||
|
||||
const [agents, backgroundWorkers, regionsResult] = await Promise.all([
|
||||
playgroundPresenter.listAgents({
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
}),
|
||||
$replica.backgroundWorker.findMany({
|
||||
where: { runtimeEnvironmentId: environment.id },
|
||||
select: { version: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 20,
|
||||
}),
|
||||
new RegionsPresenter().call({
|
||||
userId: user.id,
|
||||
projectSlug: projectParam,
|
||||
isAdmin: user.admin || user.isImpersonating,
|
||||
}),
|
||||
]);
|
||||
|
||||
return json({
|
||||
agents,
|
||||
versions: backgroundWorkers.map((w) => w.version),
|
||||
regions: regionsResult.regions,
|
||||
isDev: environment.type === "DEVELOPMENT",
|
||||
});
|
||||
};
|
||||
|
||||
export default function PlaygroundPage() {
|
||||
const { agents } = useLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const navigate = useNavigate();
|
||||
const params = useParams();
|
||||
const selectedAgent = params.agentParam ?? "";
|
||||
|
||||
if (agents.length === 0) {
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Playground" />
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<MainCenteredContainer className="max-w-2xl">
|
||||
<InfoPanel
|
||||
title="Create your first agent"
|
||||
icon={CpuChipIcon}
|
||||
iconClassName="text-indigo-500"
|
||||
panelClassName="max-w-2xl"
|
||||
accessory={
|
||||
<LinkButton
|
||||
to={docsPath("ai-chat/overview")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
Agent docs
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
The Playground lets you test your AI agents with an interactive chat interface,
|
||||
realtime streaming, and conversation history.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
Define a chat agent using{" "}
|
||||
<InlineCode variant="small">chat.agent()</InlineCode>:
|
||||
</Paragraph>
|
||||
<CodeBlock
|
||||
code={`import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { streamText } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
|
||||
export const myAgent = chat.agent({
|
||||
id: "my-agent",
|
||||
run: async ({ messages, signal }) => {
|
||||
return streamText({
|
||||
model: openai("gpt-4o"),
|
||||
messages,
|
||||
abortSignal: signal,
|
||||
});
|
||||
},
|
||||
});`}
|
||||
showLineNumbers={false}
|
||||
showOpenInModal={false}
|
||||
/>
|
||||
<Paragraph variant="small" className="mt-2">
|
||||
Deploy your project and your agents will appear here ready to test.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
</MainCenteredContainer>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Playground" />
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
{selectedAgent ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<MainCenteredContainer>
|
||||
<div className="flex flex-col items-center gap-4 py-20">
|
||||
<CpuChipIcon className="size-10 text-indigo-500/50" />
|
||||
<Header2 className="text-text-dimmed">Select an agent</Header2>
|
||||
<Paragraph variant="small" className="mb-2 max-w-md text-center text-text-dimmed">
|
||||
Choose an agent to start a conversation.
|
||||
</Paragraph>
|
||||
<Select
|
||||
value={selectedAgent}
|
||||
setValue={(slug) => {
|
||||
if (slug && typeof slug === "string") {
|
||||
navigate(v3PlaygroundAgentPath(organization, project, environment, slug));
|
||||
}
|
||||
}}
|
||||
icon={<CpuChipIcon className="size-4 text-indigo-500" />}
|
||||
text={(val) => val || undefined}
|
||||
placeholder="Select an agent..."
|
||||
variant="tertiary/small"
|
||||
items={agents}
|
||||
filter={(item, search) =>
|
||||
item.slug.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
>
|
||||
{(matches) =>
|
||||
matches.map((agent) => (
|
||||
<SelectItem key={agent.slug} value={agent.slug}>
|
||||
<div className="flex items-center gap-2">
|
||||
<CpuChipIcon className="size-3.5 text-indigo-500" />
|
||||
<span>{agent.slug}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</div>
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
+539
@@ -0,0 +1,539 @@
|
||||
import { ArrowsRightLeftIcon, BookOpenIcon, XCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { PageBody } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { AgentView } from "~/components/runs/v3/agent/AgentView";
|
||||
import { RealtimeStreamViewer } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.streams.$streamKey/route";
|
||||
import { RunTag } from "~/components/runs/v3/RunTag";
|
||||
import {
|
||||
descriptionForTaskRunStatus,
|
||||
TaskRunStatusCombo,
|
||||
} from "~/components/runs/v3/TaskRunStatus";
|
||||
import { CloseSessionDialog } from "~/components/sessions/v1/CloseSessionDialog";
|
||||
import { SessionStatusCombo } from "~/components/sessions/v1/SessionStatus";
|
||||
import { $replica } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { SessionPresenter } from "~/presenters/v3/SessionPresenter.server";
|
||||
import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
v3RunPath,
|
||||
v3RunsPath,
|
||||
v3SessionsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = EnvironmentParamSchema.extend({
|
||||
sessionParam: z.string(),
|
||||
});
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [{ title: `Session | Trigger.dev` }];
|
||||
};
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug, envParam, sessionParam } = ParamsSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage("/", request, "Project not found");
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
const presenter = new SessionPresenter($replica);
|
||||
const session = await presenter.call({
|
||||
userId,
|
||||
environmentId: environment.id,
|
||||
sessionParam,
|
||||
});
|
||||
|
||||
if (!session) {
|
||||
throw new Response("Session not found", { status: 404 });
|
||||
}
|
||||
|
||||
return typedjson({ session });
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { session } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const status: SessionStatus =
|
||||
session.closedAt != null
|
||||
? "CLOSED"
|
||||
: session.expiresAt != null && new Date(session.expiresAt).getTime() < Date.now()
|
||||
? "EXPIRED"
|
||||
: "ACTIVE";
|
||||
|
||||
const displayId = session.externalId ?? session.friendlyId;
|
||||
const sessionsPath = v3SessionsPath(organization, project, environment);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle
|
||||
backButton={{ to: sessionsPath, text: "Sessions" }}
|
||||
title={
|
||||
<CopyableText
|
||||
value={displayId}
|
||||
variant="text-below"
|
||||
className="-ml-[0.4375rem] h-6 px-1.5 font-mono text-xs hover:text-text-bright"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("/ai-chat/overview")}
|
||||
>
|
||||
Sessions docs
|
||||
</LinkButton>
|
||||
{status === "ACTIVE" && (
|
||||
<Dialog key={`close-${session.friendlyId}`}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="danger/small" LeadingIcon={XCircleIcon}>
|
||||
Close session…
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<CloseSessionDialog
|
||||
sessionParam={session.friendlyId}
|
||||
environmentId={environment.id}
|
||||
redirectPath={`${sessionsPath}/${session.friendlyId}`}
|
||||
/>
|
||||
</Dialog>
|
||||
)}
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="session-conversation" min={"300px"}>
|
||||
<ConversationPane session={session} />
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="session-handle" />
|
||||
<ResizablePanel
|
||||
id="session-inspector"
|
||||
min="380px"
|
||||
default="420px"
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<InspectorPane session={session} status={status} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type LoadedSession = ReturnType<typeof useTypedLoaderData<typeof loader>>["session"];
|
||||
|
||||
function ConversationPane({ session }: { session: LoadedSession }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { value, replace } = useSearchParams();
|
||||
const isRaw = value("raw") === "1";
|
||||
const stream: "out" | "in" = value("stream") === "in" ? "in" : "out";
|
||||
|
||||
const sessionId = session.agentView.sessionId;
|
||||
const encodedSession = encodeURIComponent(sessionId);
|
||||
const sessionResourceBase = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/sessions/${encodedSession}/realtime/v1`;
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="flex items-center justify-between gap-2 overflow-x-hidden border-b border-grid-bright px-3">
|
||||
<div className="flex items-center gap-2 overflow-x-hidden">
|
||||
<ArrowsRightLeftIcon className="size-4 text-teal-500" />
|
||||
<Header2 className={cn("overflow-x-hidden text-text-bright")}>
|
||||
<span className="truncate">Conversation</span>
|
||||
</Header2>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
name="conversation-view"
|
||||
value={isRaw ? "raw" : "rendered"}
|
||||
variant="secondary/small"
|
||||
options={[
|
||||
{ label: "Rendered", value: "rendered" },
|
||||
{ label: "Raw", value: "raw" },
|
||||
]}
|
||||
onChange={(v) => replace({ raw: v === "raw" ? "1" : undefined })}
|
||||
/>
|
||||
</div>
|
||||
{isRaw ? (
|
||||
<div className="overflow-hidden">
|
||||
<RealtimeStreamViewer
|
||||
key={stream}
|
||||
resourcePath={`${sessionResourceBase}/${stream}`}
|
||||
displayName={`.${stream}`}
|
||||
headerLeft={
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={stream === "out"}
|
||||
layoutId="conversation-stream"
|
||||
onClick={() => replace({ stream: undefined })}
|
||||
>
|
||||
Output
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={stream === "in"}
|
||||
layoutId="conversation-stream"
|
||||
onClick={() => replace({ stream: "in" })}
|
||||
>
|
||||
Input
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="min-w-0 overflow-x-hidden overflow-y-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<AgentView agentView={session.agentView} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InspectorPane({
|
||||
session,
|
||||
status,
|
||||
}: {
|
||||
session: LoadedSession;
|
||||
status: SessionStatus;
|
||||
}) {
|
||||
const { value, replace } = useSearchParams();
|
||||
const tab = value("tab") ?? "overview";
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const displayId = session.externalId ?? session.friendlyId;
|
||||
const allRunsPath = v3RunsPath(organization, project, environment, {
|
||||
tags: [`chat:${displayId}`],
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="flex items-center justify-between gap-2 overflow-x-hidden px-3">
|
||||
<div className="flex items-center gap-2 overflow-x-hidden">
|
||||
<SessionStatusCombo status={status} />
|
||||
<span className="truncate font-mono text-xs text-text-dimmed">
|
||||
{session.friendlyId}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-fit overflow-x-auto px-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<TabContainer>
|
||||
<TabButton
|
||||
isActive={tab === "overview"}
|
||||
layoutId="session-inspector"
|
||||
onClick={() => replace({ tab: "overview" })}
|
||||
shortcut={{ key: "o" }}
|
||||
>
|
||||
Overview
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={tab === "runs"}
|
||||
layoutId="session-inspector"
|
||||
onClick={() => replace({ tab: "runs" })}
|
||||
shortcut={{ key: "r" }}
|
||||
>
|
||||
Runs
|
||||
</TabButton>
|
||||
<TabButton
|
||||
isActive={tab === "metadata"}
|
||||
layoutId="session-inspector"
|
||||
onClick={() => replace({ tab: "metadata" })}
|
||||
shortcut={{ key: "m" }}
|
||||
>
|
||||
Metadata
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
{tab === "overview" ? (
|
||||
<OverviewTab session={session} status={status} />
|
||||
) : tab === "runs" ? (
|
||||
<RunsTab session={session} allRunsPath={allRunsPath} />
|
||||
) : (
|
||||
<MetadataTab session={session} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OverviewTab({
|
||||
session,
|
||||
status,
|
||||
}: {
|
||||
session: LoadedSession;
|
||||
status: SessionStatus;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const isAdmin = useHasAdminAccess();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<SessionStatusCombo status={status} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Friendly ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={session.friendlyId} className="font-mono text-xs" />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{session.externalId ? (
|
||||
<Property.Item>
|
||||
<Property.Label>External ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={session.externalId} className="font-mono text-xs" />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
<Property.Item>
|
||||
<Property.Label>Type</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="font-mono text-xs">{session.type}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Task</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="font-mono text-xs">{session.taskIdentifier}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{session.currentRun ? (
|
||||
<Property.Item>
|
||||
<Property.Label>Current run</Property.Label>
|
||||
<Property.Value>
|
||||
<TextLink
|
||||
to={v3RunPath(organization, project, environment, {
|
||||
friendlyId: session.currentRun.friendlyId,
|
||||
})}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs">{session.currentRun.friendlyId}</span>
|
||||
<SimpleTooltip
|
||||
button={<TaskRunStatusCombo status={session.currentRun.status} />}
|
||||
content={descriptionForTaskRunStatus(session.currentRun.status)}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</span>
|
||||
</TextLink>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
<Property.Item>
|
||||
<Property.Label>Tags</Property.Label>
|
||||
<Property.Value>
|
||||
{session.tags.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{session.tags.map((tag) => (
|
||||
<RunTag key={tag} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Created</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={session.createdAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Updated</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={session.updatedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{session.expiresAt ? (
|
||||
<Property.Item>
|
||||
<Property.Label>
|
||||
{new Date(session.expiresAt).getTime() < Date.now() ? "Expired" : "Expires"}
|
||||
</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={session.expiresAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
{session.closedAt ? (
|
||||
<Property.Item>
|
||||
<Property.Label>Closed</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTime date={session.closedAt} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
{session.closedReason ? (
|
||||
<Property.Item>
|
||||
<Property.Label>Close reason</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="text-xs">{session.closedReason}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
) : null}
|
||||
</Property.Table>
|
||||
<CodeBlock
|
||||
code={JSON.stringify(session.triggerConfig, null, 2)}
|
||||
language="json"
|
||||
rowTitle="Trigger config"
|
||||
maxLines={20}
|
||||
showLineNumbers={false}
|
||||
showTextWrapping
|
||||
/>
|
||||
{isAdmin && (
|
||||
<div className="border-t border-yellow-500/50 pt-2">
|
||||
<Paragraph spacing variant="small" className="text-yellow-500">
|
||||
Admin only
|
||||
</Paragraph>
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Session ID</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="font-mono text-xs">{session.id}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Stream basin</Property.Label>
|
||||
<Property.Value>
|
||||
<span className="font-mono text-xs">
|
||||
{session.streamBasinName ?? "(global)"}
|
||||
</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataTab({ session }: { session: LoadedSession }) {
|
||||
if (session.metadata == null) {
|
||||
return (
|
||||
<Paragraph variant="small/dimmed">No metadata.</Paragraph>
|
||||
);
|
||||
}
|
||||
const json = JSON.stringify(session.metadata, null, 2);
|
||||
return (
|
||||
<CodeBlock code={json} language="json" showLineNumbers={false} showTextWrapping />
|
||||
);
|
||||
}
|
||||
|
||||
function RunsTab({
|
||||
session,
|
||||
allRunsPath,
|
||||
}: {
|
||||
session: LoadedSession;
|
||||
allRunsPath: string;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
if (session.runs.length === 0) {
|
||||
return <Paragraph variant="small/dimmed">No runs yet.</Paragraph>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Property.Table>
|
||||
{session.runs.map((entry) => {
|
||||
const runPath = entry.run
|
||||
? v3RunPath(organization, project, environment, {
|
||||
friendlyId: entry.run.friendlyId,
|
||||
})
|
||||
: undefined;
|
||||
return (
|
||||
<Property.Item key={entry.id}>
|
||||
<Property.Label>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="capitalize">{entry.reason}</span>
|
||||
<span className="text-xs text-text-dimmed">
|
||||
<DateTime date={entry.triggeredAt} />
|
||||
</span>
|
||||
</div>
|
||||
</Property.Label>
|
||||
<Property.Value>
|
||||
{entry.run && runPath ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<TextLink
|
||||
to={runPath}
|
||||
className="group flex flex-wrap items-center gap-x-2 gap-y-0"
|
||||
>
|
||||
<CopyableText
|
||||
value={entry.run.friendlyId}
|
||||
copyValue={entry.run.friendlyId}
|
||||
asChild
|
||||
/>
|
||||
<TaskRunStatusCombo status={entry.run.status} />
|
||||
</TextLink>
|
||||
}
|
||||
content={`Jump to run`}
|
||||
disableHoverableContent
|
||||
/>
|
||||
) : (
|
||||
<span className="text-text-dimmed">–</span>
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
);
|
||||
})}
|
||||
</Property.Table>
|
||||
<div className="flex justify-end">
|
||||
<LinkButton variant="tertiary/small" to={allRunsPath}>
|
||||
View all runs
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+21
-5
@@ -9,18 +9,34 @@ import { docsPath } from "~/utils/pathBuilder";
|
||||
export function SchemaTabContent({
|
||||
schema,
|
||||
inferredSchema,
|
||||
title = "Payload schema",
|
||||
description,
|
||||
showDocsLink = true,
|
||||
}: {
|
||||
schema?: unknown;
|
||||
inferredSchema?: unknown;
|
||||
title?: string;
|
||||
description?: string;
|
||||
showDocsLink?: boolean;
|
||||
}) {
|
||||
if (schema) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Header3 className="text-text-bright">Payload schema</Header3>
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
JSON Schema defined by this task via{" "}
|
||||
<TextLink to={docsPath("tasks/schemaTask")}>schemaTask</TextLink>.
|
||||
</Paragraph>
|
||||
<Header3 className="text-text-bright">{title}</Header3>
|
||||
{showDocsLink ? (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{description ?? (
|
||||
<>
|
||||
JSON Schema defined by this task via{" "}
|
||||
<TextLink to={docsPath("tasks/schemaTask")}>schemaTask</TextLink>.
|
||||
</>
|
||||
)}
|
||||
</Paragraph>
|
||||
) : description ? (
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{description}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
<CodeBlock
|
||||
code={JSON.stringify(schema, null, 2)}
|
||||
language="json"
|
||||
|
||||
+307
@@ -0,0 +1,307 @@
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import type { Prisma } from "@trigger.dev/database";
|
||||
import { SessionId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { prisma } from "~/db.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { mintSessionToken } from "~/services/realtime/mintSessionToken.server";
|
||||
import { ensureRunForSession } from "~/services/realtime/sessionRunManager.server";
|
||||
|
||||
const PlaygroundAction = z.object({
|
||||
intent: z.enum(["create", "start", "save", "delete"]),
|
||||
agentSlug: z.string(),
|
||||
// For create
|
||||
conversationId: z.string().optional(),
|
||||
// For start (replaces "trigger" — atomically creates the Session and
|
||||
// triggers its first run, returns a session-scoped PAT)
|
||||
chatId: z.string().optional(),
|
||||
payload: z.string().optional(),
|
||||
clientData: z.string().optional(),
|
||||
tags: z.string().optional(),
|
||||
machine: z.string().optional(),
|
||||
maxAttempts: z.string().optional(),
|
||||
maxDuration: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
region: z.string().optional(),
|
||||
// For save
|
||||
messages: z.string().optional(),
|
||||
lastEventId: z.string().optional(),
|
||||
// For delete
|
||||
deleteConversationId: z.string().optional(),
|
||||
});
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return json({ error: "Environment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const parsed = PlaygroundAction.safeParse(Object.fromEntries(formData));
|
||||
if (!parsed.success) {
|
||||
return json({ error: "Invalid request", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const { intent } = parsed.data;
|
||||
|
||||
switch (intent) {
|
||||
case "create": {
|
||||
const { agentSlug } = parsed.data;
|
||||
const chatId = crypto.randomUUID();
|
||||
|
||||
const conversation = await prisma.playgroundConversation.create({
|
||||
data: {
|
||||
chatId,
|
||||
agentSlug,
|
||||
projectId: project.id,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
return json({
|
||||
conversationId: conversation.id,
|
||||
chatId,
|
||||
});
|
||||
}
|
||||
|
||||
case "start": {
|
||||
const {
|
||||
agentSlug,
|
||||
chatId,
|
||||
payload: payloadStr,
|
||||
clientData,
|
||||
tags: tagsStr,
|
||||
machine,
|
||||
maxAttempts,
|
||||
maxDuration,
|
||||
version,
|
||||
region,
|
||||
} = parsed.data;
|
||||
|
||||
if (!chatId) {
|
||||
return json({ error: "chatId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Parse the optional initial payload — used as the basePayload
|
||||
// for the first run trigger. After session create, the agent
|
||||
// reads subsequent messages from `.in/append` so the payload
|
||||
// here is just the bootstrap.
|
||||
let payload: Record<string, any> = {};
|
||||
try {
|
||||
payload = payloadStr ? (JSON.parse(payloadStr) as Record<string, any>) : {};
|
||||
} catch {
|
||||
return json({ error: "Invalid payload JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
let parsedClientData: unknown;
|
||||
try {
|
||||
parsedClientData = clientData ? JSON.parse(clientData) : undefined;
|
||||
} catch {
|
||||
/* invalid JSON — fall through with undefined */
|
||||
}
|
||||
|
||||
const tags = [
|
||||
`chat:${chatId}`,
|
||||
"playground:true",
|
||||
...(tagsStr ? tagsStr.split(",").map((t) => t.trim()).filter(Boolean) : []),
|
||||
].slice(0, 5);
|
||||
|
||||
const triggerConfig = {
|
||||
basePayload: {
|
||||
// The first run boots before the user's first message lands on
|
||||
// `.in/append`, so it sees `messages: []` and `trigger: "preload"`.
|
||||
// Mirrors the defaults in `chat.createStartSessionAction` —
|
||||
// chat.agent's runtime reads `payload.messages.length` so the
|
||||
// field must be an array, not undefined.
|
||||
messages: [],
|
||||
trigger: "preload",
|
||||
...payload,
|
||||
chatId,
|
||||
...(parsedClientData ? { metadata: parsedClientData } : {}),
|
||||
},
|
||||
...(machine ? { machine } : {}),
|
||||
tags,
|
||||
...(maxAttempts ? { maxAttempts: parseInt(maxAttempts, 10) } : {}),
|
||||
...(maxDuration ? { maxDuration: parseInt(maxDuration, 10) } : {}),
|
||||
...(version ? { lockToVersion: version } : {}),
|
||||
...(region ? { region } : {}),
|
||||
};
|
||||
|
||||
// Atomic: upsert the Session, then trigger the first run via
|
||||
// the optimistic-claim path. The transport's `accessToken`
|
||||
// callback hits this endpoint on initial start AND on 401 — the
|
||||
// upsert + ensureRunForSession combo is idempotent so repeat
|
||||
// calls converge to the same session and (if alive) reuse the
|
||||
// existing run.
|
||||
const { id: sessionId, friendlyId } = SessionId.generate();
|
||||
const session = await prisma.session.upsert({
|
||||
where: {
|
||||
runtimeEnvironmentId_externalId: {
|
||||
runtimeEnvironmentId: environment.id,
|
||||
externalId: chatId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
id: sessionId,
|
||||
friendlyId,
|
||||
externalId: chatId,
|
||||
type: "chat.agent",
|
||||
taskIdentifier: agentSlug,
|
||||
triggerConfig: triggerConfig as unknown as Prisma.InputJsonValue,
|
||||
tags: ["playground"],
|
||||
projectId: project.id,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
organizationId: project.organizationId,
|
||||
// Stamp the org's S2 basin so realtime reads on this
|
||||
// session's `.in/.out` channels resolve without joining
|
||||
// Organization. Null until per-org basins are provisioned.
|
||||
streamBasinName: environment.organization.streamBasinName,
|
||||
},
|
||||
update: {
|
||||
// Refresh trigger config in case agent version / params changed
|
||||
triggerConfig: triggerConfig as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
const ensureResult = await ensureRunForSession({
|
||||
session,
|
||||
environment,
|
||||
reason: "initial",
|
||||
});
|
||||
|
||||
const run = await prisma.taskRun.findFirst({
|
||||
where: { id: ensureResult.runId },
|
||||
select: { friendlyId: true },
|
||||
});
|
||||
if (!run) {
|
||||
return json({ error: "Triggered run not found" }, { status: 500 });
|
||||
}
|
||||
|
||||
// Title: prefer the user message text on first start, else a
|
||||
// generic placeholder. The conversation row is the playground's
|
||||
// own surface — separate from the Session row that drives the
|
||||
// trigger.
|
||||
const firstMessage = payload?.messages?.[0];
|
||||
const firstText =
|
||||
firstMessage?.parts?.find((p: any) => p.type === "text")?.text ?? "New conversation";
|
||||
const title = firstText.length > 60 ? firstText.slice(0, 60) + "..." : firstText;
|
||||
|
||||
const conversation = await prisma.playgroundConversation.upsert({
|
||||
where: {
|
||||
chatId_runtimeEnvironmentId: {
|
||||
chatId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
chatId,
|
||||
title,
|
||||
agentSlug,
|
||||
runId: ensureResult.runId,
|
||||
clientData: parsedClientData as any,
|
||||
projectId: project.id,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
userId,
|
||||
},
|
||||
update: {
|
||||
runId: ensureResult.runId,
|
||||
clientData: parsedClientData as any,
|
||||
title,
|
||||
},
|
||||
});
|
||||
|
||||
const publicAccessToken = await mintSessionToken(environment, chatId);
|
||||
|
||||
return json({
|
||||
runId: run.friendlyId,
|
||||
publicAccessToken,
|
||||
conversationId: conversation.id,
|
||||
});
|
||||
}
|
||||
|
||||
case "save": {
|
||||
const { chatId, messages: messagesStr, lastEventId } = parsed.data;
|
||||
if (!chatId) {
|
||||
return json({ error: "chatId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
let messagesData: unknown;
|
||||
try {
|
||||
messagesData = messagesStr ? JSON.parse(messagesStr) : undefined;
|
||||
} catch {
|
||||
return json({ error: "Invalid messages JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
// Extract title from the first user message if the conversation still has the default title.
|
||||
// This handles the case where a preloaded conversation gets its first real message
|
||||
// via the input stream (bypassing the trigger action that normally sets the title).
|
||||
let titleUpdate: { title: string } | undefined;
|
||||
if (messagesData && Array.isArray(messagesData)) {
|
||||
const existing = await prisma.playgroundConversation.findFirst({
|
||||
where: { chatId, runtimeEnvironmentId: environment.id, userId },
|
||||
select: { title: true },
|
||||
});
|
||||
|
||||
if (existing?.title === "New conversation") {
|
||||
const firstUserMsg = messagesData.find(
|
||||
(m: any) => m.role === "user"
|
||||
) as Record<string, any> | undefined;
|
||||
const firstText =
|
||||
firstUserMsg?.parts?.find((p: any) => p.type === "text")?.text ??
|
||||
firstUserMsg?.content;
|
||||
if (firstText && typeof firstText === "string") {
|
||||
titleUpdate = {
|
||||
title: firstText.length > 60 ? firstText.slice(0, 60) + "..." : firstText,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.playgroundConversation.updateMany({
|
||||
where: {
|
||||
chatId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
userId,
|
||||
},
|
||||
data: {
|
||||
...(messagesData ? { messages: messagesData as any } : {}),
|
||||
...(lastEventId ? { lastEventId } : {}),
|
||||
...titleUpdate,
|
||||
},
|
||||
});
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
const { deleteConversationId } = parsed.data;
|
||||
if (!deleteConversationId) {
|
||||
return json({ error: "deleteConversationId is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.playgroundConversation.deleteMany({
|
||||
where: {
|
||||
id: deleteConversationId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
return json({ ok: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import { nanoid } from "nanoid";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
|
||||
import { ensureRunForSession } from "~/services/realtime/sessionRunManager.server";
|
||||
import {
|
||||
canonicalSessionAddressingKey,
|
||||
resolveSessionByIdOrExternalId,
|
||||
} from "~/services/realtime/sessions.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { drainSessionStreamWaitpoints } from "~/services/sessionStreamWaitpointCache.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
import { ServiceValidationError } from "~/v3/services/common.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
session: z.string(),
|
||||
io: z.enum(["out", "in"]),
|
||||
});
|
||||
|
||||
// S2 record body cap. Mirrors the public /realtime/v1/sessions/:s/:io/append
|
||||
// route — keep it well under S2's 1 MiB per-record limit so JSON wrapping,
|
||||
// string escaping, and any future per-record headers stay safe.
|
||||
const MAX_APPEND_BODY_BYTES = 1024 * 512;
|
||||
|
||||
// POST: Append a single record to a Session channel from the dashboard
|
||||
// playground. Mirrors the public `POST /realtime/v1/sessions/:session/:io/append`
|
||||
// but authenticates via the dashboard session cookie instead of a
|
||||
// session-scoped JWT.
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
const { session: sessionParam, io } = ParamsSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return json({ ok: false, error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return json({ ok: false, error: "Environment not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const contentLength = request.headers.get("content-length");
|
||||
const contentLengthNum = contentLength ? parseInt(contentLength, 10) : NaN;
|
||||
if (Number.isNaN(contentLengthNum) || contentLengthNum > MAX_APPEND_BODY_BYTES) {
|
||||
return json({ ok: false, error: "Request body too large" }, { status: 413 });
|
||||
}
|
||||
|
||||
const session = await resolveSessionByIdOrExternalId(
|
||||
$replica,
|
||||
environment.id,
|
||||
sessionParam
|
||||
);
|
||||
if (!session) {
|
||||
return json({ ok: false, error: "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 }
|
||||
);
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session });
|
||||
|
||||
if (!(realtimeStream instanceof S2RealtimeStreams)) {
|
||||
return json(
|
||||
{ ok: false, error: "Session channels require the S2 realtime backend" },
|
||||
{ status: 501 }
|
||||
);
|
||||
}
|
||||
|
||||
// Probe + ensure a live run before appending (mirrors public route).
|
||||
// Best-effort: failure here doesn't block the append — the record is
|
||||
// durable; the next append retries the ensure.
|
||||
const [ensureError] = await tryCatch(
|
||||
ensureRunForSession({
|
||||
session,
|
||||
environment,
|
||||
reason: "continuation",
|
||||
})
|
||||
);
|
||||
if (ensureError) {
|
||||
logger.error("Failed to ensureRunForSession on playground .in/append", {
|
||||
sessionId: session.id,
|
||||
externalId: session.externalId,
|
||||
error: ensureError,
|
||||
});
|
||||
}
|
||||
|
||||
const addressingKey = canonicalSessionAddressingKey(session, sessionParam);
|
||||
|
||||
const part = await request.text();
|
||||
const partId = request.headers.get("X-Part-Id") ?? nanoid(7);
|
||||
|
||||
const [appendError] = await tryCatch(
|
||||
realtimeStream.appendPartToSessionStream(part, partId, addressingKey, io)
|
||||
);
|
||||
|
||||
if (appendError) {
|
||||
if (appendError instanceof ServiceValidationError) {
|
||||
return json(
|
||||
{ ok: false, error: appendError.message },
|
||||
{ status: appendError.status ?? 422 }
|
||||
);
|
||||
}
|
||||
return json({ ok: false, error: appendError.message }, { status: 500 });
|
||||
}
|
||||
|
||||
// Drain any waitpoints registered for this channel — same as the
|
||||
// public append. Best-effort; failure doesn't fail the append.
|
||||
const [drainError, waitpointIds] = await tryCatch(
|
||||
drainSessionStreamWaitpoints(addressingKey, io)
|
||||
);
|
||||
if (drainError) {
|
||||
logger.error("Failed to drain session stream waitpoints (playground)", {
|
||||
addressingKey,
|
||||
io,
|
||||
error: drainError,
|
||||
});
|
||||
} else if (waitpointIds && waitpointIds.length > 0) {
|
||||
await Promise.all(
|
||||
waitpointIds.map(async (waitpointId) => {
|
||||
const [completeError] = await tryCatch(
|
||||
engine.completeWaitpoint({
|
||||
id: waitpointId,
|
||||
output: {
|
||||
value: part,
|
||||
type: "application/json",
|
||||
isError: false,
|
||||
},
|
||||
})
|
||||
);
|
||||
if (completeError) {
|
||||
logger.error("Failed to complete session stream waitpoint (playground)", {
|
||||
addressingKey,
|
||||
io,
|
||||
waitpointId,
|
||||
error: completeError,
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return json({ ok: true }, { status: 200 });
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
||||
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
|
||||
import {
|
||||
canonicalSessionAddressingKey,
|
||||
resolveSessionByIdOrExternalId,
|
||||
} from "~/services/realtime/sessions.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
session: z.string(),
|
||||
io: z.enum(["out", "in"]),
|
||||
});
|
||||
|
||||
// HEAD/GET: SSE subscribe to a Session channel from the dashboard
|
||||
// playground. Mirrors the public `GET /realtime/v1/sessions/:session/:io`
|
||||
// route but authenticates via the dashboard session cookie instead of a
|
||||
// session-scoped JWT — the playground transport never holds a PAT.
|
||||
//
|
||||
// `:session` accepts either the `session_*` friendlyId or the externalId
|
||||
// the playground assigned (`chatId`). Resolution is environment-scoped
|
||||
// so users can't subscribe to sessions from other envs.
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
const { session: sessionParam, io } = ParamsSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const session = await resolveSessionByIdOrExternalId(
|
||||
$replica,
|
||||
environment.id,
|
||||
sessionParam
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return new Response("Session not found", { status: 404 });
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session });
|
||||
|
||||
if (!(realtimeStream instanceof S2RealtimeStreams)) {
|
||||
return new Response("Session channels require the S2 realtime backend", {
|
||||
status: 501,
|
||||
});
|
||||
}
|
||||
|
||||
if (request.method === "HEAD") {
|
||||
// No last-chunk-index on the S2 backend (clients resume via
|
||||
// Last-Event-ID on the SSE stream directly). Return 200 with a
|
||||
// zero index for compatibility with the run-stream shape.
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { "X-Last-Chunk-Index": "0" },
|
||||
});
|
||||
}
|
||||
|
||||
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
|
||||
const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds");
|
||||
let timeoutInSeconds: number | undefined;
|
||||
if (timeoutInSecondsRaw !== null) {
|
||||
timeoutInSeconds = Number(timeoutInSecondsRaw);
|
||||
if (!Number.isInteger(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600) {
|
||||
return new Response("Invalid timeout", { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const addressingKey = canonicalSessionAddressingKey(session, sessionParam);
|
||||
|
||||
return realtimeStream.streamResponseFromSessionStream(
|
||||
request,
|
||||
addressingKey,
|
||||
io,
|
||||
getRequestAbortSignal(),
|
||||
{ lastEventId, timeoutInSeconds }
|
||||
);
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
||||
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
|
||||
import {
|
||||
canonicalSessionAddressingKey,
|
||||
resolveSessionByIdOrExternalId,
|
||||
} from "~/services/realtime/sessions.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runParam: z.string(),
|
||||
sessionId: z.string(),
|
||||
io: z.enum(["out", "in"]),
|
||||
});
|
||||
|
||||
// GET: SSE stream subscription for a backing Session's `.out` / `.in`
|
||||
// channel. Dashboard-auth counterpart to the public API's
|
||||
// `/realtime/v1/sessions/:sessionId/:io` endpoint. Used by the Agent tab
|
||||
// in the span inspector to observe assistant chunks (`.out`) and
|
||||
// user-side ChatInputChunk payloads (`.in`) for a chat.agent run.
|
||||
//
|
||||
// The `:sessionId` segment accepts either the `session_*` friendlyId or
|
||||
// the externalId the transport registered for the chat (typically the
|
||||
// browser's `chatId`). Runs pre-dating the Sessions migration that have
|
||||
// `chatId` but no `sessionId` in the payload take the externalId path.
|
||||
//
|
||||
// Authenticated by the dashboard session — the user must have access to
|
||||
// the project, environment, and run. The run binds this resource
|
||||
// hierarchy; the session identity is verified against the environment.
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
const { runParam, sessionId, io } = ParamsSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Verify the run lives in this environment — keeps callers from
|
||||
// subscribing to arbitrary sessions via `/runs/$runParam/...`.
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: runParam,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
select: { id: true, friendlyId: true },
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return new Response("Run not found", { status: 404 });
|
||||
}
|
||||
|
||||
const session = await resolveSessionByIdOrExternalId(
|
||||
$replica,
|
||||
environment.id,
|
||||
sessionId
|
||||
);
|
||||
|
||||
if (!session) {
|
||||
return new Response("Session not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Enforce run ↔ session linkage. Without this, knowledge of a runId in
|
||||
// this environment is enough to subscribe to any session in the same
|
||||
// environment — defeats the point of scoping subscriptions through the
|
||||
// run route. SessionRun.runId is indexed (@unique), so this is cheap.
|
||||
const linkedSessionRun = await $replica.sessionRun.findFirst({
|
||||
where: { runId: run.id, sessionId: session.id },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!linkedSessionRun) {
|
||||
return new Response("Session not found for run", { status: 404 });
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session });
|
||||
|
||||
if (!(realtimeStream instanceof S2RealtimeStreams)) {
|
||||
return new Response("Session channels require the S2 realtime backend", {
|
||||
status: 501,
|
||||
});
|
||||
}
|
||||
|
||||
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
|
||||
const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds");
|
||||
let timeoutInSeconds: number | undefined;
|
||||
if (timeoutInSecondsRaw !== null) {
|
||||
timeoutInSeconds = Number(timeoutInSecondsRaw);
|
||||
if (!Number.isInteger(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600) {
|
||||
return new Response("Invalid timeout", { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// The agent writes via the canonical addressing key (externalId if
|
||||
// set, else friendlyId). Subscribe with the same key so the read
|
||||
// hits the same S2 stream the agent is writing into.
|
||||
const addressingKey = canonicalSessionAddressingKey(session, sessionId);
|
||||
|
||||
return realtimeStream.streamResponseFromSessionStream(
|
||||
request,
|
||||
addressingKey,
|
||||
io,
|
||||
getRequestAbortSignal(),
|
||||
{ lastEventId, timeoutInSeconds }
|
||||
);
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runParam: z.string(),
|
||||
runId: z.string(),
|
||||
streamId: z.string(),
|
||||
});
|
||||
|
||||
// GET: SSE stream subscription for a run's realtime output stream.
|
||||
//
|
||||
// The run-scoped equivalent of the playground stream route. Used by the
|
||||
// Agent tab in the span inspector to subscribe to the run's chat output
|
||||
// stream (streamed via `pipeChat` on the task side) through the dashboard
|
||||
// instead of hitting the public API directly.
|
||||
//
|
||||
// Authenticated by the dashboard session — the user must have access to
|
||||
// the project and environment.
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
const { runParam, runId, streamId } = ParamsSchema.parse(params);
|
||||
|
||||
// Defensive: callers should pass the same friendly ID for both the route
|
||||
// `:runParam` segment and the stream `:runId` segment.
|
||||
if (runParam !== runId) {
|
||||
return new Response("Run ID mismatch", { status: 400 });
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: runId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
realtimeStreamsVersion: true,
|
||||
streamBasinName: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return new Response("Run not found", { status: 404 });
|
||||
}
|
||||
|
||||
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
|
||||
const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds");
|
||||
let timeoutInSeconds: number | undefined;
|
||||
if (timeoutInSecondsRaw !== null) {
|
||||
timeoutInSeconds = Number(timeoutInSecondsRaw);
|
||||
if (!Number.isInteger(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600) {
|
||||
return new Response("Invalid timeout", { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(environment, run.realtimeStreamsVersion, {
|
||||
run,
|
||||
});
|
||||
|
||||
// `request.signal` is severed by Remix's Request.clone() + Node undici GC bug
|
||||
// (see apps/webapp/CLAUDE.md). Use the Express res.on('close')-backed signal so
|
||||
// the upstream stream fetch actually aborts when the user closes the tab.
|
||||
return realtimeStream.streamResponse(
|
||||
request,
|
||||
run.friendlyId,
|
||||
streamId,
|
||||
getRequestAbortSignal(),
|
||||
{
|
||||
lastEventId,
|
||||
timeoutInSeconds,
|
||||
}
|
||||
);
|
||||
}
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runParam: z.string(),
|
||||
runId: z.string(),
|
||||
streamId: z.string(),
|
||||
});
|
||||
|
||||
// GET: SSE stream subscription for a run's realtime INPUT stream.
|
||||
//
|
||||
// Dashboard-auth counterpart to the public API's
|
||||
// `/realtime/v1/streams/:runId/input/:streamId` endpoint. Used by the Agent
|
||||
// tab in the span inspector to observe user messages sent to an agent run
|
||||
// over the `chat-messages` input stream.
|
||||
//
|
||||
// The underlying S2 stream name is `$trigger.input:${streamId}` (mirrors the
|
||||
// naming used on the write side in `sendInputStream`). The realtime stream
|
||||
// instance handles the actual SSE proxy; this route just enforces session
|
||||
// auth and resolves the run.
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
const { runParam, runId, streamId } = ParamsSchema.parse(params);
|
||||
|
||||
// Defensive: callers should pass the same friendly ID for both the route
|
||||
// `:runParam` segment and the stream `:runId` segment.
|
||||
if (runParam !== runId) {
|
||||
return new Response("Run ID mismatch", { status: 400 });
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const run = await $replica.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: runId,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
friendlyId: true,
|
||||
realtimeStreamsVersion: true,
|
||||
streamBasinName: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return new Response("Run not found", { status: 404 });
|
||||
}
|
||||
|
||||
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
|
||||
const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds");
|
||||
let timeoutInSeconds: number | undefined;
|
||||
if (timeoutInSecondsRaw !== null) {
|
||||
timeoutInSeconds = Number(timeoutInSecondsRaw);
|
||||
if (!Number.isInteger(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600) {
|
||||
return new Response("Invalid timeout", { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(environment, run.realtimeStreamsVersion, {
|
||||
run,
|
||||
});
|
||||
|
||||
// `request.signal` is severed by Remix's Request.clone() + Node undici GC bug
|
||||
// (see apps/webapp/CLAUDE.md). Use the Express res.on('close')-backed signal.
|
||||
return realtimeStream.streamResponse(
|
||||
request,
|
||||
run.friendlyId,
|
||||
`$trigger.input:${streamId}`,
|
||||
getRequestAbortSignal(),
|
||||
{
|
||||
lastEventId,
|
||||
timeoutInSeconds,
|
||||
}
|
||||
);
|
||||
}
|
||||
+48
-1
@@ -53,6 +53,7 @@ import {
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { SessionStatusCombo } from "~/components/sessions/v1/SessionStatus";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { RunTimeline, RunTimelineEvent, SpanTimeline } from "~/components/run/RunTimeline";
|
||||
@@ -88,6 +89,7 @@ import { formatCurrencyAccurate } from "~/utils/numberFormatter";
|
||||
import {
|
||||
docsPath,
|
||||
v3BatchPath,
|
||||
v3SessionPath,
|
||||
v3DeploymentVersionPath,
|
||||
v3LogsPath,
|
||||
v3RunDownloadLogsPath,
|
||||
@@ -124,7 +126,26 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
linkedRunId,
|
||||
});
|
||||
|
||||
return typedjson(result);
|
||||
if (!result) {
|
||||
return redirectWithErrorMessage(
|
||||
v3RunPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: runParam }
|
||||
),
|
||||
request,
|
||||
`Event not found.`
|
||||
);
|
||||
}
|
||||
|
||||
// Reconstruct the discriminated union explicitly. Spreading
|
||||
// `{ ...result }` collapses the union and loses the
|
||||
// `type === "run" | "span"` discriminant downstream in `SpanView`.
|
||||
if (result.type === "run") {
|
||||
return typedjson({ type: "run" as const, run: result.run });
|
||||
}
|
||||
return typedjson({ type: "span" as const, span: result.span });
|
||||
} catch (error) {
|
||||
logger.error("Error loading span", {
|
||||
projectParam,
|
||||
@@ -618,6 +639,32 @@ function RunBody({
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{run.session && (
|
||||
<Property.Item>
|
||||
<Property.Label>Session</Property.Label>
|
||||
<Property.Value>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<TextLink
|
||||
to={v3SessionPath(organization, project, environment, {
|
||||
friendlyId: run.session.friendlyId,
|
||||
})}
|
||||
className="group flex flex-wrap items-center gap-x-2 gap-y-0"
|
||||
>
|
||||
<CopyableText
|
||||
value={run.session.externalId ?? run.session.friendlyId}
|
||||
copyValue={run.session.externalId ?? run.session.friendlyId}
|
||||
asChild
|
||||
/>
|
||||
<SessionStatusCombo status={run.session.status} />
|
||||
</TextLink>
|
||||
}
|
||||
content={`Jump to session (${run.session.reason})`}
|
||||
disableHoverableContent
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
<Property.Item>
|
||||
<Property.Label>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
+31
-12
@@ -101,17 +101,32 @@ export function RealtimeStreamViewer({
|
||||
streamKey,
|
||||
metadata,
|
||||
displayName,
|
||||
resourcePath: resourcePathOverride,
|
||||
headerLabel,
|
||||
headerLeft,
|
||||
}: {
|
||||
runId: string;
|
||||
streamKey: string;
|
||||
metadata: Record<string, unknown> | undefined;
|
||||
runId?: string;
|
||||
streamKey?: string;
|
||||
metadata?: Record<string, unknown> | undefined;
|
||||
displayName?: string;
|
||||
/** Pre-built resource path. When provided, `runId`/`streamKey` are unused. */
|
||||
resourcePath?: string;
|
||||
/** Override the "Stream:" / "Input stream:" prefix in the header. */
|
||||
headerLabel?: string;
|
||||
/**
|
||||
* Replaces the default "Stream: <name>" content next to the connection
|
||||
* icon. Use to inline tabs or other navigation in place of a static
|
||||
* label.
|
||||
*/
|
||||
headerLeft?: React.ReactNode;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runId}/streams/${streamKey}`;
|
||||
const resourcePath =
|
||||
resourcePathOverride ??
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/${runId}/streams/${streamKey}`;
|
||||
|
||||
const startIndex = typeof metadata?.startIndex === "number" ? metadata.startIndex : undefined;
|
||||
const { chunks, error, isConnected } = useRealtimeStream(resourcePath, startIndex);
|
||||
@@ -229,7 +244,7 @@ export function RealtimeStreamViewer({
|
||||
{/* Header */}
|
||||
<div className="border-b border-grid-bright bg-background-bright @container">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 px-3 py-2.5 @[300px]:flex-nowrap">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
@@ -244,13 +259,17 @@ export function RealtimeStreamViewer({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Paragraph
|
||||
variant="small/bright"
|
||||
className="mb-0 flex min-w-0 items-center gap-1 truncate whitespace-nowrap"
|
||||
>
|
||||
<span>{displayName ? "Input stream:" : "Stream:"}</span>
|
||||
<span className="truncate font-mono text-text-dimmed">{displayName ?? streamKey}</span>
|
||||
</Paragraph>
|
||||
{headerLeft ?? (
|
||||
<Paragraph
|
||||
variant="small/bright"
|
||||
className="mb-0 flex min-w-0 items-center gap-1 truncate whitespace-nowrap"
|
||||
>
|
||||
<span>{headerLabel ?? (displayName ? "Input stream:" : "Stream:")}</span>
|
||||
<span className="truncate font-mono text-text-dimmed">
|
||||
{displayName ?? streamKey ?? ""}
|
||||
</span>
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3 @[300px]:w-auto @[300px]:flex-nowrap">
|
||||
<Paragraph variant="small" className="mb-0 whitespace-nowrap">
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { $replica } from "~/db.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server";
|
||||
import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server";
|
||||
import {
|
||||
canonicalSessionAddressingKey,
|
||||
resolveSessionByIdOrExternalId,
|
||||
} from "~/services/realtime/sessions.server";
|
||||
import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
sessionParam: z.string(),
|
||||
io: z.enum(["out", "in"]),
|
||||
});
|
||||
|
||||
// GET: SSE stream subscription for a Session's `.out` / `.in` channel.
|
||||
// Dashboard-auth counterpart to the public API's
|
||||
// `/realtime/v1/sessions/:sessionId/:io`. Used by the Sessions detail
|
||||
// view (and the run page's Agent tab) to observe assistant chunks
|
||||
// (`.out`) and user-side ChatInputChunk payloads (`.in`).
|
||||
//
|
||||
// The `:sessionParam` segment accepts either the `session_*` friendlyId
|
||||
// or the externalId the transport registered for the chat (typically the
|
||||
// browser's `chatId`).
|
||||
//
|
||||
// Authenticated by the dashboard session — the user must have access to
|
||||
// the project and environment. The session must live in that environment.
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
const { sessionParam, io } = ParamsSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const session = await resolveSessionByIdOrExternalId($replica, environment.id, sessionParam);
|
||||
if (!session) {
|
||||
return new Response("Session not found", { status: 404 });
|
||||
}
|
||||
|
||||
const realtimeStream = getRealtimeStreamInstance(environment, "v2", { session });
|
||||
|
||||
if (!(realtimeStream instanceof S2RealtimeStreams)) {
|
||||
return new Response("Session channels require the S2 realtime backend", {
|
||||
status: 501,
|
||||
});
|
||||
}
|
||||
|
||||
const lastEventId = request.headers.get("Last-Event-ID") || undefined;
|
||||
const timeoutInSecondsRaw = request.headers.get("Timeout-Seconds");
|
||||
let timeoutInSeconds: number | undefined;
|
||||
if (timeoutInSecondsRaw !== null) {
|
||||
timeoutInSeconds = Number(timeoutInSecondsRaw);
|
||||
if (!Number.isInteger(timeoutInSeconds) || timeoutInSeconds < 1 || timeoutInSeconds > 600) {
|
||||
return new Response("Invalid timeout", { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
// The agent writes via the canonical addressing key (externalId if
|
||||
// set, else friendlyId). Subscribe with the same key so the read
|
||||
// hits the same S2 stream the agent is writing into.
|
||||
const addressingKey = canonicalSessionAddressingKey(session, sessionParam);
|
||||
|
||||
return realtimeStream.streamResponseFromSessionStream(
|
||||
request,
|
||||
addressingKey,
|
||||
io,
|
||||
getRequestAbortSignal(),
|
||||
{ lastEventId, timeoutInSeconds }
|
||||
);
|
||||
}
|
||||
+61
-2
@@ -21,6 +21,7 @@ const RequestSchema = z.object({
|
||||
taskIdentifier: z.string().max(256),
|
||||
payloadSchema: z.string().max(50_000).optional(),
|
||||
currentPayload: z.string().max(50_000).optional(),
|
||||
isAgent: z.enum(["true", "false"]).optional(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
@@ -64,16 +65,20 @@ export async function action({ request, params }: ActionFunctionArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
const { prompt, taskIdentifier, payloadSchema, currentPayload } = submission.data;
|
||||
const { prompt, taskIdentifier, payloadSchema, currentPayload, isAgent } = submission.data;
|
||||
const agentMode = isAgent === "true";
|
||||
|
||||
logger.info("[AI payload] Generating payload", {
|
||||
taskIdentifier,
|
||||
hasPayloadSchema: !!payloadSchema,
|
||||
hasCurrentPayload: !!currentPayload,
|
||||
promptLength: prompt.length,
|
||||
agentMode,
|
||||
});
|
||||
|
||||
const systemPrompt = buildSystemPrompt(taskIdentifier, payloadSchema, currentPayload);
|
||||
const systemPrompt = agentMode
|
||||
? buildAgentClientDataPrompt(taskIdentifier, payloadSchema, currentPayload)
|
||||
: buildSystemPrompt(taskIdentifier, payloadSchema, currentPayload);
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
@@ -234,6 +239,60 @@ async function getTaskFromDeployment(environmentId: string, taskIdentifier: stri
|
||||
return { fileId: task.fileId };
|
||||
}
|
||||
|
||||
function buildAgentClientDataPrompt(
|
||||
taskIdentifier: string,
|
||||
payloadSchema?: string,
|
||||
currentPayload?: string
|
||||
): string {
|
||||
let prompt = `You are a JSON generator for client data (metadata) of a Trigger.dev chat agent with id "${taskIdentifier}".
|
||||
|
||||
IMPORTANT: You are generating ONLY the client data object — this is the metadata sent alongside each chat message. It is NOT the full task payload. Do NOT generate fields like "chatId", "messages", "trigger", or "idleTimeoutInSeconds" — those are internal transport fields managed by the framework.
|
||||
|
||||
The client data typically contains user context like user IDs, preferences, configuration, or session info. Return ONLY valid JSON wrapped in a \`\`\`json code block.
|
||||
|
||||
Requirements:
|
||||
- Generate realistic, meaningful example data
|
||||
- All string values should be plausible (real-looking IDs, names, etc.)
|
||||
- The JSON must be valid and parseable
|
||||
- Keep it simple — client data is usually a flat or shallow object`;
|
||||
|
||||
if (payloadSchema) {
|
||||
prompt += `
|
||||
|
||||
The agent has the following JSON Schema for its client data:
|
||||
\`\`\`json
|
||||
${payloadSchema}
|
||||
\`\`\`
|
||||
|
||||
Generate client data that strictly conforms to this schema.`;
|
||||
} else {
|
||||
prompt += `
|
||||
|
||||
No JSON Schema is available for this agent's client data. Use the getTaskSourceCode tool to look up the agent's source code file.
|
||||
|
||||
IMPORTANT instructions for reading the source code:
|
||||
- The file may contain multiple task/agent definitions. Find the one with id "${taskIdentifier}".
|
||||
- Look for \`withClientData({ schema: ... })\` or \`clientDataSchema\` to find the expected client data shape.
|
||||
- If using \`chat.agent()\` or \`chat.customAgent()\`, the client data is accessed via \`clientData\` in hooks and \`payload.metadata\` in raw tasks.
|
||||
- Look for how \`clientData\` or \`payload.metadata\` is accessed/destructured to infer the shape.
|
||||
- Do NOT generate the full ChatTaskWirePayload (messages, chatId, trigger, etc.) — ONLY the metadata/clientData portion.
|
||||
- If no client data schema or usage is found, generate a simple \`{ "userId": "user_..." }\` object.`;
|
||||
}
|
||||
|
||||
if (currentPayload) {
|
||||
prompt += `
|
||||
|
||||
The current client data in the editor is:
|
||||
\`\`\`json
|
||||
${currentPayload}
|
||||
\`\`\`
|
||||
|
||||
Use this as context but generate new client data based on the user's prompt.`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function buildSystemPrompt(
|
||||
taskIdentifier: string,
|
||||
payloadSchema?: string,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Suspense } from "react";
|
||||
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
|
||||
const sampleMarkdown = `# Streamdown Rendering
|
||||
|
||||
This is a paragraph with **bold**, *italic*, and \`inline code\` formatting.
|
||||
|
||||
## Code Block (TypeScript)
|
||||
|
||||
\`\`\`typescript
|
||||
import { task } from "@trigger.dev/sdk";
|
||||
|
||||
export const myTask = task({
|
||||
id: "my-task",
|
||||
run: async (payload: { message: string }) => {
|
||||
const result = await processMessage(payload.message);
|
||||
this.logger.info("Task completed", { result });
|
||||
return { success: true, count: 42 };
|
||||
},
|
||||
});
|
||||
\`\`\`
|
||||
|
||||
## Code Block (JSON)
|
||||
|
||||
\`\`\`json
|
||||
{
|
||||
"id": "run_1234",
|
||||
"status": "completed",
|
||||
"output": {
|
||||
"success": true,
|
||||
"count": 42
|
||||
}
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## Lists
|
||||
|
||||
- First item
|
||||
- Second item with \`code\`
|
||||
- Third item
|
||||
|
||||
1. Ordered first
|
||||
2. Ordered second
|
||||
3. Ordered third
|
||||
|
||||
## Table
|
||||
|
||||
| Feature | Status | Notes |
|
||||
|---------|--------|-------|
|
||||
| Syntax highlighting | Done | Custom Shiki theme |
|
||||
| Markdown rendering | Done | Streamdown v2 |
|
||||
| Lazy loading | Done | SSR safe |
|
||||
|
||||
## Blockquote
|
||||
|
||||
> This is a blockquote with some **bold** text and a [link](https://trigger.dev).
|
||||
|
||||
---
|
||||
|
||||
That's all the elements.
|
||||
`;
|
||||
|
||||
const codeOnlyMarkdown = `Here's a function that demonstrates the color palette:
|
||||
|
||||
\`\`\`typescript
|
||||
const API_URL = "https://api.trigger.dev";
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
interface TaskConfig {
|
||||
id: string;
|
||||
retry: { maxAttempts: number };
|
||||
}
|
||||
|
||||
export async function executeTask(config: TaskConfig): Promise<boolean> {
|
||||
// Validate the configuration
|
||||
if (!config.id || config.retry.maxAttempts < 1) {
|
||||
throw new Error("Invalid task config");
|
||||
}
|
||||
|
||||
for (let i = 0; i < MAX_RETRIES; i++) {
|
||||
const response = await fetch(\`\${API_URL}/tasks/\${config.id}\`);
|
||||
const data = response.json();
|
||||
|
||||
if (response.ok) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-y-8 p-8">
|
||||
<div className="max-w-3xl">
|
||||
<Header2 className="mb-4">Full Markdown</Header2>
|
||||
<div className="streamdown-container rounded-lg border border-charcoal-700 bg-charcoal-900 p-6 text-sm text-text-bright/90">
|
||||
<Suspense fallback={<p className="text-text-dimmed">Loading streamdown...</p>}>
|
||||
<StreamdownRenderer>{sampleMarkdown}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-3xl">
|
||||
<Header2 className="mb-4">Code Highlighting Theme</Header2>
|
||||
<div className="streamdown-container rounded-lg border border-charcoal-700 bg-charcoal-900 p-6 text-sm text-text-bright/90">
|
||||
<Suspense fallback={<p className="text-text-dimmed">Loading streamdown...</p>}>
|
||||
<StreamdownRenderer>{codeOnlyMarkdown}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,10 @@ const stories: Story[] = [
|
||||
name: "Spinners",
|
||||
slug: "spinner",
|
||||
},
|
||||
{
|
||||
name: "Streamdown",
|
||||
slug: "streamdown",
|
||||
},
|
||||
{
|
||||
name: "Switch",
|
||||
slug: "switch",
|
||||
|
||||
@@ -17,6 +17,8 @@ import { tryCatch } from "@trigger.dev/core/v3";
|
||||
import { ServiceValidationError } from "~/v3/services/common.server";
|
||||
import { createCache, createLRUMemoryStore, DefaultStatefulContext, Namespace } from "@internal/cache";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import type { TaskMetadataCache, TaskMetadataEntry } from "~/services/taskMetadataCache.server";
|
||||
import { taskMetadataCacheInstance } from "~/services/taskMetadataCacheInstance.server";
|
||||
|
||||
// LRU cache for environment queue sizes to reduce Redis calls
|
||||
const queueSizeCache = singleton("queueSizeCache", () => {
|
||||
@@ -63,13 +65,16 @@ function extractQueueName(queue: { name?: unknown } | undefined): string | undef
|
||||
|
||||
export class DefaultQueueManager implements QueueManager {
|
||||
private readonly replicaPrisma: PrismaClientOrTransaction;
|
||||
private readonly taskMetaCache: TaskMetadataCache;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaClientOrTransaction,
|
||||
private readonly engine: RunEngine,
|
||||
replicaPrisma?: PrismaClientOrTransaction
|
||||
replicaPrisma?: PrismaClientOrTransaction,
|
||||
taskMetaCache: TaskMetadataCache = taskMetadataCacheInstance
|
||||
) {
|
||||
this.replicaPrisma = replicaPrisma ?? prisma;
|
||||
this.taskMetaCache = taskMetaCache;
|
||||
}
|
||||
|
||||
async resolveQueueProperties(
|
||||
@@ -87,7 +92,10 @@ export class DefaultQueueManager implements QueueManager {
|
||||
const specifiedQueueName = extractQueueName(request.body.options?.queue);
|
||||
|
||||
if (specifiedQueueName) {
|
||||
// A specific queue name is provided, validate it exists for the locked worker
|
||||
// A specific queue name is provided, validate it exists for the locked worker.
|
||||
// Pre-existing query — not cached because TaskQueue rows can be added or
|
||||
// removed independently of BackgroundWorkerTask, and a stale "queue exists"
|
||||
// claim would silently route to the wrong queue.
|
||||
const specifiedQueue = await this.prisma.taskQueue.findFirst({
|
||||
where: {
|
||||
name: specifiedQueueName,
|
||||
@@ -107,49 +115,45 @@ export class DefaultQueueManager implements QueueManager {
|
||||
queueName = specifiedQueue.name;
|
||||
lockedQueueId = specifiedQueue.id;
|
||||
|
||||
// Always fetch the task so we can resolve `triggerSource` (which
|
||||
// becomes `taskKind` on annotations and replicates to ClickHouse).
|
||||
// Without this, AGENT/SCHEDULED runs triggered with
|
||||
// `lockToVersion` + a queue override would be annotated as
|
||||
// STANDARD and disappear from the run-list "Source" filter.
|
||||
// `ttl` is read from the same row but only used when the caller
|
||||
// didn't specify a per-trigger TTL.
|
||||
const lockedTask = await this.replicaPrisma.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
workerId: lockedBackgroundWorker.id,
|
||||
runtimeEnvironmentId: request.environment.id,
|
||||
slug: request.taskId,
|
||||
},
|
||||
select: { ttl: true, triggerSource: true },
|
||||
});
|
||||
// Pull `triggerSource` (for `taskKind` annotation) and `ttl` from cache.
|
||||
// On cache hit this is 0 PG queries; on miss the helper falls back to
|
||||
// a BackgroundWorkerTask lookup and back-fills the cache.
|
||||
//
|
||||
// If the task slug isn't on this locked worker version, we tolerate
|
||||
// the missing row and fall through with `taskKind = undefined`
|
||||
// (coalesced to "STANDARD" downstream) and `taskTtl = undefined`.
|
||||
// This matches main's pre-PR behavior — the no-override branch below
|
||||
// still throws because there's no queue to route to in that case,
|
||||
// but here the caller already named the queue.
|
||||
const lockedMeta = await this.resolveLockedTaskMetadata(
|
||||
lockedBackgroundWorker.id,
|
||||
request.environment.id,
|
||||
request.taskId
|
||||
);
|
||||
|
||||
if (request.body.options?.ttl === undefined) {
|
||||
taskTtl = lockedTask?.ttl;
|
||||
taskTtl = lockedMeta?.ttl ?? undefined;
|
||||
}
|
||||
taskKind = lockedTask?.triggerSource;
|
||||
taskKind = lockedMeta?.triggerSource;
|
||||
} else {
|
||||
// No queue override - fetch task with queue to get both default queue and TTL
|
||||
const lockedTask = await this.replicaPrisma.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
workerId: lockedBackgroundWorker.id,
|
||||
runtimeEnvironmentId: request.environment.id,
|
||||
slug: request.taskId,
|
||||
},
|
||||
include: {
|
||||
queue: true,
|
||||
},
|
||||
});
|
||||
// No queue override - resolve default queue + TTL + triggerSource via cache,
|
||||
// falling back to a single BackgroundWorkerTask lookup on miss.
|
||||
const lockedMeta = await this.resolveLockedTaskMetadata(
|
||||
lockedBackgroundWorker.id,
|
||||
request.environment.id,
|
||||
request.taskId
|
||||
);
|
||||
|
||||
if (!lockedTask) {
|
||||
if (!lockedMeta) {
|
||||
throw new ServiceValidationError(
|
||||
`Task '${request.taskId}' not found on locked version '${lockedBackgroundWorker.version ?? "<unknown>"
|
||||
}'.`
|
||||
);
|
||||
}
|
||||
|
||||
taskTtl = lockedTask.ttl;
|
||||
taskTtl = lockedMeta.ttl;
|
||||
|
||||
if (!lockedTask.queue) {
|
||||
if (!lockedMeta.queueName) {
|
||||
// This case should ideally be prevented by earlier checks or schema constraints,
|
||||
// but handle it defensively.
|
||||
logger.error("Task found on locked version, but has no associated queue record", {
|
||||
@@ -164,9 +168,9 @@ export class DefaultQueueManager implements QueueManager {
|
||||
}
|
||||
|
||||
// Use the task's default queue name
|
||||
queueName = lockedTask.queue.name;
|
||||
lockedQueueId = lockedTask.queue.id;
|
||||
taskKind = lockedTask.triggerSource;
|
||||
queueName = lockedMeta.queueName;
|
||||
lockedQueueId = lockedMeta.queueId ?? undefined;
|
||||
taskKind = lockedMeta.triggerSource;
|
||||
}
|
||||
} else {
|
||||
// Task is not locked to a specific version, use regular logic
|
||||
@@ -213,76 +217,130 @@ export class DefaultQueueManager implements QueueManager {
|
||||
|
||||
const defaultQueueName = `task/${taskId}`;
|
||||
|
||||
// Even when the caller provides both a queue override and a
|
||||
// per-trigger TTL, we still need to fetch the task so `triggerSource`
|
||||
// (which becomes `taskKind` on annotations and replicates to
|
||||
// ClickHouse) is populated. Without it, AGENT/SCHEDULED runs hitting
|
||||
// this path get stamped as STANDARD and disappear from the
|
||||
// dashboard's `Source` filter. Mirrors the locked-worker fix above
|
||||
// — `taskTtl` is harmless in the returned value because the call
|
||||
// site coalesces `body.options.ttl ?? taskTtl`.
|
||||
// Resolve the current worker's task metadata via cache (HGET on warm path,
|
||||
// BackgroundWorkerTask findFirst + cache back-fill on miss). When this hits,
|
||||
// both the queue-override + TTL caller and the default-queue caller satisfy
|
||||
// their full result without any database query.
|
||||
const meta = await this.resolveCurrentTaskMetadata(environment, taskId);
|
||||
|
||||
// Find the current worker for the environment. Replica is fine here —
|
||||
// the adjacent `backgroundWorkerTask` lookups below already use
|
||||
// `replicaPrisma` (replica lag for "just deployed" is bounded the same
|
||||
// way for both queries; reading the worker from the writer and the
|
||||
// task from the replica would only widen the inconsistency window).
|
||||
const worker = await findCurrentWorkerFromEnvironment(environment, this.replicaPrisma);
|
||||
|
||||
if (!worker) {
|
||||
logger.debug("Failed to get queue name: No worker found", {
|
||||
taskId,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
|
||||
return { queueName: overriddenQueueName ?? defaultQueueName, taskTtl: undefined };
|
||||
}
|
||||
|
||||
// When queue is overridden, we only need TTL from the task (no queue join needed)
|
||||
if (overriddenQueueName) {
|
||||
const task = await this.replicaPrisma.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
workerId: worker.id,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
slug: taskId,
|
||||
},
|
||||
select: { ttl: true, triggerSource: true },
|
||||
});
|
||||
|
||||
return { queueName: overriddenQueueName, taskTtl: task?.ttl, taskKind: task?.triggerSource };
|
||||
// Caller already named the queue. We only need triggerSource (for taskKind)
|
||||
// and ttl (for the call site to coalesce against body.options.ttl).
|
||||
return {
|
||||
queueName: overriddenQueueName,
|
||||
taskTtl: meta?.ttl ?? undefined,
|
||||
taskKind: meta?.triggerSource,
|
||||
};
|
||||
}
|
||||
|
||||
const task = await this.replicaPrisma.backgroundWorkerTask.findFirst({
|
||||
where: {
|
||||
workerId: worker.id,
|
||||
runtimeEnvironmentId: environment.id,
|
||||
slug: taskId,
|
||||
},
|
||||
include: {
|
||||
queue: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
console.log("Failed to get queue name: No task found", {
|
||||
if (!meta) {
|
||||
logger.debug("Failed to get queue name: No worker or task found", {
|
||||
taskId,
|
||||
environmentId: environment.id,
|
||||
});
|
||||
|
||||
return { queueName: defaultQueueName, taskTtl: undefined };
|
||||
}
|
||||
|
||||
if (!task.queue) {
|
||||
console.log("Failed to get queue name: No queue found", {
|
||||
if (!meta.queueName) {
|
||||
logger.debug("Failed to get queue name: No queue found", {
|
||||
taskId,
|
||||
environmentId: environment.id,
|
||||
queueConfig: task.queueConfig,
|
||||
});
|
||||
|
||||
return { queueName: defaultQueueName, taskTtl: task.ttl, taskKind: task.triggerSource };
|
||||
return { queueName: defaultQueueName, taskTtl: meta.ttl, taskKind: meta.triggerSource };
|
||||
}
|
||||
|
||||
return { queueName: task.queue.name ?? defaultQueueName, taskTtl: task.ttl, taskKind: task.triggerSource };
|
||||
return { queueName: meta.queueName, taskTtl: meta.ttl, taskKind: meta.triggerSource };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve task metadata for a locked-version trigger. Reads from the
|
||||
* `task-meta:by-worker:{workerId}` Redis hash; falls back to a single
|
||||
* BackgroundWorkerTask findFirst on miss and back-fills the cache.
|
||||
*
|
||||
* Returns null when no BackgroundWorkerTask row exists.
|
||||
*/
|
||||
private async resolveLockedTaskMetadata(
|
||||
workerId: string,
|
||||
environmentId: string,
|
||||
slug: string
|
||||
): Promise<TaskMetadataEntry | null> {
|
||||
const cached = await this.taskMetaCache.getByWorker(workerId, slug);
|
||||
if (cached) return cached;
|
||||
|
||||
const row = await this.replicaPrisma.backgroundWorkerTask.findFirst({
|
||||
where: { workerId, runtimeEnvironmentId: environmentId, slug },
|
||||
select: {
|
||||
ttl: true,
|
||||
triggerSource: true,
|
||||
queue: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const entry: TaskMetadataEntry = {
|
||||
slug,
|
||||
ttl: row.ttl,
|
||||
triggerSource: row.triggerSource,
|
||||
queueId: row.queue?.id ?? null,
|
||||
queueName: row.queue?.name ?? "",
|
||||
};
|
||||
|
||||
// Fire-and-forget back-fill — `setByWorker` upserts the single field and
|
||||
// refreshes the hash TTL. Errors are logged inside the cache and swallowed.
|
||||
void this.taskMetaCache.setByWorker(workerId, entry);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve task metadata for a non-locked trigger. Reads from the
|
||||
* `task-meta:env:{envId}` Redis hash; falls back to
|
||||
* findCurrentWorkerFromEnvironment + a single BackgroundWorkerTask findFirst
|
||||
* on miss and back-fills both keyspaces.
|
||||
*
|
||||
* Returns null when no current worker or task can be resolved.
|
||||
*/
|
||||
private async resolveCurrentTaskMetadata(
|
||||
environment: AuthenticatedEnvironment,
|
||||
slug: string
|
||||
): Promise<TaskMetadataEntry | null> {
|
||||
const cached = await this.taskMetaCache.getCurrent(environment.id, slug);
|
||||
if (cached) return cached;
|
||||
|
||||
// Cold cache: discover the current worker for the env. Replica is fine —
|
||||
// the adjacent BackgroundWorkerTask lookup below uses `replicaPrisma` too
|
||||
// (replica lag for "just deployed" is bounded the same way for both
|
||||
// queries; reading from the writer here would only widen the window).
|
||||
const worker = await findCurrentWorkerFromEnvironment(environment, this.replicaPrisma);
|
||||
if (!worker) return null;
|
||||
|
||||
const row = await this.replicaPrisma.backgroundWorkerTask.findFirst({
|
||||
where: { workerId: worker.id, runtimeEnvironmentId: environment.id, slug },
|
||||
select: {
|
||||
ttl: true,
|
||||
triggerSource: true,
|
||||
queue: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) return null;
|
||||
|
||||
const entry: TaskMetadataEntry = {
|
||||
slug,
|
||||
ttl: row.ttl,
|
||||
triggerSource: row.triggerSource,
|
||||
queueId: row.queue?.id ?? null,
|
||||
queueName: row.queue?.name ?? "",
|
||||
};
|
||||
|
||||
// Fire-and-forget back-fill — atomically upserts the slug into both
|
||||
// keyspaces so a subsequent locked-or-not trigger hits the cache. The
|
||||
// env-keyspace TTL is preserved (promotion owns it); the by-worker TTL
|
||||
// is refreshed (sliding window keeps active workers warm).
|
||||
void this.taskMetaCache.setByCurrentWorker(environment.id, worker.id, entry);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
async validateQueueLimits(
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
import type { Redis, Result, Callback } from "ioredis";
|
||||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { logger } from "./logger.server";
|
||||
|
||||
export type TaskMetadataEntry = {
|
||||
slug: string;
|
||||
ttl: string | null;
|
||||
triggerSource: TaskTriggerSource;
|
||||
queueId: string | null;
|
||||
queueName: string;
|
||||
};
|
||||
|
||||
export interface TaskMetadataCache {
|
||||
/** Read a slug's metadata from the env keyspace (current pointer). */
|
||||
getCurrent(envId: string, slug: string): Promise<TaskMetadataEntry | null>;
|
||||
/** Read a slug's metadata from the by-worker keyspace (locked-version lookups). */
|
||||
getByWorker(workerId: string, slug: string): Promise<TaskMetadataEntry | null>;
|
||||
/**
|
||||
* Atomically replace both `task-meta:env:{envId}` and
|
||||
* `task-meta:by-worker:{workerId}` with the given entries. Used at deploy
|
||||
* promotion sites where the worker just became current for the env.
|
||||
*/
|
||||
populateByCurrentWorker(
|
||||
envId: string,
|
||||
workerId: string,
|
||||
entries: TaskMetadataEntry[]
|
||||
): Promise<void>;
|
||||
/**
|
||||
* Replace `task-meta:by-worker:{workerId}` only. Used at deploy build sites
|
||||
* (V4) where the worker is created but not yet promoted.
|
||||
*/
|
||||
populateByWorker(workerId: string, entries: TaskMetadataEntry[]): Promise<void>;
|
||||
/**
|
||||
* Atomically upsert one slug in both keyspaces. Used by the non-locked
|
||||
* read-path back-fill. The env-keyspace TTL is only set when no TTL is
|
||||
* present (preserves the promotion boundary); the by-worker TTL is
|
||||
* refreshed on every call (sliding expiry).
|
||||
*/
|
||||
setByCurrentWorker(envId: string, workerId: string, entry: TaskMetadataEntry): Promise<void>;
|
||||
/**
|
||||
* Upsert one slug in `task-meta:by-worker:{workerId}` only. Used by the
|
||||
* locked-version read-path back-fill; refreshes the by-worker TTL.
|
||||
*/
|
||||
setByWorker(workerId: string, entry: TaskMetadataEntry): Promise<void>;
|
||||
}
|
||||
|
||||
export type RedisTaskMetadataCacheOptions = {
|
||||
redis: Redis;
|
||||
/** Safety TTL on `task-meta:env:{envId}`. Default 24h. Use 0 for no expiry. */
|
||||
currentEnvTtlSeconds?: number;
|
||||
/** Idle TTL on `task-meta:by-worker:{workerId}`. Default 30d. Use 0 for no expiry. */
|
||||
byWorkerTtlSeconds?: number;
|
||||
};
|
||||
|
||||
type EncodedEntry = {
|
||||
t: string | null;
|
||||
k: TaskTriggerSource;
|
||||
q: string | null;
|
||||
n: string;
|
||||
};
|
||||
|
||||
function encode(entry: TaskMetadataEntry): string {
|
||||
const payload: EncodedEntry = {
|
||||
t: entry.ttl,
|
||||
k: entry.triggerSource,
|
||||
q: entry.queueId,
|
||||
n: entry.queueName,
|
||||
};
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
|
||||
function decode(slug: string, raw: string): TaskMetadataEntry | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as EncodedEntry;
|
||||
return {
|
||||
slug,
|
||||
ttl: parsed.t,
|
||||
triggerSource: parsed.k,
|
||||
queueId: parsed.q,
|
||||
queueName: parsed.n,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error("Failed to decode task metadata cache entry", { slug, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function currentEnvKey(envId: string): string {
|
||||
return `task-meta:env:${envId}`;
|
||||
}
|
||||
|
||||
function byWorkerKey(workerId: string): string {
|
||||
return `task-meta:by-worker:${workerId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically replace a single HASH's contents and reset its TTL.
|
||||
*
|
||||
* KEYS[1] = hash key
|
||||
* ARGV[1] = ttl seconds (0 = no TTL)
|
||||
* ARGV[2..N] = alternating field, value pairs
|
||||
*/
|
||||
const REPLACE_HASH_LUA = `
|
||||
redis.call("DEL", KEYS[1])
|
||||
if #ARGV > 1 then
|
||||
local fv = {}
|
||||
for i = 2, #ARGV do
|
||||
fv[#fv + 1] = ARGV[i]
|
||||
end
|
||||
redis.call("HSET", KEYS[1], unpack(fv))
|
||||
end
|
||||
local ttl = tonumber(ARGV[1])
|
||||
if ttl and ttl > 0 then
|
||||
redis.call("EXPIRE", KEYS[1], ttl)
|
||||
end
|
||||
return 1
|
||||
`;
|
||||
|
||||
/**
|
||||
* Reserved field name on env hashes that records the worker currently
|
||||
* "owning" the env keyspace. The back-fill Lua script reads this and skips
|
||||
* its env-side write if the owner has flipped — closing the race where a
|
||||
* concurrent promotion atomically replaces the env hash between a resolver's
|
||||
* PG read and its back-fill write. Customer task slugs are kebab/camelCase
|
||||
* and never start with `__`, so collisions are not a concern; an accidental
|
||||
* `getCurrent(envId, "__owner_worker_id")` would JSON.parse-fail and fall
|
||||
* back to PG, not corrupt anything.
|
||||
*/
|
||||
const OWNER_FIELD = "__owner_worker_id";
|
||||
|
||||
/**
|
||||
* Atomically replace BOTH keyspaces in one Redis transaction. Used at deploy
|
||||
* promotion — the worker just became current for the env, so the env keyspace
|
||||
* and the worker keyspace get the same field set, and the env hash is
|
||||
* stamped with the new owner workerId.
|
||||
*
|
||||
* KEYS[1] = env hash key
|
||||
* KEYS[2] = by-worker hash key
|
||||
* ARGV[1] = env ttl seconds (0 = no TTL)
|
||||
* ARGV[2] = by-worker ttl seconds (0 = no TTL)
|
||||
* ARGV[3] = workerId (env-hash owner marker)
|
||||
* ARGV[4..N] = alternating field, value pairs (same for both hashes)
|
||||
*/
|
||||
const REPLACE_TWO_HASHES_LUA = `
|
||||
redis.call("DEL", KEYS[1])
|
||||
redis.call("DEL", KEYS[2])
|
||||
if #ARGV > 3 then
|
||||
local fv = {}
|
||||
for i = 4, #ARGV do
|
||||
fv[#fv + 1] = ARGV[i]
|
||||
end
|
||||
redis.call("HSET", KEYS[1], unpack(fv))
|
||||
redis.call("HSET", KEYS[2], unpack(fv))
|
||||
end
|
||||
redis.call("HSET", KEYS[1], "${OWNER_FIELD}", ARGV[3])
|
||||
local envTtl = tonumber(ARGV[1])
|
||||
if envTtl and envTtl > 0 then
|
||||
redis.call("EXPIRE", KEYS[1], envTtl)
|
||||
end
|
||||
local workerTtl = tonumber(ARGV[2])
|
||||
if workerTtl and workerTtl > 0 then
|
||||
redis.call("EXPIRE", KEYS[2], workerTtl)
|
||||
end
|
||||
return 1
|
||||
`;
|
||||
|
||||
/**
|
||||
* Set a single field and refresh the HASH TTL. Used by the locked-version
|
||||
* back-fill path — sliding expiry keeps active workers warm.
|
||||
*
|
||||
* KEYS[1] = hash key
|
||||
* ARGV[1] = ttl seconds (0 = no TTL refresh)
|
||||
* ARGV[2] = field
|
||||
* ARGV[3] = value
|
||||
*/
|
||||
const SET_FIELD_REFRESH_TTL_LUA = `
|
||||
redis.call("HSET", KEYS[1], ARGV[2], ARGV[3])
|
||||
local ttl = tonumber(ARGV[1])
|
||||
if ttl and ttl > 0 then
|
||||
redis.call("EXPIRE", KEYS[1], ttl)
|
||||
end
|
||||
return 1
|
||||
`;
|
||||
|
||||
/**
|
||||
* Atomically upsert one field in BOTH keyspaces. Used by the non-locked
|
||||
* back-fill path.
|
||||
*
|
||||
* The by-worker hash always gets written (the key contains the workerId, so
|
||||
* stale data lands in a dead worker's keyspace and is never read by anyone
|
||||
* not pinned to that version).
|
||||
*
|
||||
* The env hash is CAS-guarded by `${OWNER_FIELD}`: if a concurrent promotion
|
||||
* has replaced the hash between this resolver's PG read and this write, the
|
||||
* stored owner won't match the workerId the back-filler resolved to, so the
|
||||
* env write is skipped — preventing the back-fill from overwriting a freshly
|
||||
* promoted slug with stale data from the previous worker.
|
||||
*
|
||||
* KEYS[1] = env hash key
|
||||
* KEYS[2] = by-worker hash key
|
||||
* ARGV[1] = env ttl seconds (0 = no TTL)
|
||||
* ARGV[2] = by-worker ttl seconds (0 = no TTL)
|
||||
* ARGV[3] = writer's expected env-hash owner workerId
|
||||
* ARGV[4] = field
|
||||
* ARGV[5] = value
|
||||
*/
|
||||
const SET_TWO_FIELDS_LUA = `
|
||||
redis.call("HSET", KEYS[2], ARGV[4], ARGV[5])
|
||||
local workerTtl = tonumber(ARGV[2])
|
||||
if workerTtl and workerTtl > 0 then
|
||||
redis.call("EXPIRE", KEYS[2], workerTtl)
|
||||
end
|
||||
|
||||
local owner = redis.call("HGET", KEYS[1], "${OWNER_FIELD}")
|
||||
if owner == false or owner == ARGV[3] then
|
||||
redis.call("HSET", KEYS[1], ARGV[4], ARGV[5])
|
||||
if owner == false then
|
||||
redis.call("HSET", KEYS[1], "${OWNER_FIELD}", ARGV[3])
|
||||
end
|
||||
local envTtl = tonumber(ARGV[1])
|
||||
if envTtl and envTtl > 0 and redis.call("TTL", KEYS[1]) == -1 then
|
||||
redis.call("EXPIRE", KEYS[1], envTtl)
|
||||
end
|
||||
end
|
||||
return 1
|
||||
`;
|
||||
|
||||
declare module "ioredis" {
|
||||
interface RedisCommander<Context> {
|
||||
taskMetaReplaceHash(
|
||||
key: string,
|
||||
ttlSeconds: string,
|
||||
...fieldValues: string[]
|
||||
): Result<number, Context>;
|
||||
taskMetaReplaceTwoHashes(
|
||||
envKey: string,
|
||||
workerKey: string,
|
||||
envTtlSeconds: string,
|
||||
workerTtlSeconds: string,
|
||||
workerId: string,
|
||||
...fieldValues: string[]
|
||||
): Result<number, Context>;
|
||||
taskMetaSetFieldRefreshTtl(
|
||||
key: string,
|
||||
ttlSeconds: string,
|
||||
field: string,
|
||||
value: string,
|
||||
callback?: Callback<number>
|
||||
): Result<number, Context>;
|
||||
taskMetaSetTwoFields(
|
||||
envKey: string,
|
||||
workerKey: string,
|
||||
envTtlSeconds: string,
|
||||
workerTtlSeconds: string,
|
||||
workerId: string,
|
||||
field: string,
|
||||
value: string,
|
||||
callback?: Callback<number>
|
||||
): Result<number, Context>;
|
||||
}
|
||||
}
|
||||
|
||||
export class RedisTaskMetadataCache implements TaskMetadataCache {
|
||||
private readonly redis: Redis;
|
||||
private readonly currentEnvTtlSeconds: number;
|
||||
private readonly byWorkerTtlSeconds: number;
|
||||
|
||||
constructor(options: RedisTaskMetadataCacheOptions) {
|
||||
this.redis = options.redis;
|
||||
this.currentEnvTtlSeconds = options.currentEnvTtlSeconds ?? 86400;
|
||||
this.byWorkerTtlSeconds = options.byWorkerTtlSeconds ?? 30 * 24 * 60 * 60;
|
||||
|
||||
this.redis.defineCommand("taskMetaReplaceHash", {
|
||||
numberOfKeys: 1,
|
||||
lua: REPLACE_HASH_LUA,
|
||||
});
|
||||
this.redis.defineCommand("taskMetaReplaceTwoHashes", {
|
||||
numberOfKeys: 2,
|
||||
lua: REPLACE_TWO_HASHES_LUA,
|
||||
});
|
||||
this.redis.defineCommand("taskMetaSetFieldRefreshTtl", {
|
||||
numberOfKeys: 1,
|
||||
lua: SET_FIELD_REFRESH_TTL_LUA,
|
||||
});
|
||||
this.redis.defineCommand("taskMetaSetTwoFields", {
|
||||
numberOfKeys: 2,
|
||||
lua: SET_TWO_FIELDS_LUA,
|
||||
});
|
||||
}
|
||||
|
||||
async getCurrent(envId: string, slug: string): Promise<TaskMetadataEntry | null> {
|
||||
return this.#get(currentEnvKey(envId), slug);
|
||||
}
|
||||
|
||||
async getByWorker(workerId: string, slug: string): Promise<TaskMetadataEntry | null> {
|
||||
return this.#get(byWorkerKey(workerId), slug);
|
||||
}
|
||||
|
||||
async populateByCurrentWorker(
|
||||
envId: string,
|
||||
workerId: string,
|
||||
entries: TaskMetadataEntry[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Always invoke the script — empty `entries` is valid and causes both
|
||||
// keyspaces to be cleared (DEL + no HSET), which is the right behavior
|
||||
// when promoting a worker with no tasks.
|
||||
const fieldValues: string[] = [];
|
||||
for (const entry of entries) {
|
||||
fieldValues.push(entry.slug, encode(entry));
|
||||
}
|
||||
await this.redis.taskMetaReplaceTwoHashes(
|
||||
currentEnvKey(envId),
|
||||
byWorkerKey(workerId),
|
||||
String(this.currentEnvTtlSeconds),
|
||||
String(this.byWorkerTtlSeconds),
|
||||
workerId,
|
||||
...fieldValues
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to populate task metadata cache (current worker)", {
|
||||
envId,
|
||||
workerId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async populateByWorker(workerId: string, entries: TaskMetadataEntry[]): Promise<void> {
|
||||
try {
|
||||
// Always invoke the script — empty `entries` clears the keyspace.
|
||||
const fieldValues: string[] = [];
|
||||
for (const entry of entries) {
|
||||
fieldValues.push(entry.slug, encode(entry));
|
||||
}
|
||||
await this.redis.taskMetaReplaceHash(
|
||||
byWorkerKey(workerId),
|
||||
String(this.byWorkerTtlSeconds),
|
||||
...fieldValues
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to populate task metadata cache (by worker)", {
|
||||
workerId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async setByCurrentWorker(
|
||||
envId: string,
|
||||
workerId: string,
|
||||
entry: TaskMetadataEntry
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.redis.taskMetaSetTwoFields(
|
||||
currentEnvKey(envId),
|
||||
byWorkerKey(workerId),
|
||||
String(this.currentEnvTtlSeconds),
|
||||
String(this.byWorkerTtlSeconds),
|
||||
workerId,
|
||||
entry.slug,
|
||||
encode(entry)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to set task metadata cache field (current worker)", {
|
||||
envId,
|
||||
workerId,
|
||||
slug: entry.slug,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async setByWorker(workerId: string, entry: TaskMetadataEntry): Promise<void> {
|
||||
try {
|
||||
await this.redis.taskMetaSetFieldRefreshTtl(
|
||||
byWorkerKey(workerId),
|
||||
String(this.byWorkerTtlSeconds),
|
||||
entry.slug,
|
||||
encode(entry)
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error("Failed to set task metadata cache field (by worker)", {
|
||||
workerId,
|
||||
slug: entry.slug,
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async #get(key: string, slug: string): Promise<TaskMetadataEntry | null> {
|
||||
try {
|
||||
const raw = await this.redis.hget(key, slug);
|
||||
if (!raw) return null;
|
||||
return decode(slug, raw);
|
||||
} catch (error) {
|
||||
logger.error("Failed to read task metadata from cache", { key, slug, error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NoopTaskMetadataCache implements TaskMetadataCache {
|
||||
async getCurrent(): Promise<TaskMetadataEntry | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async getByWorker(): Promise<TaskMetadataEntry | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
async populateByCurrentWorker(): Promise<void> {
|
||||
// intentionally empty
|
||||
}
|
||||
|
||||
async populateByWorker(): Promise<void> {
|
||||
// intentionally empty
|
||||
}
|
||||
|
||||
async setByCurrentWorker(): Promise<void> {
|
||||
// intentionally empty
|
||||
}
|
||||
|
||||
async setByWorker(): Promise<void> {
|
||||
// intentionally empty
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Redis } from "ioredis";
|
||||
import { defaultReconnectOnError } from "@internal/redis";
|
||||
import { env } from "~/env.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import {
|
||||
NoopTaskMetadataCache,
|
||||
RedisTaskMetadataCache,
|
||||
type TaskMetadataCache,
|
||||
} from "./taskMetadataCache.server";
|
||||
|
||||
export const taskMetadataCacheInstance: TaskMetadataCache = singleton(
|
||||
"taskMetadataCacheInstance",
|
||||
initializeTaskMetadataCache
|
||||
);
|
||||
|
||||
function initializeTaskMetadataCache(): TaskMetadataCache {
|
||||
if (!env.TASK_META_CACHE_REDIS_HOST) {
|
||||
return new NoopTaskMetadataCache();
|
||||
}
|
||||
|
||||
const redis = new Redis({
|
||||
connectionName: "taskMetadataCache",
|
||||
host: env.TASK_META_CACHE_REDIS_HOST,
|
||||
port: env.TASK_META_CACHE_REDIS_PORT,
|
||||
username: env.TASK_META_CACHE_REDIS_USERNAME,
|
||||
password: env.TASK_META_CACHE_REDIS_PASSWORD,
|
||||
keyPrefix: "tr:",
|
||||
enableAutoPipelining: true,
|
||||
reconnectOnError: defaultReconnectOnError,
|
||||
...(env.TASK_META_CACHE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
|
||||
});
|
||||
|
||||
return new RedisTaskMetadataCache({
|
||||
redis,
|
||||
currentEnvTtlSeconds: env.TASK_META_CACHE_CURRENT_ENV_TTL_SECONDS,
|
||||
byWorkerTtlSeconds: env.TASK_META_CACHE_BY_WORKER_TTL_SECONDS,
|
||||
});
|
||||
}
|
||||
@@ -151,11 +151,18 @@
|
||||
|
||||
/* Streamdown markdown styling */
|
||||
.streamdown-container {
|
||||
/* Streamdown uses shadcn/ui CSS variables - define them for our theme */
|
||||
--muted: 220 13% 20%;
|
||||
--muted-foreground: 215 14% 60%;
|
||||
--foreground: 210 20% 90%;
|
||||
--border: 217 19% 27%;
|
||||
/* Streamdown uses shadcn/ui CSS variables - define them for our theme.
|
||||
These map Tailwind utility classes like bg-background, bg-primary, etc.
|
||||
that streamdown uses internally for its link safety modal, code blocks,
|
||||
and other interactive elements. */
|
||||
--background: 230 16% 9%; /* charcoal-900 #121317 */
|
||||
--foreground: 215 19% 87%; /* charcoal-200 #D7D9DD */
|
||||
--muted: 220 8% 17%; /* charcoal-775 #1C1E21 */
|
||||
--muted-foreground: 220 8% 57%; /* charcoal-400 #878C99 */
|
||||
--border: 216 7% 27%; /* charcoal-650 #2C3034 */
|
||||
--primary: 95 100% 66%; /* apple-500 #A8FF53 */
|
||||
--primary-foreground: 230 16% 9%; /* charcoal-900 */
|
||||
--sidebar: 228 10% 11%; /* charcoal-850 #15171A */;
|
||||
|
||||
/* Code block styling */
|
||||
& [data-code-block-container] {
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { BackgroundWorkerMetadata, tryCatch } from "@trigger.dev/core/v3";
|
||||
import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { WorkerDeployment } from "@trigger.dev/database";
|
||||
import { PrismaClientOrTransaction, WorkerDeployment } from "@trigger.dev/database";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { syncTaskIdentifiers } from "~/services/taskIdentifierRegistry.server";
|
||||
import {
|
||||
type TaskMetadataCache,
|
||||
type TaskMetadataEntry,
|
||||
} from "~/services/taskMetadataCache.server";
|
||||
import { taskMetadataCacheInstance } from "~/services/taskMetadataCacheInstance.server";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import { syncDeclarativeSchedules } from "./createBackgroundWorker.server";
|
||||
import { ExecuteTasksWaitingForDeployService } from "./executeTasksWaitingForDeploy";
|
||||
@@ -11,6 +16,17 @@ import { compareDeploymentVersions } from "../utils/deploymentVersions";
|
||||
export type ChangeCurrentDeploymentDirection = "promote" | "rollback";
|
||||
|
||||
export class ChangeCurrentDeploymentService extends BaseService {
|
||||
private readonly _taskMetaCache: TaskMetadataCache;
|
||||
|
||||
constructor(
|
||||
prisma?: PrismaClientOrTransaction,
|
||||
replica?: PrismaClientOrTransaction,
|
||||
taskMetaCache: TaskMetadataCache = taskMetadataCacheInstance
|
||||
) {
|
||||
super(prisma, replica);
|
||||
this._taskMetaCache = taskMetaCache;
|
||||
}
|
||||
|
||||
public async call(
|
||||
deployment: WorkerDeployment,
|
||||
direction: ChangeCurrentDeploymentDirection,
|
||||
@@ -96,23 +112,59 @@ export class ChangeCurrentDeploymentService extends BaseService {
|
||||
},
|
||||
});
|
||||
|
||||
const [syncError] = await tryCatch(
|
||||
(async () => {
|
||||
const tasks = await this._prisma.backgroundWorkerTask.findMany({
|
||||
where: { workerId: deployment.workerId! },
|
||||
select: { slug: true, triggerSource: true },
|
||||
});
|
||||
await syncTaskIdentifiers(
|
||||
const [fetchTasksError, tasks] = await tryCatch(
|
||||
this._prisma.backgroundWorkerTask.findMany({
|
||||
where: { workerId: deployment.workerId! },
|
||||
select: {
|
||||
slug: true,
|
||||
triggerSource: true,
|
||||
ttl: true,
|
||||
queue: { select: { id: true, name: true } },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
if (fetchTasksError) {
|
||||
logger.error("Error fetching worker tasks on deployment change", {
|
||||
error: fetchTasksError,
|
||||
});
|
||||
}
|
||||
|
||||
if (tasks) {
|
||||
// Side effect 1: refresh the `TaskIdentifier` table and the existing
|
||||
// `tids:` Redis cache so the task-listing UI reflects the new deploy.
|
||||
const [syncIdentifiersError] = await tryCatch(
|
||||
syncTaskIdentifiers(
|
||||
deployment.environmentId,
|
||||
deployment.projectId,
|
||||
deployment.workerId!,
|
||||
tasks.map((t) => ({ id: t.slug, triggerSource: t.triggerSource }))
|
||||
);
|
||||
})()
|
||||
);
|
||||
)
|
||||
);
|
||||
|
||||
if (syncError) {
|
||||
logger.error("Error syncing task identifiers on deployment change", { error: syncError });
|
||||
if (syncIdentifiersError) {
|
||||
logger.error("Error syncing task identifiers on deployment change", {
|
||||
error: syncIdentifiersError,
|
||||
});
|
||||
}
|
||||
|
||||
// Side effect 2: refresh the `task-meta:` cache that the queue resolver
|
||||
// reads from. Independent of side effect 1 — if `syncTaskIdentifiers`
|
||||
// throws, the queue resolver still gets a warm cache for the new worker.
|
||||
const metadataEntries: TaskMetadataEntry[] = tasks.map((t) => ({
|
||||
slug: t.slug,
|
||||
ttl: t.ttl,
|
||||
triggerSource: t.triggerSource,
|
||||
queueId: t.queue?.id ?? null,
|
||||
queueName: t.queue?.name ?? "",
|
||||
}));
|
||||
|
||||
// Cache calls log+swallow internally.
|
||||
await this._taskMetaCache.populateByCurrentWorker(
|
||||
deployment.environmentId,
|
||||
deployment.workerId!,
|
||||
metadataEntries
|
||||
);
|
||||
}
|
||||
|
||||
const [scheduleSyncError] = await tryCatch(this.#syncSchedulesForDeployment(deployment));
|
||||
|
||||
@@ -14,6 +14,11 @@ import { sanitizeQueueName } from "~/models/taskQueue.server";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { syncTaskIdentifiers } from "~/services/taskIdentifierRegistry.server";
|
||||
import {
|
||||
type TaskMetadataCache,
|
||||
type TaskMetadataEntry,
|
||||
} from "~/services/taskMetadataCache.server";
|
||||
import { taskMetadataCacheInstance } from "~/services/taskMetadataCacheInstance.server";
|
||||
import { generateFriendlyId } from "../friendlyIdentifiers";
|
||||
import {
|
||||
removeQueueConcurrencyLimits,
|
||||
@@ -56,6 +61,17 @@ export function stripBackgroundWorkerMetadataForStorage(
|
||||
}
|
||||
|
||||
export class CreateBackgroundWorkerService extends BaseService {
|
||||
private readonly _taskMetaCache: TaskMetadataCache;
|
||||
|
||||
constructor(
|
||||
prisma?: PrismaClientOrTransaction,
|
||||
replica?: PrismaClientOrTransaction,
|
||||
taskMetaCache: TaskMetadataCache = taskMetadataCacheInstance
|
||||
) {
|
||||
super(prisma, replica);
|
||||
this._taskMetaCache = taskMetaCache;
|
||||
}
|
||||
|
||||
public async call(
|
||||
projectRef: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
@@ -147,7 +163,7 @@ export class CreateBackgroundWorkerService extends BaseService {
|
||||
throw new ServiceValidationError("Error creating background worker files");
|
||||
}
|
||||
|
||||
const [resourcesError] = await tryCatch(
|
||||
const [resourcesError, workerTaskEntries] = await tryCatch(
|
||||
createWorkerResources(
|
||||
body.metadata,
|
||||
backgroundWorker,
|
||||
@@ -212,6 +228,26 @@ export class CreateBackgroundWorkerService extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
// Populate task metadata cache. DEV workers are always "current" because
|
||||
// `findCurrentWorkerFromEnvironment` resolves DEV current as the latest
|
||||
// worker by createdAt. Non-DEV (deploy-built) workers are not promoted
|
||||
// here — promotion writes the `:env:` keyspace later in
|
||||
// changeCurrentDeployment / createDeploymentBackgroundWorkerV3.
|
||||
// Cache calls log+swallow internally, so a Redis blip can't break
|
||||
// anything else here. Empty `workerTaskEntries` is intentional — the
|
||||
// populate methods clear stale hashes for zero-task deploys.
|
||||
if (workerTaskEntries) {
|
||||
if (environment.type === "DEVELOPMENT") {
|
||||
await this._taskMetaCache.populateByCurrentWorker(
|
||||
environment.id,
|
||||
backgroundWorker.id,
|
||||
workerTaskEntries
|
||||
);
|
||||
} else {
|
||||
await this._taskMetaCache.populateByWorker(backgroundWorker.id, workerTaskEntries);
|
||||
}
|
||||
}
|
||||
|
||||
const [updateConcurrencyLimitsError] = await tryCatch(
|
||||
updateEnvConcurrencyLimits(environment)
|
||||
);
|
||||
@@ -265,17 +301,26 @@ export async function createWorkerResources(
|
||||
environment: AuthenticatedEnvironment,
|
||||
prisma: PrismaClientOrTransaction,
|
||||
tasksToBackgroundFiles?: Map<string, string>
|
||||
) {
|
||||
): Promise<TaskMetadataEntry[]> {
|
||||
// Create the queues
|
||||
const queues = await createWorkerQueues(metadata, worker, environment, prisma);
|
||||
|
||||
// Create the tasks
|
||||
await createWorkerTasks(metadata, queues, worker, environment, prisma, tasksToBackgroundFiles);
|
||||
const taskEntries = await createWorkerTasks(
|
||||
metadata,
|
||||
queues,
|
||||
worker,
|
||||
environment,
|
||||
prisma,
|
||||
tasksToBackgroundFiles
|
||||
);
|
||||
|
||||
// Register prompts
|
||||
if (metadata.prompts && metadata.prompts.length > 0) {
|
||||
await createWorkerPrompts(metadata.prompts, worker, environment, prisma);
|
||||
}
|
||||
|
||||
return taskEntries;
|
||||
}
|
||||
|
||||
async function createWorkerTasks(
|
||||
@@ -285,17 +330,22 @@ async function createWorkerTasks(
|
||||
environment: AuthenticatedEnvironment,
|
||||
prisma: PrismaClientOrTransaction,
|
||||
tasksToBackgroundFiles?: Map<string, string>
|
||||
) {
|
||||
): Promise<TaskMetadataEntry[]> {
|
||||
// Create tasks in chunks of 20
|
||||
const CHUNK_SIZE = 20;
|
||||
const entries: TaskMetadataEntry[] = [];
|
||||
for (let i = 0; i < metadata.tasks.length; i += CHUNK_SIZE) {
|
||||
const chunk = metadata.tasks.slice(i, i + CHUNK_SIZE);
|
||||
await Promise.all(
|
||||
const chunkEntries = await Promise.all(
|
||||
chunk.map((task) =>
|
||||
createWorkerTask(task, queues, worker, environment, prisma, tasksToBackgroundFiles)
|
||||
)
|
||||
);
|
||||
for (const entry of chunkEntries) {
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function createWorkerTask(
|
||||
@@ -305,7 +355,7 @@ async function createWorkerTask(
|
||||
environment: AuthenticatedEnvironment,
|
||||
prisma: PrismaClientOrTransaction,
|
||||
tasksToBackgroundFiles?: Map<string, string>
|
||||
) {
|
||||
): Promise<TaskMetadataEntry | null> {
|
||||
try {
|
||||
let queue = queues.find((queue) => queue.name === task.queue?.name);
|
||||
|
||||
@@ -331,6 +381,9 @@ async function createWorkerTask(
|
||||
? ("AGENT" as const)
|
||||
: ("STANDARD" as const);
|
||||
|
||||
const resolvedTtl =
|
||||
typeof task.ttl === "number" ? stringifyDuration(task.ttl) ?? null : task.ttl ?? null;
|
||||
|
||||
await prisma.backgroundWorkerTask.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("task"),
|
||||
@@ -348,12 +401,19 @@ async function createWorkerTask(
|
||||
config: task.agentConfig ? (task.agentConfig as any) : undefined,
|
||||
fileId: tasksToBackgroundFiles?.get(task.id) ?? null,
|
||||
maxDurationInSeconds: task.maxDuration ? clampMaxDuration(task.maxDuration) : null,
|
||||
ttl:
|
||||
typeof task.ttl === "number" ? stringifyDuration(task.ttl) ?? null : task.ttl ?? null,
|
||||
ttl: resolvedTtl,
|
||||
queueId: queue.id,
|
||||
payloadSchema: task.payloadSchema as any,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
slug: task.id,
|
||||
ttl: resolvedTtl,
|
||||
triggerSource: resolvedTriggerSource,
|
||||
queueId: queue.id,
|
||||
queueName: queue.name,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
// The error code for unique constraint violation in Prisma is P2002
|
||||
@@ -389,6 +449,7 @@ async function createWorkerTask(
|
||||
worker,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { CreateBackgroundWorkerRequestBody, tryCatch } from "@trigger.dev/core/v3";
|
||||
import type { BackgroundWorker } from "@trigger.dev/database";
|
||||
import type { BackgroundWorker, PrismaClientOrTransaction } from "@trigger.dev/database";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { syncTaskIdentifiers } from "~/services/taskIdentifierRegistry.server";
|
||||
import { type TaskMetadataCache } from "~/services/taskMetadataCache.server";
|
||||
import { taskMetadataCacheInstance } from "~/services/taskMetadataCacheInstance.server";
|
||||
import { socketIo } from "../handleSocketIo.server";
|
||||
import { updateEnvConcurrencyLimits } from "../runQueue.server";
|
||||
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
|
||||
@@ -24,6 +26,17 @@ import { CURRENT_DEPLOYMENT_LABEL, BackgroundWorkerId } from "@trigger.dev/core/
|
||||
* @deprecated
|
||||
*/
|
||||
export class CreateDeploymentBackgroundWorkerServiceV3 extends BaseService {
|
||||
private readonly _taskMetaCache: TaskMetadataCache;
|
||||
|
||||
constructor(
|
||||
prisma?: PrismaClientOrTransaction,
|
||||
replica?: PrismaClientOrTransaction,
|
||||
taskMetaCache: TaskMetadataCache = taskMetadataCacheInstance
|
||||
) {
|
||||
super(prisma, replica);
|
||||
this._taskMetaCache = taskMetaCache;
|
||||
}
|
||||
|
||||
public async call(
|
||||
projectRef: string,
|
||||
environment: AuthenticatedEnvironment,
|
||||
@@ -74,8 +87,14 @@ export class CreateDeploymentBackgroundWorkerServiceV3 extends BaseService {
|
||||
});
|
||||
}
|
||||
|
||||
let workerTaskEntries: Awaited<ReturnType<typeof createWorkerResources>> = [];
|
||||
try {
|
||||
await createWorkerResources(body.metadata, backgroundWorker, environment, this._prisma);
|
||||
workerTaskEntries = await createWorkerResources(
|
||||
body.metadata,
|
||||
backgroundWorker,
|
||||
environment,
|
||||
this._prisma
|
||||
);
|
||||
await syncDeclarativeSchedules(
|
||||
body.metadata.tasks,
|
||||
backgroundWorker,
|
||||
@@ -147,6 +166,16 @@ export class CreateDeploymentBackgroundWorkerServiceV3 extends BaseService {
|
||||
logger.error("Error syncing task identifiers", { error: syncIdError });
|
||||
}
|
||||
|
||||
// V3 promotes the deployment immediately above, so this worker is now
|
||||
// current for the env — write both keyspaces atomically. Cache calls
|
||||
// log+swallow internally. Empty `workerTaskEntries` is intentional: the
|
||||
// populate methods clear stale hashes for zero-task deploys.
|
||||
await this._taskMetaCache.populateByCurrentWorker(
|
||||
environment.id,
|
||||
backgroundWorker.id,
|
||||
workerTaskEntries
|
||||
);
|
||||
|
||||
try {
|
||||
//send a notification that a new worker has been created
|
||||
await projectPubSub.publish(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { CreateBackgroundWorkerRequestBody, logger, tryCatch } from "@trigger.dev/core/v3";
|
||||
import { BackgroundWorkerId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import type { BackgroundWorker, WorkerDeployment } from "@trigger.dev/database";
|
||||
import type { BackgroundWorker, PrismaClientOrTransaction, WorkerDeployment } from "@trigger.dev/database";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { type TaskMetadataCache } from "~/services/taskMetadataCache.server";
|
||||
import { taskMetadataCacheInstance } from "~/services/taskMetadataCacheInstance.server";
|
||||
import { BaseService, ServiceValidationError } from "./baseService.server";
|
||||
import {
|
||||
createBackgroundFiles,
|
||||
@@ -13,6 +15,17 @@ import { TimeoutDeploymentService } from "./timeoutDeployment.server";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
|
||||
private readonly _taskMetaCache: TaskMetadataCache;
|
||||
|
||||
constructor(
|
||||
prisma?: PrismaClientOrTransaction,
|
||||
replica?: PrismaClientOrTransaction,
|
||||
taskMetaCache: TaskMetadataCache = taskMetadataCacheInstance
|
||||
) {
|
||||
super(prisma, replica);
|
||||
this._taskMetaCache = taskMetaCache;
|
||||
}
|
||||
|
||||
public async call(
|
||||
environment: AuthenticatedEnvironment,
|
||||
deploymentId: string,
|
||||
@@ -110,7 +123,7 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
|
||||
throw serviceError;
|
||||
}
|
||||
|
||||
const [resourcesError] = await tryCatch(
|
||||
const [resourcesError, workerTaskEntries] = await tryCatch(
|
||||
createWorkerResources(
|
||||
body.metadata,
|
||||
backgroundWorker,
|
||||
@@ -134,6 +147,16 @@ export class CreateDeploymentBackgroundWorkerServiceV4 extends BaseService {
|
||||
throw serviceError;
|
||||
}
|
||||
|
||||
// V4 build path: worker created but NOT yet promoted to current. Write
|
||||
// only the `task-meta:by-worker:{workerId}` keyspace so locked-version
|
||||
// triggers against this build hit the cache. Promotion (which writes the
|
||||
// env keyspace) happens later via finalizeDeployment → changeCurrentDeployment.
|
||||
// Cache calls log+swallow internally, so a Redis blip can't stall the
|
||||
// deployment state machine. Empty entries clears stale hashes.
|
||||
if (workerTaskEntries) {
|
||||
await this._taskMetaCache.populateByWorker(backgroundWorker.id, workerTaskEntries);
|
||||
}
|
||||
|
||||
const [schedulesError] = await tryCatch(
|
||||
syncDeclarativeSchedules(body.metadata.tasks, backgroundWorker, environment, this._prisma)
|
||||
);
|
||||
|
||||
@@ -184,7 +184,7 @@ const radius = "0.5rem";
|
||||
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./app/**/*.{ts,jsx,tsx}"],
|
||||
content: ["./app/**/*.{ts,jsx,tsx}", "./node_modules/streamdown/dist/**/*.js"],
|
||||
theme: {
|
||||
container: {
|
||||
center: true,
|
||||
@@ -264,6 +264,18 @@ module.exports = {
|
||||
aiPrompts,
|
||||
aiMetrics,
|
||||
errors,
|
||||
// shadcn/ui color tokens used by streamdown's internal components
|
||||
// (link safety modal, code block actions, etc.)
|
||||
// Values are defined via CSS variables in .streamdown-container
|
||||
background: "hsl(var(--background, 230 16% 9%) / <alpha-value>)",
|
||||
foreground: "hsl(var(--foreground, 215 19% 87%) / <alpha-value>)",
|
||||
muted: {
|
||||
DEFAULT: "hsl(var(--muted, 220 8% 17%) / <alpha-value>)",
|
||||
foreground: "hsl(var(--muted-foreground, 220 8% 57%) / <alpha-value>)",
|
||||
},
|
||||
border: "hsl(var(--border, 216 7% 27%) / <alpha-value>)",
|
||||
sidebar: "hsl(var(--sidebar, 228 10% 11%) / <alpha-value>)",
|
||||
"primary-foreground": "hsl(var(--primary-foreground, 230 16% 9%) / <alpha-value>)",
|
||||
},
|
||||
focusStyles: {
|
||||
outline: "1px solid",
|
||||
|
||||
@@ -20,8 +20,10 @@ import { assertNonNullable, containerTest } from "@internal/testcontainers";
|
||||
import { trace } from "@opentelemetry/api";
|
||||
import { IOPacket } from "@trigger.dev/core/v3";
|
||||
import { TaskRun } from "@trigger.dev/database";
|
||||
import { Redis } from "ioredis";
|
||||
import { IdempotencyKeyConcern } from "~/runEngine/concerns/idempotencyKeys.server";
|
||||
import { DefaultQueueManager } from "~/runEngine/concerns/queues.server";
|
||||
import { RedisTaskMetadataCache } from "~/services/taskMetadataCache.server";
|
||||
import {
|
||||
EntitlementValidationParams,
|
||||
MaxAttemptsValidationParams,
|
||||
@@ -1737,3 +1739,295 @@ describe("RunEngineTriggerTaskService", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("DefaultQueueManager task metadata cache", () => {
|
||||
containerTest(
|
||||
"warm cache returns metadata without falling through to PG",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
|
||||
queue: { redis: redisOptions },
|
||||
runLock: { redis: redisOptions },
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": { name: "small-1x", cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
|
||||
},
|
||||
baseCostInCents: 0.0005,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "cached-task";
|
||||
const setup = await setupBackgroundWorker(engine, environment, taskIdentifier);
|
||||
|
||||
const redis = new Redis(redisOptions);
|
||||
const cache = new RedisTaskMetadataCache({ redis });
|
||||
|
||||
// Pre-populate cache with AGENT triggerSource; DB row has the default STANDARD.
|
||||
// If the read path hits the cache, the resulting TaskRun.taskKind reflects the
|
||||
// cached value. If it falls through to PG, it reflects STANDARD.
|
||||
await cache.populateByCurrentWorker(environment.id, setup.worker.id, [
|
||||
{
|
||||
slug: taskIdentifier,
|
||||
ttl: null,
|
||||
triggerSource: "AGENT",
|
||||
queueId: null,
|
||||
queueName: `task/${taskIdentifier}`,
|
||||
},
|
||||
]);
|
||||
|
||||
const queuesManager = new DefaultQueueManager(prisma, engine, undefined, cache);
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: queuesManager,
|
||||
idempotencyKeyConcern: new IdempotencyKeyConcern(prisma, engine, new MockTraceEventConcern()),
|
||||
validator: new MockTriggerTaskValidator(),
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024,
|
||||
});
|
||||
|
||||
const result = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment,
|
||||
body: { payload: { test: "x" } },
|
||||
});
|
||||
|
||||
assertNonNullable(result);
|
||||
expect(result.run.taskIdentifier).toBe(taskIdentifier);
|
||||
expect((result.run.annotations as { taskKind?: string } | null)?.taskKind).toBe("AGENT");
|
||||
|
||||
await redis.quit();
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"cache miss falls through to PG and back-fills the cache",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
|
||||
queue: { redis: redisOptions },
|
||||
runLock: { redis: redisOptions },
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": { name: "small-1x", cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
|
||||
},
|
||||
baseCostInCents: 0.0005,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "miss-task";
|
||||
await setupBackgroundWorker(engine, environment, taskIdentifier);
|
||||
|
||||
const redis = new Redis(redisOptions);
|
||||
const cache = new RedisTaskMetadataCache({ redis });
|
||||
|
||||
// Cache starts empty. Sanity-check both keyspaces.
|
||||
expect(await cache.getCurrent(environment.id, taskIdentifier)).toBeNull();
|
||||
|
||||
const queuesManager = new DefaultQueueManager(prisma, engine, undefined, cache);
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: queuesManager,
|
||||
idempotencyKeyConcern: new IdempotencyKeyConcern(prisma, engine, new MockTraceEventConcern()),
|
||||
validator: new MockTriggerTaskValidator(),
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024,
|
||||
});
|
||||
|
||||
const result = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment,
|
||||
body: { payload: { test: "x" } },
|
||||
});
|
||||
|
||||
assertNonNullable(result);
|
||||
expect((result.run.annotations as { taskKind?: string } | null)?.taskKind).toBe("STANDARD");
|
||||
|
||||
// Back-fill is fire-and-forget; poll with a bounded timeout to avoid CI flakes.
|
||||
let backfilled = await cache.getCurrent(environment.id, taskIdentifier);
|
||||
for (let i = 0; i < 40 && !backfilled; i++) {
|
||||
await setTimeout(25);
|
||||
backfilled = await cache.getCurrent(environment.id, taskIdentifier);
|
||||
}
|
||||
expect(backfilled).not.toBeNull();
|
||||
expect(backfilled?.triggerSource).toBe("STANDARD");
|
||||
expect(backfilled?.queueName).toBe(`task/${taskIdentifier}`);
|
||||
|
||||
await redis.quit();
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"queue-override + ttl path returns taskKind from cache without a BWT lookup",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
|
||||
queue: { redis: redisOptions },
|
||||
runLock: { redis: redisOptions },
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": { name: "small-1x", cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
|
||||
},
|
||||
baseCostInCents: 0.0005,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "override-task";
|
||||
const setup = await setupBackgroundWorker(engine, environment, taskIdentifier);
|
||||
|
||||
const redis = new Redis(redisOptions);
|
||||
const cache = new RedisTaskMetadataCache({ redis });
|
||||
|
||||
// Cache says AGENT; DB row says STANDARD. Caller provides both a queue
|
||||
// override and an explicit TTL — the hot path the PR regressed.
|
||||
await cache.populateByCurrentWorker(environment.id, setup.worker.id, [
|
||||
{
|
||||
slug: taskIdentifier,
|
||||
ttl: null,
|
||||
triggerSource: "AGENT",
|
||||
queueId: null,
|
||||
queueName: `task/${taskIdentifier}`,
|
||||
},
|
||||
]);
|
||||
|
||||
const queuesManager = new DefaultQueueManager(prisma, engine, undefined, cache);
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: queuesManager,
|
||||
idempotencyKeyConcern: new IdempotencyKeyConcern(prisma, engine, new MockTraceEventConcern()),
|
||||
validator: new MockTriggerTaskValidator(),
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024,
|
||||
});
|
||||
|
||||
const result = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment,
|
||||
body: {
|
||||
payload: { test: "x" },
|
||||
options: {
|
||||
queue: { name: "caller-queue" },
|
||||
ttl: "5m",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assertNonNullable(result);
|
||||
expect(result.run.queue).toBe("caller-queue");
|
||||
expect((result.run.annotations as { taskKind?: string } | null)?.taskKind).toBe("AGENT");
|
||||
|
||||
await redis.quit();
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
|
||||
containerTest(
|
||||
"locked-version trigger reads from by-worker keyspace, not env keyspace",
|
||||
async ({ prisma, redisOptions }) => {
|
||||
const engine = new RunEngine({
|
||||
prisma,
|
||||
worker: { redis: redisOptions, workers: 1, tasksPerWorker: 10, pollIntervalMs: 100 },
|
||||
queue: { redis: redisOptions },
|
||||
runLock: { redis: redisOptions },
|
||||
machines: {
|
||||
defaultMachine: "small-1x",
|
||||
machines: {
|
||||
"small-1x": { name: "small-1x", cpu: 0.5, memory: 0.5, centsPerMs: 0.0001 },
|
||||
},
|
||||
baseCostInCents: 0.0005,
|
||||
},
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
});
|
||||
|
||||
const environment = await setupAuthenticatedEnvironment(prisma, "PRODUCTION");
|
||||
const taskIdentifier = "keyspace-task";
|
||||
const worker = await setupBackgroundWorker(engine, environment, taskIdentifier);
|
||||
|
||||
const redis = new Redis(redisOptions);
|
||||
const cache = new RedisTaskMetadataCache({ redis });
|
||||
|
||||
// Populate the two keyspaces with conflicting triggerSource values so we
|
||||
// can tell which keyspace the read used. The real worker's by-worker
|
||||
// hash gets AGENT; the env hash gets SCHEDULED (seeded via a throwaway
|
||||
// worker id since `populateByCurrentWorker` writes both keyspaces and
|
||||
// we want the real worker's by-worker hash untouched).
|
||||
await cache.populateByWorker(worker.worker.id, [
|
||||
{
|
||||
slug: taskIdentifier,
|
||||
ttl: null,
|
||||
triggerSource: "AGENT",
|
||||
queueId: null,
|
||||
queueName: `task/${taskIdentifier}`,
|
||||
},
|
||||
]);
|
||||
await cache.populateByCurrentWorker(environment.id, "dummy-worker-for-env-seed", [
|
||||
{
|
||||
slug: taskIdentifier,
|
||||
ttl: null,
|
||||
triggerSource: "SCHEDULED",
|
||||
queueId: null,
|
||||
queueName: `task/${taskIdentifier}`,
|
||||
},
|
||||
]);
|
||||
|
||||
const queuesManager = new DefaultQueueManager(prisma, engine, undefined, cache);
|
||||
const triggerTaskService = new RunEngineTriggerTaskService({
|
||||
engine,
|
||||
prisma,
|
||||
payloadProcessor: new MockPayloadProcessor(),
|
||||
queueConcern: queuesManager,
|
||||
idempotencyKeyConcern: new IdempotencyKeyConcern(prisma, engine, new MockTraceEventConcern()),
|
||||
validator: new MockTriggerTaskValidator(),
|
||||
traceEventConcern: new MockTraceEventConcern(),
|
||||
tracer: trace.getTracer("test", "0.0.0"),
|
||||
metadataMaximumSize: 1024 * 1024,
|
||||
});
|
||||
|
||||
// Locked → by-worker keyspace → AGENT
|
||||
const locked = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment,
|
||||
body: {
|
||||
payload: { test: "x" },
|
||||
options: { lockToVersion: worker.worker.version },
|
||||
},
|
||||
});
|
||||
assertNonNullable(locked);
|
||||
expect((locked.run.annotations as { taskKind?: string } | null)?.taskKind).toBe("AGENT");
|
||||
|
||||
// Not locked → env keyspace → SCHEDULED
|
||||
const current = await triggerTaskService.call({
|
||||
taskId: taskIdentifier,
|
||||
environment,
|
||||
body: { payload: { test: "y" } },
|
||||
});
|
||||
assertNonNullable(current);
|
||||
expect((current.run.annotations as { taskKind?: string } | null)?.taskKind).toBe("SCHEDULED");
|
||||
|
||||
await redis.quit();
|
||||
await engine.quit();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ResolvedConfig } from "@trigger.dev/core/v3/build";
|
||||
import { BuildManifest, BuildTarget } from "@trigger.dev/core/v3/schemas";
|
||||
import { BundleResult, bundleWorker, createBuildManifestFromBundle } from "./bundle.js";
|
||||
import { bundleSkills } from "./bundleSkills.js";
|
||||
import {
|
||||
createBuildContext,
|
||||
notifyExtensionOnBuildComplete,
|
||||
@@ -8,6 +9,8 @@ import {
|
||||
resolvePluginsForContext,
|
||||
} from "./extensions.js";
|
||||
import { createExternalsBuildExtension } from "./externals.js";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join, relative, sep } from "node:path";
|
||||
import { generateContainerfile } from "../deploy/buildImage.js";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
@@ -97,6 +100,31 @@ export async function buildWorker(options: BuildWorkerOptions) {
|
||||
envVars: options.envVars,
|
||||
});
|
||||
|
||||
// Built-in skill bundler — discovers `ai.defineSkill` registrations
|
||||
// via a local indexer run and copies each skill folder into
|
||||
// `{destination}/.trigger/skills/{id}/` before Docker COPY picks up
|
||||
// the bundle. First-class, not a build extension.
|
||||
const skillsTmpDir = await mkdtemp(join(tmpdir(), "trigger-skills-"));
|
||||
const skillsBuildManifestPath = join(skillsTmpDir, "build.json");
|
||||
try {
|
||||
await writeFile(skillsBuildManifestPath, JSON.stringify(buildManifest));
|
||||
const skillsResult = await bundleSkills({
|
||||
buildManifest,
|
||||
buildManifestPath: skillsBuildManifestPath,
|
||||
workingDir: resolvedConfig.workingDir,
|
||||
env: {
|
||||
...process.env,
|
||||
...(options.envVars ?? {}),
|
||||
},
|
||||
logger: buildContext.logger,
|
||||
});
|
||||
buildManifest = skillsResult.buildManifest;
|
||||
} catch (err) {
|
||||
logger.warn("Skill bundling failed; continuing without skills", err);
|
||||
} finally {
|
||||
await rm(skillsTmpDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest);
|
||||
|
||||
if (options.target !== "dev") {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { dirname, isAbsolute, join, resolve as resolvePath } from "node:path";
|
||||
import type { BuildManifest, SkillManifest } from "@trigger.dev/core/v3/schemas";
|
||||
import { copyDirectoryRecursive } from "@trigger.dev/build/internal";
|
||||
import { indexWorkerManifest } from "../indexing/indexWorkerManifest.js";
|
||||
import { execOptionsForRuntime, type BuildLogger } from "@trigger.dev/core/v3/build";
|
||||
|
||||
export type BundleSkillsOptions = {
|
||||
buildManifest: BuildManifest;
|
||||
buildManifestPath: string;
|
||||
workingDir: string;
|
||||
env: Record<string, string | undefined>;
|
||||
logger: BuildLogger;
|
||||
};
|
||||
|
||||
export type BundleSkillsResult = {
|
||||
/** The input manifest, annotated with `skills` on return. */
|
||||
buildManifest: BuildManifest;
|
||||
/** Discovered skills, in deterministic order. */
|
||||
skills: SkillManifest[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Built-in skill bundler — not an extension. Runs the indexer locally
|
||||
* against the bundled worker output to discover `ai.defineSkill(...)`
|
||||
* registrations, validates each skill's `SKILL.md`, and copies the
|
||||
* folder into `{outputPath}/.trigger/skills/{id}/` so the deploy image
|
||||
* picks it up via the existing Dockerfile `COPY`.
|
||||
*
|
||||
* No `trigger.config.ts` changes required — discovery is side-effect
|
||||
* based, same mechanism as task/prompt registration.
|
||||
*/
|
||||
export async function bundleSkills(
|
||||
options: BundleSkillsOptions
|
||||
): Promise<BundleSkillsResult> {
|
||||
const { buildManifest, buildManifestPath, workingDir, env, logger } = options;
|
||||
|
||||
let skills: SkillManifest[];
|
||||
try {
|
||||
const workerManifest = await indexWorkerManifest({
|
||||
runtime: buildManifest.runtime,
|
||||
indexWorkerPath: buildManifest.indexWorkerEntryPoint,
|
||||
buildManifestPath,
|
||||
nodeOptions: execOptionsForRuntime(buildManifest.runtime, buildManifest),
|
||||
env,
|
||||
cwd: workingDir,
|
||||
otelHookInclude: buildManifest.otelImportHook?.include,
|
||||
otelHookExclude: buildManifest.otelImportHook?.exclude,
|
||||
handleStdout(data) {
|
||||
logger.debug(`[bundleSkills] ${data}`);
|
||||
},
|
||||
handleStderr(data) {
|
||||
if (!data.includes("Debugger attached")) {
|
||||
logger.debug(`[bundleSkills:stderr] ${data}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
skills = workerManifest.skills ?? [];
|
||||
} catch (err) {
|
||||
// Skill discovery via the indexer is best-effort — if the user's
|
||||
// bundle doesn't load cleanly here the downstream full indexer will
|
||||
// surface the real error. Warn so the user sees what went wrong.
|
||||
logger.warn(
|
||||
`[bundleSkills] skill discovery failed, skipping skill bundling: ${(err as Error).message}`
|
||||
);
|
||||
return { buildManifest, skills: [] };
|
||||
}
|
||||
|
||||
if (skills.length === 0) {
|
||||
return { buildManifest, skills: [] };
|
||||
}
|
||||
|
||||
// Destination layout differs between dev and deploy:
|
||||
// - Dev: the worker runs with cwd = workingDir, so skills must live at
|
||||
// {workingDir}/.trigger/skills/{id}/ for skill.local() to find them.
|
||||
// - Deploy: the Dockerfile COPY picks up everything under outputPath into
|
||||
// /app, so we target {outputPath}/.trigger/skills/{id}/ and the
|
||||
// container's cwd (/app) resolves correctly.
|
||||
const destinationRoot =
|
||||
buildManifest.target === "dev"
|
||||
? join(workingDir, ".trigger", "skills")
|
||||
: join(buildManifest.outputPath, ".trigger", "skills");
|
||||
|
||||
for (const skill of skills) {
|
||||
// Resolve the skill's source folder relative to the file that called
|
||||
// `skills.define(...)`. Absolute paths are honored as-is.
|
||||
const callerDir = skill.filePath
|
||||
? dirname(resolvePath(workingDir, skill.filePath))
|
||||
: workingDir;
|
||||
const sourcePath = isAbsolute(skill.sourcePath)
|
||||
? skill.sourcePath
|
||||
: resolvePath(callerDir, skill.sourcePath);
|
||||
const skillMdPath = join(sourcePath, "SKILL.md");
|
||||
|
||||
let skillMd: string;
|
||||
try {
|
||||
skillMd = await readFile(skillMdPath, "utf8");
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Skill "${skill.id}": SKILL.md not found at ${skillMdPath}. ` +
|
||||
`Registered via ai.defineSkill({ id: "${skill.id}", path: "${skill.sourcePath}" }) ` +
|
||||
`at ${skill.filePath}.`
|
||||
);
|
||||
}
|
||||
|
||||
if (!/^---\r?\n[\s\S]*?\r?\n---/.test(skillMd)) {
|
||||
throw new Error(
|
||||
`Skill "${skill.id}": SKILL.md at ${skillMdPath} is missing a frontmatter block.`
|
||||
);
|
||||
}
|
||||
if (!/\bname:\s*\S/.test(skillMd) || !/\bdescription:\s*\S/.test(skillMd)) {
|
||||
throw new Error(
|
||||
`Skill "${skill.id}": SKILL.md at ${skillMdPath} frontmatter must include both \`name\` and \`description\`.`
|
||||
);
|
||||
}
|
||||
|
||||
const skillDest = join(destinationRoot, skill.id);
|
||||
logger.debug(`[bundleSkills] Copying ${sourcePath} → ${skillDest}`);
|
||||
await copyDirectoryRecursive(sourcePath, skillDest);
|
||||
}
|
||||
|
||||
// Sort by id for deterministic manifest output
|
||||
skills = [...skills].sort((a, b) => a.id.localeCompare(b.id));
|
||||
|
||||
// Content hash is derived from each SKILL.md's content for cache invalidation
|
||||
// downstream (dashboard persistence in Phase 2). Not used in Phase 1.
|
||||
void createHash;
|
||||
void dirname;
|
||||
|
||||
return {
|
||||
buildManifest: { ...buildManifest, skills },
|
||||
skills,
|
||||
};
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
logBuildFailure,
|
||||
logBuildWarnings,
|
||||
} from "../build/bundle.js";
|
||||
import { bundleSkills } from "../build/bundleSkills.js";
|
||||
import {
|
||||
createBuildContext,
|
||||
notifyExtensionOnBuildComplete,
|
||||
@@ -118,6 +119,26 @@ export async function startDevSession({
|
||||
bundle.metafile
|
||||
);
|
||||
|
||||
// Built-in skill bundling — copies registered skill folders into
|
||||
// `.trigger/skills/{id}/` so `skill.local()` works at dev runtime.
|
||||
try {
|
||||
const buildManifestPath = join(
|
||||
workerDir?.path ?? destination.path,
|
||||
"build.json"
|
||||
);
|
||||
await writeJSONFile(buildManifestPath, buildManifest);
|
||||
const skillsResult = await bundleSkills({
|
||||
buildManifest,
|
||||
buildManifestPath,
|
||||
workingDir: rawConfig.workingDir,
|
||||
env: process.env,
|
||||
logger: buildContext.logger,
|
||||
});
|
||||
buildManifest = skillsResult.buildManifest;
|
||||
} catch (err) {
|
||||
logger.warn("Skill bundling failed during dev rebuild", err);
|
||||
}
|
||||
|
||||
buildManifest = await notifyExtensionOnBuildComplete(buildContext, buildManifest);
|
||||
|
||||
try {
|
||||
|
||||
@@ -127,7 +127,11 @@ export class TaskRunProcessPool {
|
||||
return { taskRunProcess: newProcess, isReused: false };
|
||||
}
|
||||
|
||||
async returnProcess(process: TaskRunProcess, version: string): Promise<void> {
|
||||
async returnProcess(
|
||||
process: TaskRunProcess,
|
||||
version: string,
|
||||
options?: { forceKill?: boolean }
|
||||
): Promise<void> {
|
||||
// Remove from busy processes for this version
|
||||
const busyProcesses = this.busyProcessesByVersion.get(version);
|
||||
if (busyProcesses) {
|
||||
@@ -141,6 +145,19 @@ export class TaskRunProcessPool {
|
||||
);
|
||||
}
|
||||
|
||||
// `forceKill` skips the reuse heuristic and tears the process down. Used
|
||||
// on outcomes that leave the process in a state we can't safely reuse
|
||||
// (OOM in particular — production would get a fresh container, so local
|
||||
// dev should match that).
|
||||
if (options?.forceKill) {
|
||||
logger.debug("[TaskRunProcessPool] Force-killing process", {
|
||||
version,
|
||||
pid: process.pid,
|
||||
});
|
||||
await this.killProcess(process);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.shouldReuseProcess(process, version)) {
|
||||
const availableCount = this.availableProcessesByVersion.get(version)?.length || 0;
|
||||
const busyCount = this.busyProcessesByVersion.get(version)?.size || 0;
|
||||
|
||||
@@ -184,6 +184,7 @@ await sendMessageInCatalog(
|
||||
manifest: {
|
||||
tasks,
|
||||
prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()),
|
||||
skills: resourceCatalog.listSkillManifests(),
|
||||
queues: resourceCatalog.listQueueManifests(),
|
||||
configPath: buildManifest.configPath,
|
||||
runtime: buildManifest.runtime,
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
CompleteRunAttemptResult,
|
||||
DequeuedMessage,
|
||||
IntervalService,
|
||||
isManualOutOfMemoryError,
|
||||
isOOMRunError,
|
||||
LogLevel,
|
||||
RunExecutionData,
|
||||
SuspendedProcessError,
|
||||
@@ -52,6 +54,12 @@ export class DevRunController {
|
||||
private readonly cwd?: string;
|
||||
private isCompletingRun = false;
|
||||
private isShuttingDown = false;
|
||||
// Set when the current attempt's outcome means the worker process can't
|
||||
// safely be reused (OOM in particular). Production gives every retry a
|
||||
// fresh container; local dev's process pool needs the same on these
|
||||
// outcomes or in-process state (e.g. session.in cursors) leaks across
|
||||
// attempts and the OOM retry skips the message that triggered it.
|
||||
private discardProcessOnReturn = false;
|
||||
|
||||
private state:
|
||||
| {
|
||||
@@ -539,6 +547,13 @@ export class DevRunController {
|
||||
error: TaskRunProcess.parseExecuteError(error),
|
||||
} satisfies TaskRunFailedExecutionResult;
|
||||
|
||||
// Same OOM check as the success path: if the thrown error parses to
|
||||
// an OOM, force-kill the process when it's eventually returned (via
|
||||
// runFinished / stop) instead of recycling it.
|
||||
if (isOOMRunError(completion.error) || isManualOutOfMemoryError(completion.error)) {
|
||||
this.discardProcessOnReturn = true;
|
||||
}
|
||||
|
||||
const completionResult = await this.httpClient.dev.completeRunAttempt(
|
||||
run.friendlyId,
|
||||
this.snapshotFriendlyId ?? snapshot.friendlyId,
|
||||
@@ -591,6 +606,9 @@ export class DevRunController {
|
||||
});
|
||||
|
||||
this.isCompletingRun = false;
|
||||
// Reset between attempts so a stale OOM flag from a prior attempt
|
||||
// doesn't force-kill a healthy reused process on RETRY_IMMEDIATELY.
|
||||
this.discardProcessOnReturn = false;
|
||||
|
||||
// Get process from pool instead of creating new one
|
||||
const { taskRunProcess, isReused } = await this.opts.taskRunProcessPool.getProcess(
|
||||
@@ -664,10 +682,22 @@ export class DevRunController {
|
||||
|
||||
this.isCompletingRun = true;
|
||||
|
||||
// Detect OOM in the failure result so we can force-kill the worker
|
||||
// instead of returning it to the pool. Mirrors the production behavior
|
||||
// where OOM retry happens on a brand-new container.
|
||||
if (
|
||||
!completion.ok &&
|
||||
(isOOMRunError(completion.error) || isManualOutOfMemoryError(completion.error))
|
||||
) {
|
||||
this.discardProcessOnReturn = true;
|
||||
}
|
||||
|
||||
// Return process to pool instead of killing it
|
||||
try {
|
||||
const version = this.opts.worker.serverWorker?.version || "unknown";
|
||||
await this.opts.taskRunProcessPool.returnProcess(this.taskRunProcess, version);
|
||||
await this.opts.taskRunProcessPool.returnProcess(this.taskRunProcess, version, {
|
||||
forceKill: this.discardProcessOnReturn,
|
||||
});
|
||||
this.taskRunProcess = undefined;
|
||||
} catch (error) {
|
||||
logger.debug("Failed to return task run process to pool, submitting completion anyway", {
|
||||
@@ -820,7 +850,9 @@ export class DevRunController {
|
||||
if (this.taskRunProcess) {
|
||||
try {
|
||||
const version = this.opts.worker.serverWorker?.version || "unknown";
|
||||
await this.opts.taskRunProcessPool.returnProcess(this.taskRunProcess, version);
|
||||
await this.opts.taskRunProcessPool.returnProcess(this.taskRunProcess, version, {
|
||||
forceKill: this.discardProcessOnReturn,
|
||||
});
|
||||
this.taskRunProcess = undefined;
|
||||
} catch (error) {
|
||||
logger.debug("Failed to return task run process to pool during runFinished", { error });
|
||||
@@ -854,7 +886,9 @@ export class DevRunController {
|
||||
if (this.taskRunProcess && !this.taskRunProcess.isBeingKilled) {
|
||||
try {
|
||||
const version = this.opts.worker.serverWorker?.version || "unknown";
|
||||
await this.opts.taskRunProcessPool.returnProcess(this.taskRunProcess, version);
|
||||
await this.opts.taskRunProcessPool.returnProcess(this.taskRunProcess, version, {
|
||||
forceKill: this.discardProcessOnReturn,
|
||||
});
|
||||
this.taskRunProcess = undefined;
|
||||
} catch (error) {
|
||||
logger.debug("Failed to return task run process to pool during stop", { error });
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
heartbeats,
|
||||
realtimeStreams,
|
||||
inputStreams,
|
||||
sessionStreams,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { TriggerTracer } from "@trigger.dev/core/v3/tracer";
|
||||
import {
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
StandardHeartbeatsManager,
|
||||
StandardRealtimeStreamsManager,
|
||||
StandardInputStreamManager,
|
||||
StandardSessionStreamManager,
|
||||
} from "@trigger.dev/core/v3/workers";
|
||||
import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc";
|
||||
import { readFile } from "node:fs/promises";
|
||||
@@ -186,6 +188,14 @@ const standardInputStreamManager = new StandardInputStreamManager(
|
||||
);
|
||||
inputStreams.setGlobalManager(standardInputStreamManager);
|
||||
|
||||
const standardSessionStreamManager = new StandardSessionStreamManager(
|
||||
apiClientManager.clientOrThrow(),
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev",
|
||||
(getEnvVar("TRIGGER_STREAMS_DEBUG") === "1" || getEnvVar("TRIGGER_STREAMS_DEBUG") === "true") ??
|
||||
false
|
||||
);
|
||||
sessionStreams.setGlobalManager(standardSessionStreamManager);
|
||||
|
||||
const waitUntilTimeoutInMs = getNumberEnvVar("TRIGGER_WAIT_UNTIL_TIMEOUT_MS", 60_000);
|
||||
const waitUntilManager = new StandardWaitUntilManager(waitUntilTimeoutInMs);
|
||||
waitUntil.setGlobalManager(waitUntilManager);
|
||||
@@ -360,6 +370,7 @@ function resetExecutionEnvironment() {
|
||||
runMetadataManager.reset();
|
||||
standardRealtimeStreamsManager.reset();
|
||||
standardInputStreamManager.reset();
|
||||
standardSessionStreamManager.reset();
|
||||
waitUntilManager.reset();
|
||||
_sharedWorkerRuntime?.reset();
|
||||
durableClock.reset();
|
||||
|
||||
@@ -104,6 +104,7 @@ async function indexDeployment({
|
||||
packageVersion: buildManifest.packageVersion,
|
||||
cliPackageVersion: buildManifest.cliPackageVersion,
|
||||
tasks: workerManifest.tasks,
|
||||
prompts: workerManifest.prompts,
|
||||
queues: workerManifest.queues,
|
||||
sourceFiles,
|
||||
runtime: workerManifest.runtime,
|
||||
|
||||
@@ -180,6 +180,7 @@ await sendMessageInCatalog(
|
||||
manifest: {
|
||||
tasks,
|
||||
prompts: convertPromptSchemasToJsonSchemas(resourceCatalog.listPromptManifests()),
|
||||
skills: resourceCatalog.listSkillManifests(),
|
||||
queues: resourceCatalog.listQueueManifests(),
|
||||
configPath: buildManifest.configPath,
|
||||
runtime: buildManifest.runtime,
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
heartbeats,
|
||||
realtimeStreams,
|
||||
inputStreams,
|
||||
sessionStreams,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { TriggerTracer } from "@trigger.dev/core/v3/tracer";
|
||||
import {
|
||||
@@ -61,6 +62,7 @@ import {
|
||||
StandardHeartbeatsManager,
|
||||
StandardRealtimeStreamsManager,
|
||||
StandardInputStreamManager,
|
||||
StandardSessionStreamManager,
|
||||
} from "@trigger.dev/core/v3/workers";
|
||||
import { ZodIpcConnection } from "@trigger.dev/core/v3/zodIpc";
|
||||
import { readFile } from "node:fs/promises";
|
||||
@@ -159,6 +161,14 @@ const standardInputStreamManager = new StandardInputStreamManager(
|
||||
);
|
||||
inputStreams.setGlobalManager(standardInputStreamManager);
|
||||
|
||||
const standardSessionStreamManager = new StandardSessionStreamManager(
|
||||
apiClientManager.clientOrThrow(),
|
||||
getEnvVar("TRIGGER_STREAM_URL", getEnvVar("TRIGGER_API_URL")) ?? "https://api.trigger.dev",
|
||||
(getEnvVar("TRIGGER_STREAMS_DEBUG") === "1" || getEnvVar("TRIGGER_STREAMS_DEBUG") === "true") ??
|
||||
false
|
||||
);
|
||||
sessionStreams.setGlobalManager(standardSessionStreamManager);
|
||||
|
||||
const waitUntilTimeoutInMs = getNumberEnvVar("TRIGGER_WAIT_UNTIL_TIMEOUT_MS", 60_000);
|
||||
const waitUntilManager = new StandardWaitUntilManager(waitUntilTimeoutInMs);
|
||||
waitUntil.setGlobalManager(waitUntilManager);
|
||||
@@ -333,6 +343,7 @@ function resetExecutionEnvironment() {
|
||||
waitUntilManager.reset();
|
||||
standardRealtimeStreamsManager.reset();
|
||||
standardInputStreamManager.reset();
|
||||
standardSessionStreamManager.reset();
|
||||
_sharedWorkerRuntime?.reset();
|
||||
durableClock.reset();
|
||||
taskContext.disable();
|
||||
|
||||
@@ -213,4 +213,28 @@ export const toolsMetadata = {
|
||||
description:
|
||||
"Reactivate a previous dashboard-sourced version as the active override. Use get_prompt_versions to find dashboard versions that can be reactivated.",
|
||||
},
|
||||
list_agents: {
|
||||
name: "list_agents",
|
||||
title: "List Agents",
|
||||
description:
|
||||
"List all chat agents in the current worker. Agents are tasks created with chat.agent() or chat.customAgent(). Use start_agent_chat with an agent's slug to start a conversation.",
|
||||
},
|
||||
start_agent_chat: {
|
||||
name: "start_agent_chat",
|
||||
title: "Start Agent Chat",
|
||||
description:
|
||||
"Start a conversation with a chat agent. Returns a chatId you can use with send_agent_message. Optionally preloads the agent so it initializes before the first message.",
|
||||
},
|
||||
send_agent_message: {
|
||||
name: "send_agent_message",
|
||||
title: "Send Agent Message",
|
||||
description:
|
||||
"Send a message to an active agent chat and get the full response text back. Use the chatId from start_agent_chat. The agent remembers full context from previous messages in the same chat.",
|
||||
},
|
||||
close_agent_chat: {
|
||||
name: "close_agent_chat",
|
||||
title: "Close Agent Chat",
|
||||
description:
|
||||
"Close an agent chat conversation. The agent exits its loop gracefully. Without this, the agent will close on its own when its idle timeout expires.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -29,6 +29,12 @@ import {
|
||||
removePromptOverrideTool,
|
||||
reactivatePromptOverrideTool,
|
||||
} from "./tools/prompts.js";
|
||||
import { listAgentsTool } from "./tools/agents.js";
|
||||
import {
|
||||
startAgentChatTool,
|
||||
sendAgentMessageTool,
|
||||
closeAgentChatTool,
|
||||
} from "./tools/agentChat.js";
|
||||
import { respondWithError } from "./utils.js";
|
||||
|
||||
/** Tool names that perform write/mutating operations. */
|
||||
@@ -43,6 +49,9 @@ const WRITE_TOOLS = new Set([
|
||||
updatePromptOverrideTool.name,
|
||||
removePromptOverrideTool.name,
|
||||
reactivatePromptOverrideTool.name,
|
||||
startAgentChatTool.name,
|
||||
sendAgentMessageTool.name,
|
||||
closeAgentChatTool.name,
|
||||
]);
|
||||
|
||||
export function registerTools(context: McpContext) {
|
||||
@@ -80,6 +89,10 @@ export function registerTools(context: McpContext) {
|
||||
updatePromptOverrideTool,
|
||||
removePromptOverrideTool,
|
||||
reactivatePromptOverrideTool,
|
||||
listAgentsTool,
|
||||
startAgentChatTool,
|
||||
sendAgentMessageTool,
|
||||
closeAgentChatTool,
|
||||
];
|
||||
|
||||
for (const tool of tools) {
|
||||
|
||||
@@ -0,0 +1,522 @@
|
||||
import { z } from "zod";
|
||||
import { ApiClient, SSEStreamSubscription } from "@trigger.dev/core/v3";
|
||||
import { toolsMetadata } from "../config.js";
|
||||
import { CommonProjectsInput } from "../schemas.js";
|
||||
import { respondWithError, toolHandler } from "../utils.js";
|
||||
|
||||
// ─── In-memory chat sessions ──────────────────────────────────────
|
||||
|
||||
type ChatMessage = {
|
||||
id: string;
|
||||
role: string;
|
||||
parts: Array<{ type: string; [key: string]: unknown }>;
|
||||
};
|
||||
|
||||
type ChatSession = {
|
||||
/** `session_*` friendlyId — durable identity for the conversation. */
|
||||
sessionId: string;
|
||||
/** Last-known live run id. Cleared when a run ends. */
|
||||
runId: string;
|
||||
chatId: string;
|
||||
agentId: string;
|
||||
lastEventId?: string;
|
||||
apiClient: ApiClient;
|
||||
clientData?: Record<string, unknown>;
|
||||
/** Accumulated conversation messages for continuation payloads. */
|
||||
messages: ChatMessage[];
|
||||
};
|
||||
|
||||
const activeSessions = new Map<string, ChatSession>();
|
||||
|
||||
// ─── ChatInputChunk serialization (mirrors TriggerChatTransport) ──
|
||||
//
|
||||
// Slim-wire: one delta `message` per chunk, not a full `messages[]` array.
|
||||
// The agent's run loop destructures `payload.message` (singular). Sending
|
||||
// a `messages: [...]` array makes `message` undefined and turn 1 calls
|
||||
// `streamText({ messages: [] })`, which throws
|
||||
// `AI_InvalidPromptError: messages must not be empty`.
|
||||
type ChatInputChunk =
|
||||
| {
|
||||
kind: "message";
|
||||
payload: {
|
||||
message?: ChatMessage;
|
||||
chatId: string;
|
||||
trigger: "submit-message" | "close" | "preload" | "regenerate-message" | "action";
|
||||
metadata?: unknown;
|
||||
sessionId?: string;
|
||||
continuation?: boolean;
|
||||
previousRunId?: string;
|
||||
};
|
||||
}
|
||||
| { kind: "stop"; message?: string };
|
||||
|
||||
function serializeInputChunk(chunk: ChatInputChunk): string {
|
||||
return JSON.stringify(chunk);
|
||||
}
|
||||
|
||||
// ─── Start Agent Chat ─────────────────────────────────────────────
|
||||
|
||||
const StartAgentChatInput = CommonProjectsInput.extend({
|
||||
agentId: z
|
||||
.string()
|
||||
.describe(
|
||||
"The agent task ID to chat with. Use get_current_worker to see available agents."
|
||||
),
|
||||
chatId: z
|
||||
.string()
|
||||
.describe("A unique conversation ID. Reuse to resume a conversation.")
|
||||
.optional(),
|
||||
clientData: z
|
||||
.record(z.unknown())
|
||||
.describe("Client data to include with every message (e.g. userId, model).")
|
||||
.optional(),
|
||||
preload: z
|
||||
.boolean()
|
||||
.describe("Whether to preload the agent before the first message.")
|
||||
.default(true),
|
||||
});
|
||||
|
||||
export const startAgentChatTool = {
|
||||
name: toolsMetadata.start_agent_chat.name,
|
||||
title: toolsMetadata.start_agent_chat.title,
|
||||
description: toolsMetadata.start_agent_chat.description,
|
||||
inputSchema: StartAgentChatInput.shape,
|
||||
handler: toolHandler(StartAgentChatInput.shape, async (input, { ctx }) => {
|
||||
ctx.logger?.log("calling start_agent_chat", { input });
|
||||
|
||||
if (ctx.options.devOnly && input.environment !== "dev") {
|
||||
return respondWithError(
|
||||
`This MCP server is only available for the dev environment.`
|
||||
);
|
||||
}
|
||||
|
||||
const projectRef = await ctx.getProjectRef({
|
||||
projectRef: input.projectRef,
|
||||
cwd: input.configPath,
|
||||
});
|
||||
|
||||
const apiClient = await ctx.getApiClient({
|
||||
projectRef,
|
||||
environment: input.environment,
|
||||
scopes: [
|
||||
"write:tasks",
|
||||
"read:runs",
|
||||
"read:sessions",
|
||||
"write:sessions",
|
||||
],
|
||||
branch: input.branch,
|
||||
});
|
||||
|
||||
const chatId = input.chatId ?? crypto.randomUUID();
|
||||
|
||||
// Check if session already exists
|
||||
if (activeSessions.has(chatId)) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Chat ${chatId} is already active with agent ${activeSessions.get(chatId)!.agentId}. Use send_agent_message to continue the conversation.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// Create (or upsert) the backing Session. Idempotent via externalId —
|
||||
// two MCP clients targeting the same chatId converge to the same row.
|
||||
// Sessions are now task-bound: taskIdentifier + triggerConfig are
|
||||
// required, and the server reuses them for every run scheduled by
|
||||
// this session (initial + continuations after run termination).
|
||||
//
|
||||
// basePayload mirrors the browser-mediated `chat.createStartSessionAction`
|
||||
// shape so the auto-triggered first run hits `onPreload` (not
|
||||
// `onChatStart` with `preloaded: true`). Without `trigger: "preload"`
|
||||
// + `messages: []`, the agent runtime bypasses both lifecycle hooks
|
||||
// and `onTurnStart`'s DB write fails with "No record found".
|
||||
//
|
||||
// POST /api/v1/sessions auto-triggers the first run and returns its
|
||||
// runId, so we don't need a separate triggerTask call. The `preload`
|
||||
// flag on this MCP tool is kept as a no-op signal (true=default) for
|
||||
// backwards compat — a Session is always created with a live run now.
|
||||
const session = await apiClient.createSession({
|
||||
type: "chat.agent",
|
||||
externalId: chatId,
|
||||
taskIdentifier: input.agentId,
|
||||
triggerConfig: {
|
||||
basePayload: {
|
||||
messages: [],
|
||||
trigger: "preload",
|
||||
chatId,
|
||||
...(input.clientData ? { metadata: input.clientData } : {}),
|
||||
},
|
||||
tags: [`chat:${chatId}`],
|
||||
},
|
||||
});
|
||||
|
||||
activeSessions.set(chatId, {
|
||||
sessionId: session.id,
|
||||
runId: session.runId,
|
||||
chatId,
|
||||
agentId: input.agentId,
|
||||
apiClient,
|
||||
clientData: input.clientData,
|
||||
messages: [],
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: [
|
||||
`Agent chat started${input.preload ? " and preloaded" : ""}.`,
|
||||
`- Chat ID: ${chatId}`,
|
||||
`- Session ID: ${session.id}`,
|
||||
`- Agent: ${input.agentId}`,
|
||||
`- Run ID: ${session.runId}`,
|
||||
``,
|
||||
`Use send_agent_message with chatId "${chatId}" to send messages.`,
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// ─── Send Agent Message ───────────────────────────────────────────
|
||||
|
||||
const SendAgentMessageInput = z.object({
|
||||
chatId: z.string().describe("The chat ID from start_agent_chat."),
|
||||
message: z.string().describe("The message to send to the agent."),
|
||||
});
|
||||
|
||||
export const sendAgentMessageTool = {
|
||||
name: toolsMetadata.send_agent_message.name,
|
||||
title: toolsMetadata.send_agent_message.title,
|
||||
description: toolsMetadata.send_agent_message.description,
|
||||
inputSchema: SendAgentMessageInput.shape,
|
||||
handler: toolHandler(SendAgentMessageInput.shape, async (input, { ctx }) => {
|
||||
ctx.logger?.log("calling send_agent_message", { input });
|
||||
|
||||
const session = activeSessions.get(input.chatId);
|
||||
if (!session) {
|
||||
return respondWithError(
|
||||
`No active chat with ID "${input.chatId}". Use start_agent_chat first.`
|
||||
);
|
||||
}
|
||||
|
||||
const msgId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const userMessage: ChatMessage = {
|
||||
id: msgId, role: "user", parts: [{ type: "text", text: input.message }],
|
||||
};
|
||||
|
||||
// Track the outgoing user message
|
||||
session.messages.push(userMessage);
|
||||
|
||||
// Slim-wire: one delta `message` per trigger. Prior turns live in the
|
||||
// session.out snapshot+replay; we only ship the new user message.
|
||||
const wirePayload = {
|
||||
message: userMessage,
|
||||
chatId: session.chatId,
|
||||
trigger: "submit-message" as const,
|
||||
metadata: session.clientData,
|
||||
};
|
||||
|
||||
// If we have an active run, send via session.in. If that fails
|
||||
// (run ended, token expired, etc.) fall back to triggering a new
|
||||
// run on the same session — the new run replays prior turns from the
|
||||
// snapshot and picks up `message` as turn N's user delta.
|
||||
if (session.runId) {
|
||||
try {
|
||||
await session.apiClient.appendToSessionStream(
|
||||
session.sessionId,
|
||||
"in",
|
||||
serializeInputChunk({ kind: "message", payload: wirePayload })
|
||||
);
|
||||
} catch (sendErr: any) {
|
||||
ctx.logger?.log("appendToSessionStream failed, falling back to triggerTask", {
|
||||
chatId: session.chatId,
|
||||
sessionId: session.sessionId,
|
||||
error: sendErr?.message ?? String(sendErr),
|
||||
});
|
||||
const result = await session.apiClient.triggerTask(session.agentId, {
|
||||
payload: {
|
||||
message: userMessage,
|
||||
chatId: session.chatId,
|
||||
sessionId: session.sessionId,
|
||||
trigger: "submit-message",
|
||||
metadata: session.clientData,
|
||||
continuation: true,
|
||||
previousRunId: session.runId,
|
||||
},
|
||||
options: {
|
||||
payloadType: "application/json",
|
||||
tags: [`chat:${session.chatId}`],
|
||||
},
|
||||
});
|
||||
session.runId = result.id;
|
||||
// Keep session.lastEventId as-is. The .out stream is per-session, so
|
||||
// resuming from the last-seen chunk's id skips historical chunks —
|
||||
// including stale `trigger:turn-complete` markers from prior turns
|
||||
// that would otherwise break collectAgentResponse's read loop with
|
||||
// empty/old text. Same reasoning as the trigger:upgrade-required
|
||||
// path below.
|
||||
}
|
||||
} else {
|
||||
// No run yet — trigger one (agent opens the session on startup).
|
||||
const result = await session.apiClient.triggerTask(session.agentId, {
|
||||
payload: {
|
||||
...wirePayload,
|
||||
sessionId: session.sessionId,
|
||||
},
|
||||
options: {
|
||||
payloadType: "application/json",
|
||||
tags: [`chat:${session.chatId}`],
|
||||
},
|
||||
});
|
||||
session.runId = result.id;
|
||||
}
|
||||
|
||||
// Subscribe to the response stream and collect the full text
|
||||
const { text, toolCalls, assistantMessage } = await collectAgentResponse(session);
|
||||
|
||||
// Track the assistant response for continuation payloads
|
||||
session.messages.push(assistantMessage);
|
||||
|
||||
const formatted = formatAssistantParts(assistantMessage.parts);
|
||||
const footer = `\n\n---\nRun: ${session.runId}`;
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: formatted + footer }],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// ─── Close Agent Chat ─────────────────────────────────────────────
|
||||
|
||||
const CloseAgentChatInput = z.object({
|
||||
chatId: z.string().describe("The chat ID to close."),
|
||||
});
|
||||
|
||||
export const closeAgentChatTool = {
|
||||
name: toolsMetadata.close_agent_chat.name,
|
||||
title: toolsMetadata.close_agent_chat.title,
|
||||
description: toolsMetadata.close_agent_chat.description,
|
||||
inputSchema: CloseAgentChatInput.shape,
|
||||
handler: toolHandler(CloseAgentChatInput.shape, async (input, { ctx }) => {
|
||||
ctx.logger?.log("calling close_agent_chat", { input });
|
||||
|
||||
const session = activeSessions.get(input.chatId);
|
||||
if (!session) {
|
||||
return respondWithError(
|
||||
`No active chat with ID "${input.chatId}".`
|
||||
);
|
||||
}
|
||||
|
||||
if (session.runId) {
|
||||
try {
|
||||
await session.apiClient.appendToSessionStream(
|
||||
session.sessionId,
|
||||
"in",
|
||||
serializeInputChunk({
|
||||
kind: "message",
|
||||
payload: {
|
||||
// `trigger: "close"` carries no message delta — the agent
|
||||
// looks at `trigger` and exits without touching `message`.
|
||||
chatId: session.chatId,
|
||||
trigger: "close",
|
||||
},
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
// Best effort — run may already be done
|
||||
}
|
||||
}
|
||||
|
||||
activeSessions.delete(input.chatId);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `Chat ${input.chatId} closed.`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
// ─── Stream collector ─────────────────────────────────────────────
|
||||
|
||||
// Safety bound on chained upgrades during a single send. A misconfigured
|
||||
// agent or upgrade-loop bug would otherwise grow the call stack without limit.
|
||||
const MAX_UPGRADE_RECURSION_DEPTH = 10;
|
||||
|
||||
async function collectAgentResponse(
|
||||
session: ChatSession,
|
||||
depth = 0
|
||||
): Promise<{ text: string; toolCalls: string[]; assistantMessage: ChatMessage }> {
|
||||
if (depth > MAX_UPGRADE_RECURSION_DEPTH) {
|
||||
throw new Error(
|
||||
`Agent upgrade recursion depth exceeded (${depth} chained trigger:upgrade-required signals)`
|
||||
);
|
||||
}
|
||||
const baseURL = session.apiClient.baseUrl;
|
||||
const streamUrl = `${baseURL}/realtime/v1/sessions/${encodeURIComponent(session.sessionId)}/out`;
|
||||
|
||||
const subscription = new SSEStreamSubscription(streamUrl, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.apiClient.accessToken}`,
|
||||
},
|
||||
timeoutInSeconds: 120,
|
||||
lastEventId: session.lastEventId,
|
||||
});
|
||||
|
||||
const sseStream = await subscription.subscribe();
|
||||
const reader = sseStream.getReader();
|
||||
|
||||
let text = "";
|
||||
const toolCalls: string[] = [];
|
||||
const parts: Array<{ type: string; [key: string]: unknown }> = [];
|
||||
// Track current text part to accumulate deltas
|
||||
let currentTextId: string | undefined;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (value.id) {
|
||||
session.lastEventId = value.id;
|
||||
}
|
||||
|
||||
// v2 (session) SSE already parses record.body.data, so `chunk` is
|
||||
// the UIMessageChunk object written by the agent.
|
||||
if (value.chunk != null && typeof value.chunk === "object") {
|
||||
const chunk = value.chunk as Record<string, unknown>;
|
||||
|
||||
if (chunk.type === "trigger:turn-complete") {
|
||||
break;
|
||||
}
|
||||
|
||||
if (chunk.type === "trigger:upgrade-required") {
|
||||
// Agent requested upgrade — trigger continuation. Same session,
|
||||
// new run — reuse sessionId, swap runId. Slim-wire: ship only
|
||||
// the latest user message as the turn-N delta; prior turns
|
||||
// come back via snapshot+replay on the new run's boot.
|
||||
const lastUserMessage = [...session.messages]
|
||||
.reverse()
|
||||
.find((m) => m.role === "user");
|
||||
const previousRunId = session.runId;
|
||||
const result = await session.apiClient.triggerTask(session.agentId, {
|
||||
payload: {
|
||||
message: lastUserMessage,
|
||||
chatId: session.chatId,
|
||||
sessionId: session.sessionId,
|
||||
trigger: "submit-message",
|
||||
metadata: session.clientData,
|
||||
continuation: true,
|
||||
previousRunId,
|
||||
},
|
||||
options: {
|
||||
payloadType: "application/json",
|
||||
tags: [`chat:${session.chatId}`],
|
||||
},
|
||||
});
|
||||
session.runId = result.id;
|
||||
// Keep session.lastEventId pointing at the trigger:upgrade-required
|
||||
// chunk's id (set at line 370 when the chunk arrived). The recursive
|
||||
// subscribe resumes right after that marker, so we don't replay the
|
||||
// entire session.out stream — which would hit a historical
|
||||
// trigger:turn-complete and break the loop with empty/old text.
|
||||
reader.releaseLock();
|
||||
// Recurse — subscribe to the new run's stream (same session.out URL)
|
||||
return collectAgentResponse(session, depth + 1);
|
||||
}
|
||||
|
||||
if (chunk.type === "text-delta" && typeof chunk.delta === "string") {
|
||||
text += chunk.delta;
|
||||
// Accumulate into a text part
|
||||
const textId = (chunk.id as string) ?? "text";
|
||||
if (currentTextId !== textId) {
|
||||
currentTextId = textId;
|
||||
parts.push({ type: "text", text: chunk.delta });
|
||||
} else {
|
||||
const last = parts[parts.length - 1];
|
||||
if (last && last.type === "text") {
|
||||
last.text = (last.text as string) + chunk.delta;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.type === "tool-input-available" && typeof chunk.toolName === "string") {
|
||||
toolCalls.push(chunk.toolName);
|
||||
parts.push({
|
||||
type: `tool-${chunk.toolName}`,
|
||||
toolCallId: chunk.toolCallId as string,
|
||||
toolName: chunk.toolName,
|
||||
state: "input-available",
|
||||
input: chunk.input,
|
||||
});
|
||||
}
|
||||
|
||||
if (chunk.type === "tool-output-available" && typeof chunk.toolCallId === "string") {
|
||||
// Update existing tool part with output
|
||||
const toolPart = parts.find(
|
||||
(p) => p.toolCallId === chunk.toolCallId
|
||||
);
|
||||
if (toolPart) {
|
||||
toolPart.state = "output-available";
|
||||
toolPart.output = chunk.output;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
role: "assistant",
|
||||
parts: parts.length > 0 ? parts : [{ type: "text", text }],
|
||||
};
|
||||
|
||||
return { text, toolCalls, assistantMessage };
|
||||
}
|
||||
|
||||
// ─── Response formatter ──────────────────────────────────────────
|
||||
|
||||
function formatAssistantParts(
|
||||
parts: Array<{ type: string; [key: string]: unknown }>
|
||||
): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "text" && typeof part.text === "string" && part.text) {
|
||||
sections.push(part.text);
|
||||
} else if (part.type.startsWith("tool-") && part.toolName) {
|
||||
const name = part.toolName as string;
|
||||
const input = part.input;
|
||||
const output = part.output;
|
||||
|
||||
let toolSection = `[Tool: ${name}]`;
|
||||
if (input != null) {
|
||||
toolSection += `\nInput: ${compactJson(input)}`;
|
||||
}
|
||||
if (output != null) {
|
||||
toolSection += `\nOutput: ${compactJson(output)}`;
|
||||
}
|
||||
sections.push(toolSection);
|
||||
}
|
||||
}
|
||||
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
function compactJson(value: unknown): string {
|
||||
const str = JSON.stringify(value);
|
||||
// Keep short values inline, truncate long ones
|
||||
if (str.length <= 200) return str;
|
||||
return str.slice(0, 200) + "…";
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { toolsMetadata } from "../config.js";
|
||||
import { CommonProjectsInput } from "../schemas.js";
|
||||
import { respondWithError, toolHandler } from "../utils.js";
|
||||
|
||||
export const listAgentsTool = {
|
||||
name: toolsMetadata.list_agents.name,
|
||||
title: toolsMetadata.list_agents.title,
|
||||
description: toolsMetadata.list_agents.description,
|
||||
inputSchema: CommonProjectsInput.shape,
|
||||
handler: toolHandler(CommonProjectsInput.shape, async (input, { ctx }) => {
|
||||
ctx.logger?.log("calling list_agents", { input });
|
||||
|
||||
if (ctx.options.devOnly && input.environment !== "dev") {
|
||||
return respondWithError(
|
||||
`This MCP server is only available for the dev environment. You tried to access the ${input.environment} environment. Remove the --dev-only flag to access other environments.`
|
||||
);
|
||||
}
|
||||
|
||||
const projectRef = await ctx.getProjectRef({
|
||||
projectRef: input.projectRef,
|
||||
cwd: input.configPath,
|
||||
});
|
||||
|
||||
const cliApiClient = await ctx.getCliApiClient(input.branch);
|
||||
|
||||
const workerResult = await cliApiClient.getWorkerByTag(
|
||||
projectRef,
|
||||
input.environment,
|
||||
"current"
|
||||
);
|
||||
|
||||
if (!workerResult.success) {
|
||||
return respondWithError(workerResult.error);
|
||||
}
|
||||
|
||||
const { worker } = workerResult.data;
|
||||
const agents = worker.tasks.filter((t) => t.triggerSource === "AGENT");
|
||||
|
||||
if (agents.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `No agents found in the current worker (${worker.version}) for ${input.environment}. Agents are tasks created with chat.agent() or chat.customAgent().`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const contents = [
|
||||
`Found ${agents.length} agent${agents.length === 1 ? "" : "s"} in worker ${worker.version} (${input.environment}):`,
|
||||
"",
|
||||
];
|
||||
|
||||
for (const agent of agents) {
|
||||
contents.push(`- **${agent.slug}** (${agent.filePath})`);
|
||||
}
|
||||
|
||||
contents.push("");
|
||||
contents.push(
|
||||
"Use `start_agent_chat` with an agent's slug as the `agentId` to start a conversation."
|
||||
);
|
||||
contents.push(
|
||||
"Use `get_task_schema` with an agent's slug to see its payload schema."
|
||||
);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: contents.join("\n") }],
|
||||
};
|
||||
}),
|
||||
};
|
||||
@@ -44,7 +44,8 @@ export const getCurrentWorker = {
|
||||
contents.push(`The worker has ${worker.tasks.length} tasks registered:`);
|
||||
|
||||
for (const task of worker.tasks) {
|
||||
contents.push(`- ${task.slug} in ${task.filePath}`);
|
||||
const label = task.triggerSource === "AGENT" ? " [agent]" : "";
|
||||
contents.push(`- ${task.slug}${label} in ${task.filePath}`);
|
||||
}
|
||||
|
||||
contents.push("");
|
||||
|
||||
@@ -5,7 +5,7 @@ export class NoopLocalsManager implements LocalsManager {
|
||||
return {
|
||||
__type: Symbol(),
|
||||
id,
|
||||
} as unknown as LocalsKey<T>;
|
||||
};
|
||||
}
|
||||
|
||||
getLocal<T>(key: LocalsKey<T>): T | undefined {
|
||||
@@ -23,7 +23,7 @@ export class StandardLocalsManager implements LocalsManager {
|
||||
return {
|
||||
__type: key,
|
||||
id,
|
||||
} as unknown as LocalsKey<T>;
|
||||
};
|
||||
}
|
||||
|
||||
getLocal<T>(key: LocalsKey<T>): T | undefined {
|
||||
|
||||
@@ -1,10 +1,22 @@
|
||||
declare const __local: unique symbol;
|
||||
type BrandLocal<T> = { [__local]: T };
|
||||
|
||||
// Create a type-safe store for your locals
|
||||
export type LocalsKey<T> = BrandLocal<T> & {
|
||||
/**
|
||||
* A type-safe key for `locals`. Carries the value type `T` as a phantom
|
||||
* marker on the optional `__valueType` field so two keys with different
|
||||
* value types are distinguishable at the type level.
|
||||
*
|
||||
* The phantom field is intentionally not anchored to a `unique symbol`:
|
||||
* dual-package builds (`tshy`) emit separate `.d.ts` files for ESM and
|
||||
* CJS outputs, and each `unique symbol` declaration in a `.d.ts` is its
|
||||
* own nominal type. If a single compilation ever resolves `LocalsKey`
|
||||
* from both the ESM and CJS paths — which happens under certain pnpm
|
||||
* hoisting layouts — `unique symbol` brands produce structurally
|
||||
* incompatible variants of the same type. A plain string brand avoids
|
||||
* the hazard.
|
||||
*/
|
||||
export type LocalsKey<T> = {
|
||||
readonly id: string;
|
||||
readonly __type: unique symbol;
|
||||
readonly __type: symbol;
|
||||
/** Phantom carrier for the value type — never read at runtime. */
|
||||
readonly __valueType?: T;
|
||||
};
|
||||
|
||||
export interface LocalsManager {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Local dev (Trigger.dev webapp running on http://localhost:3030)
|
||||
#
|
||||
# Create a project in the local dashboard (References org) and copy:
|
||||
# - TRIGGER_PROJECT_REF from the project URL (proj_...)
|
||||
# - TRIGGER_SECRET_KEY from the project's Dev environment API keys
|
||||
# - NEXT_PUBLIC_TRIGGER_PROJECT_DASHBOARD_PATH from the dashboard URL
|
||||
TRIGGER_API_URL="http://localhost:3030"
|
||||
NEXT_PUBLIC_TRIGGER_API_URL="http://localhost:3030"
|
||||
NEXT_PUBLIC_TRIGGER_DASHBOARD_URL="http://localhost:3030"
|
||||
NEXT_PUBLIC_TRIGGER_PROJECT_DASHBOARD_PATH="/orgs/<org-slug>/projects/<project-slug>"
|
||||
TRIGGER_SECRET_KEY="tr_dev_..."
|
||||
TRIGGER_PROJECT_REF="proj_..."
|
||||
|
||||
# Postgres database for the ai-chat reference app itself (separate from the
|
||||
# webapp's database). Schema is applied by `npx prisma migrate deploy`.
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/ai_chat?schema=public"
|
||||
|
||||
# Model providers — needed by the chat task. At least one is required.
|
||||
OPENAI_API_KEY=""
|
||||
ANTHROPIC_API_KEY=""
|
||||
|
||||
# Optional — code-sandbox tool
|
||||
E2B_API_KEY=""
|
||||
|
||||
# Optional — analytics
|
||||
POSTHOG_API_KEY=""
|
||||
POSTHOG_PROJECT_ID=""
|
||||
|
||||
# Optional — tuning knobs
|
||||
COMPACT_AFTER_TOKENS="50000"
|
||||
WEBFETCH_LATENCY_MS="0"
|
||||
@@ -0,0 +1 @@
|
||||
lib/generated/
|
||||
@@ -0,0 +1 @@
|
||||
v22
|
||||
@@ -0,0 +1,51 @@
|
||||
# Demo Cheat Sheet
|
||||
|
||||
## Pitch
|
||||
- Started as workflow engine, now people building chat agents
|
||||
- Deep AI SDK useChat integration
|
||||
- One chat = one persistent isolated execution environment
|
||||
- Two-way communication
|
||||
|
||||
## 1. Preloading
|
||||
- Click New Chat, DON'T type anything
|
||||
- Flip to dashboard — run already executing
|
||||
- "waiting for first message" span
|
||||
- Zero cold start
|
||||
|
||||
## 2. First message — PostHog query
|
||||
- "What are the top events on our PostHog instance this week?"
|
||||
- Watch posthogQuery tool call
|
||||
- Real data, real HogQL
|
||||
- Show trace: onTurnStart → run → tool call → response
|
||||
- Run stays alive after turn
|
||||
|
||||
## 3. Follow-up — incremental
|
||||
- "Which of those are custom events vs autocapture?"
|
||||
- Only new message sent, not full history
|
||||
- Backend has context in memory
|
||||
- Same execution environment
|
||||
|
||||
## 4. Suspend/resume
|
||||
- 60s idle → snapshot → suspend → zero compute
|
||||
- Next message → restore → continue
|
||||
- Same run, same state
|
||||
|
||||
## 5. Tool subtasks
|
||||
- "Can you research what's new with PostHog lately?"
|
||||
- deepResearch = separate task, own container
|
||||
- Streams progress back to chat
|
||||
- Show trace: triggerAndSubscribe → child run
|
||||
- Stop cancels child automatically
|
||||
|
||||
## 6. Code
|
||||
- All regions collapsed — show the skeleton
|
||||
- idleTimeoutInSeconds, clientDataSchema
|
||||
- Hooks: onPreload, onTurnStart, onTurnComplete, run
|
||||
- Expand run: just return streamText()
|
||||
- Expand onTurnComplete: background self-review, chat.inject()
|
||||
|
||||
## Wrap
|
||||
- One chat, one persistent run
|
||||
- Lifecycle hooks, streaming, subtasks, background injection
|
||||
- Snapshot/restore, full observability
|
||||
- Available now
|
||||
@@ -0,0 +1,96 @@
|
||||
# AI Chat Demo Script (5-7 min)
|
||||
|
||||
**Setup:** Three windows ready — ai-chat app (localhost:3000), Trigger.dev dashboard, VS Code with chat.ts open (all regions collapsed).
|
||||
|
||||
**Audience:** PostHog event
|
||||
|
||||
**Pitch:** Trigger.dev started as a workflow engine for async background tasks, but more and more people are using us to build full chat agents. We've built a deep integration with the AI SDK's useChat hook that connects a single chat to a single persisted, isolated, fully customizable execution environment with two-way communication.
|
||||
|
||||
---
|
||||
|
||||
## 1. New chat — preloading (1 min)
|
||||
|
||||
**Open localhost:3000. Click "New Chat".**
|
||||
|
||||
> I haven't typed anything yet. But flip to the dashboard —
|
||||
|
||||
**Switch to dashboard. Show the run that just started.**
|
||||
|
||||
> There's already a run executing. This is preloading. When the user opens the chat page, the frontend calls `transport.preload()` which triggers the task immediately. It loaded the user from the DB, resolved the system prompt, created the chat record — all before the first keystroke. Imagine this in something like PostHog's AI product assistant — when a user opens the chat, you want the agent ready instantly, not cold-starting while they wait.
|
||||
|
||||
**Point to the "waiting for first message" span.**
|
||||
|
||||
---
|
||||
|
||||
## 2. First message + live analytics query (1.5 min)
|
||||
|
||||
**Switch back to chat. Type: "What are the top events on our PostHog instance this week?"**
|
||||
|
||||
> Now the first turn starts — and watch, it's going to call the posthogQuery tool. This tool writes a HogQL query and runs it against our actual PostHog instance — this is our real Trigger.dev analytics data.
|
||||
|
||||
**Watch the tool call + results stream back.**
|
||||
|
||||
> It wrote the query, executed it, and summarized the results — all in one turn.
|
||||
|
||||
**Switch to dashboard, show turn 1 span with the tool call.**
|
||||
|
||||
> Here's the lifecycle — onTurnStart persisted the message, run() called streamText, the LLM decided to use the posthogQuery tool, got the results, and generated a response. After the turn completes, the run doesn't end — it waits for the next message. Same process, same memory.
|
||||
|
||||
---
|
||||
|
||||
## 3. Follow-up — incremental sends + persistent state (45s)
|
||||
|
||||
**Switch back to chat. Send: "How does that compare to last week?" or "Which of those are custom events vs autocapture?"**
|
||||
|
||||
**Switch to dashboard, show turn 2.**
|
||||
|
||||
> Turn 2 — the frontend only sent the new user message, not the full conversation. The backend already has the accumulated context. It knows what "those" refers to because it's the same execution environment. For a product analytics assistant where users iteratively drill into their data, this is huge — no context lost between turns.
|
||||
|
||||
---
|
||||
|
||||
## 4. Idle, suspend, resume (30s)
|
||||
|
||||
> After 60 seconds of no messages, the run snapshots its state and suspends. Zero compute while the user is away. When they come back — maybe they went to check their PostHog dashboard based on what the agent told them and came back with a follow-up — we restore from the snapshot and continue. Same run, same state.
|
||||
|
||||
**Point to the "suspended" span in the trace if visible.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Tool subtasks (1 min)
|
||||
|
||||
**Switch back to chat. Send: "Can you research what's new with PostHog lately?"**
|
||||
|
||||
> Now it's using the deepResearch tool — this one is different. It's a separate Trigger.dev task running in its own container, fetching multiple URLs and streaming progress back to the chat in real time. You could have tools for querying PostHog, tools for checking feature flags, tools for pulling session recordings — and the heavy ones run as subtasks with their own retries and traces.
|
||||
|
||||
**Show the trace — triggerAndSubscribe span with child run nested inside.**
|
||||
|
||||
> The parent subscribes to the child via realtime. If the user hits stop, the child gets cancelled automatically.
|
||||
|
||||
---
|
||||
|
||||
## 6. The code (1.5 min)
|
||||
|
||||
**Switch to VS Code with chat.ts, all regions collapsed.**
|
||||
|
||||
> This is the whole thing — one file. A chat.task with lifecycle hooks and a run function.
|
||||
|
||||
Point out the collapsed view:
|
||||
|
||||
- `idleTimeoutInSeconds`, `clientDataSchema` — typed metadata from the frontend
|
||||
- `onPreload` — that's what fired before the first message
|
||||
- `onTurnStart`, `onTurnComplete` — persistence hooks
|
||||
- `run` — just `return streamText()`. The SDK handles everything else.
|
||||
|
||||
**Expand the run region.**
|
||||
|
||||
> Messages come in already converted. You return streamText. The posthogQuery tool is just a plain AI SDK tool that calls the PostHog API — deepResearch is a subtask wrapped with ai.tool. Mix and match.
|
||||
|
||||
**Expand onTurnComplete if time.**
|
||||
|
||||
> After every turn we defer a background call to gpt-4o-mini that reviews the response with generateObject. If it finds improvements, chat.inject adds a system message before the next LLM call. The agent gets coaching between turns — and it doesn't block the user.
|
||||
|
||||
---
|
||||
|
||||
## 7. Wrap up (15s)
|
||||
|
||||
> One chat, one persistent run. Lifecycle hooks, streaming, tool subtasks, background self-improvement — all on Trigger.dev's infrastructure with snapshot/restore and full observability. This is available now in the SDK.
|
||||
@@ -0,0 +1,78 @@
|
||||
# AI Chat Reference App
|
||||
|
||||
A multi-turn chat app built with the AI SDK's `useChat` hook and Trigger.dev's `chat.task`. Conversations run as durable Trigger.dev tasks with realtime streaming, automatic message accumulation, and persistence across page refreshes.
|
||||
|
||||
## Data Models
|
||||
|
||||
### Chat
|
||||
|
||||
The conversation itself — your application data.
|
||||
|
||||
| Column | Description |
|
||||
| ---------- | ---------------------------------------- |
|
||||
| `id` | Unique chat ID (generated on the client) |
|
||||
| `title` | Display title for the sidebar |
|
||||
| `messages` | Full `UIMessage[]` history (JSON) |
|
||||
|
||||
A Chat lives forever (until the user deletes it). It is independent of any particular Trigger.dev run.
|
||||
|
||||
### ChatSession
|
||||
|
||||
The transport's connection state for a chat — what the frontend needs to reconnect to the same Trigger.dev run after a page refresh.
|
||||
|
||||
| Column | Description |
|
||||
| ------------------- | --------------------------------------------------------------------------- |
|
||||
| `id` | Same as the chat ID (1:1 relationship) |
|
||||
| `runId` | The Trigger.dev run handling this conversation |
|
||||
| `publicAccessToken` | Scoped token for reading the run's stream and sending input stream messages |
|
||||
| `lastEventId` | Stream position — used to resume without replaying old events |
|
||||
|
||||
A Chat can outlive many ChatSessions. When the run ends (turn timeout, max turns reached, crash), the ChatSession is gone but the Chat and its messages remain. The next message from the user starts a fresh run and creates a new ChatSession for the same Chat.
|
||||
|
||||
**Think of it as: Chat = the conversation, ChatSession = the live connection to the run handling it.**
|
||||
|
||||
## Lifecycle Hooks
|
||||
|
||||
Persistence is handled server-side in the Trigger.dev task via three hooks:
|
||||
|
||||
- **`onChatStart`** — Creates the Chat and ChatSession records when a new conversation starts (turn 0).
|
||||
- **`onTurnStart`** — Saves messages and updates the session _before_ streaming begins, so a mid-stream page refresh still shows the user's message.
|
||||
- **`onTurnComplete`** — Saves the assistant's response and the `lastEventId` for stream resumption.
|
||||
|
||||
## Setup
|
||||
|
||||
This reference assumes you already have the local webapp running per the repo's [`CONTRIBUTING.md`](../../CONTRIBUTING.md) (Docker services, `pnpm run db:migrate`, `pnpm run db:seed`, webapp on `:3030`).
|
||||
|
||||
Unlike `hello-world`, the ai-chat project is **not** in the webapp seed. You'll need to create it manually:
|
||||
|
||||
1. Open http://localhost:3030, log in, switch to the `References` org, and create a new project called `ai-chat`.
|
||||
2. Grab the project ref (`proj_...`) from the URL and a Dev secret key from the project's API keys page.
|
||||
3. Set up this app's env and database:
|
||||
|
||||
```bash
|
||||
cd references/ai-chat
|
||||
cp .env.example .env
|
||||
# Fill in TRIGGER_PROJECT_REF, TRIGGER_SECRET_KEY,
|
||||
# NEXT_PUBLIC_TRIGGER_PROJECT_DASHBOARD_PATH, and at least one
|
||||
# of OPENAI_API_KEY / ANTHROPIC_API_KEY.
|
||||
npx prisma migrate deploy
|
||||
```
|
||||
|
||||
The `DATABASE_URL` in `.env.example` points at the local Postgres started by `pnpm run docker` and uses a separate `ai_chat` database.
|
||||
|
||||
## Running
|
||||
|
||||
Three terminals from the repo root:
|
||||
|
||||
```bash
|
||||
# 1. Webapp (if not already running)
|
||||
pnpm run dev --filter webapp
|
||||
|
||||
# 2. Trigger CLI dev (registers the chat tasks with the local webapp)
|
||||
cd references/ai-chat && pnpm exec trigger dev
|
||||
|
||||
# 3. Next.js dev server for the chat UI
|
||||
cd references/ai-chat && pnpm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000 to use the chat app.
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
devIndicators: false,
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"name": "references-ai-chat",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"dev:trigger": "trigger dev",
|
||||
"db:migrate": "prisma migrate dev",
|
||||
"db:push": "prisma db push",
|
||||
"db:generate": "prisma generate",
|
||||
"db:reset:chats": "prisma db execute --file prisma/reset-chats.sql",
|
||||
"test": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.0",
|
||||
"@ai-sdk/openai": "^3.0.0",
|
||||
"@ai-sdk/react": "^3.0.0",
|
||||
"@prisma/adapter-pg": "^7.4.2",
|
||||
"@prisma/client": "^7.4.2",
|
||||
"@e2b/code-interpreter": "^2.4.0",
|
||||
"@trigger.dev/sdk": "workspace:*",
|
||||
"serialize-error": "^11.0.3",
|
||||
"ai": "^6.0.0",
|
||||
"next": "15.3.3",
|
||||
"pg": "^8.16.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"streamdown": "^2.3.0",
|
||||
"turndown": "^7.2.2",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@trigger.dev/build": "workspace:*",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"prisma": "^7.4.2",
|
||||
"tailwindcss": "^4",
|
||||
"trigger.dev": "workspace:*",
|
||||
"typescript": "^5",
|
||||
"vitest": "^3.1.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,12 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig, env } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: env("DATABASE_URL"),
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Chat" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"messages" JSONB NOT NULL DEFAULT '[]',
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Chat_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ChatSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"runId" TEXT NOT NULL,
|
||||
"publicAccessToken" TEXT NOT NULL,
|
||||
"lastEventId" TEXT,
|
||||
|
||||
CONSTRAINT "ChatSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Chat" ADD COLUMN "userId" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"plan" TEXT NOT NULL DEFAULT 'free',
|
||||
"preferredModel" TEXT,
|
||||
"messageCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- DropTable
|
||||
DROP TABLE IF EXISTS "UserTool";
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Chat" ADD COLUMN "model" TEXT NOT NULL DEFAULT 'gpt-4o-mini';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "githubToken" TEXT;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "ChatSession" ADD COLUMN "sessionId" TEXT;
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `runId` on the `ChatSession` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `sessionId` on the `ChatSession` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "ChatSession" DROP COLUMN "runId",
|
||||
DROP COLUMN "sessionId";
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Wipe customer-side chat state for a fresh smoke-test slate.
|
||||
-- Run via `pnpm run db:reset:chats`.
|
||||
-- Leaves User rows intact (they're upserted by onPreload/onChatStart),
|
||||
-- but clears every Chat + ChatSession so a chatId from one target
|
||||
-- (test cloud / local) can't carry stale session/PAT/lastEventId state
|
||||
-- into the other.
|
||||
TRUNCATE "Chat", "ChatSession";
|
||||
@@ -0,0 +1,42 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../lib/generated/prisma"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id
|
||||
name String
|
||||
plan String @default("free") // "free" | "pro"
|
||||
preferredModel String?
|
||||
githubToken String?
|
||||
messageCount Int @default(0)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
chats Chat[]
|
||||
}
|
||||
|
||||
model Chat {
|
||||
id String @id
|
||||
title String
|
||||
model String @default("gpt-4o-mini")
|
||||
messages Json @default("[]")
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
// Persistable session state for a chat. After the Sessions-as-run-manager
|
||||
// refactor, the transport addresses by `chatId` (used as the Session
|
||||
// `externalId`) on every wire path — so we only need a session-scoped
|
||||
// PAT and the SSE last-event-id for resume. Runs come and go inside
|
||||
// the Session and are managed server-side.
|
||||
model ChatSession {
|
||||
id String @id // chatId
|
||||
publicAccessToken String
|
||||
lastEventId String?
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
"use server";
|
||||
|
||||
import { auth } from "@trigger.dev/sdk";
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import type {
|
||||
aiChat,
|
||||
aiChatHydrated,
|
||||
aiChatRaw,
|
||||
aiChatSession,
|
||||
upgradeTestAgent,
|
||||
} from "@/trigger/chat";
|
||||
import type { ChatUiMessage } from "@/lib/chat-tools-schemas";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/** Short-lived PATs for local testing of expiry + renewal (not for production). */
|
||||
const CHAT_EXAMPLE_PAT_TTL = "1h" as const;
|
||||
|
||||
export type ChatReferenceTaskId =
|
||||
| "ai-chat"
|
||||
| "ai-chat-hydrated"
|
||||
| "ai-chat-raw"
|
||||
| "ai-chat-session"
|
||||
| "upgrade-test";
|
||||
|
||||
function isChatReferenceTaskId(id: string): id is ChatReferenceTaskId {
|
||||
return (
|
||||
id === "ai-chat" ||
|
||||
id === "ai-chat-hydrated" ||
|
||||
id === "ai-chat-raw" ||
|
||||
id === "ai-chat-session" ||
|
||||
id === "upgrade-test"
|
||||
);
|
||||
}
|
||||
|
||||
/** Keeps compile-time alignment with exported chat tasks. */
|
||||
type TaskIdentifierForChat =
|
||||
| (typeof aiChat)["id"]
|
||||
| (typeof aiChatHydrated)["id"]
|
||||
| (typeof aiChatRaw)["id"]
|
||||
| (typeof aiChatSession)["id"]
|
||||
| (typeof upgradeTestAgent)["id"];
|
||||
|
||||
/**
|
||||
* Server-mediated start: creates the Session row + triggers the first
|
||||
* run via secret-key access, returns the session-scoped PAT for the
|
||||
* browser to use. Wired into the transport's `startSession` callback —
|
||||
* the transport invokes it on `transport.preload(chatId)` and lazily on
|
||||
* the first `sendMessage` for any chatId without a cached PAT.
|
||||
*
|
||||
* The browser never sees a `start` token in this path; the customer's
|
||||
* server keeps the secret.
|
||||
*
|
||||
* `clientData` flows through from the transport's typed `clientData`
|
||||
* option — same value the transport merges into per-turn `metadata`
|
||||
* — and lands in `triggerConfig.basePayload.metadata` so the first
|
||||
* run's `payload.metadata` (visible to `onPreload` / `onChatStart`)
|
||||
* matches what subsequent turns see. Server-side authorization can
|
||||
* still override or augment what the browser claims (e.g. ignore a
|
||||
* spoofed userId and substitute the request-session's userId).
|
||||
*/
|
||||
const startChatSessionFor = (taskId: TaskIdentifierForChat) =>
|
||||
chat.createStartSessionAction(taskId, { tokenTTL: CHAT_EXAMPLE_PAT_TTL });
|
||||
|
||||
const startActionByTaskId: Record<
|
||||
ChatReferenceTaskId,
|
||||
ReturnType<typeof startChatSessionFor>
|
||||
> = {
|
||||
"ai-chat": startChatSessionFor("ai-chat"),
|
||||
"ai-chat-hydrated": startChatSessionFor("ai-chat-hydrated"),
|
||||
"ai-chat-raw": startChatSessionFor("ai-chat-raw"),
|
||||
"ai-chat-session": startChatSessionFor("ai-chat-session"),
|
||||
"upgrade-test": startChatSessionFor("upgrade-test"),
|
||||
};
|
||||
|
||||
export async function startChatSession(input: {
|
||||
chatId: string;
|
||||
taskId?: string;
|
||||
clientData?: Record<string, unknown>;
|
||||
}): Promise<{ publicAccessToken: string }> {
|
||||
const id = input.taskId ?? "ai-chat";
|
||||
const taskId: ChatReferenceTaskId = !isChatReferenceTaskId(id) ? "ai-chat" : id;
|
||||
|
||||
// `clientData` arrives from the transport's typed `clientData` option.
|
||||
// In a real app the server would also resolve the user from the
|
||||
// request session and merge/override accordingly — never trust the
|
||||
// browser-claimed identity. The reference demo just trusts it.
|
||||
const result = await startActionByTaskId[taskId]({
|
||||
chatId: input.chatId,
|
||||
triggerConfig: input.clientData
|
||||
? { basePayload: { metadata: input.clientData } }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
// Persist the latest PAT alongside the chat so a fresh tab can
|
||||
// hydrate without going through the start callback again.
|
||||
await prisma.chatSession
|
||||
.upsert({
|
||||
where: { id: input.chatId },
|
||||
create: { id: input.chatId, publicAccessToken: result.publicAccessToken },
|
||||
update: { publicAccessToken: result.publicAccessToken },
|
||||
})
|
||||
.catch(() => {
|
||||
/* best-effort persistence */
|
||||
});
|
||||
|
||||
return { publicAccessToken: result.publicAccessToken };
|
||||
}
|
||||
|
||||
/**
|
||||
* Mint a session-scoped PAT for a chatId. Pure: just calls
|
||||
* `auth.createPublicToken` with `read:sessions:{chatId}` +
|
||||
* `write:sessions:{chatId}` scopes — no DB writes, no session
|
||||
* creation, no run triggering.
|
||||
*
|
||||
* The transport's `accessToken` callback wraps this. It fires on
|
||||
* initial use (when no PAT is hydrated) and on 401/403 refresh.
|
||||
* Session creation happens separately via `startChatSession` at page
|
||||
* load — keeping these concerns split avoids re-triggering runs every
|
||||
* time a PAT expires.
|
||||
*/
|
||||
export async function mintChatAccessToken(chatId: string): Promise<string> {
|
||||
return auth.createPublicToken({
|
||||
scopes: {
|
||||
read: { sessions: chatId },
|
||||
write: { sessions: chatId },
|
||||
},
|
||||
expirationTime: CHAT_EXAMPLE_PAT_TTL,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getChatList() {
|
||||
const chats = await prisma.chat.findMany({
|
||||
select: { id: true, title: true, model: true, createdAt: true, updatedAt: true },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
});
|
||||
return chats.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
model: c.model,
|
||||
createdAt: c.createdAt.getTime(),
|
||||
updatedAt: c.updatedAt.getTime(),
|
||||
}));
|
||||
}
|
||||
|
||||
export async function getChatMessages(chatId: string): Promise<ChatUiMessage[]> {
|
||||
const found = await prisma.chat.findUnique({ where: { id: chatId } });
|
||||
if (!found) return [];
|
||||
return found.messages as unknown as ChatUiMessage[];
|
||||
}
|
||||
|
||||
export async function deleteChat(chatId: string) {
|
||||
await prisma.chat.delete({ where: { id: chatId } }).catch(() => { });
|
||||
await prisma.chatSession.delete({ where: { id: chatId } }).catch(() => { });
|
||||
}
|
||||
|
||||
export async function deleteAllChats() {
|
||||
await prisma.chatSession.deleteMany();
|
||||
await prisma.chat.deleteMany();
|
||||
}
|
||||
|
||||
export async function updateChatTitle(chatId: string, title: string) {
|
||||
await prisma.chat.update({ where: { id: chatId }, data: { title } }).catch(() => { });
|
||||
}
|
||||
|
||||
export async function updateSessionLastEventId(chatId: string, lastEventId: string) {
|
||||
await prisma.chatSession
|
||||
.update({ where: { id: chatId }, data: { lastEventId } })
|
||||
.catch(() => { });
|
||||
}
|
||||
|
||||
export async function deleteSessionAction(chatId: string) {
|
||||
await prisma.chatSession.delete({ where: { id: chatId } }).catch(() => { });
|
||||
}
|
||||
|
||||
export async function getSessionForChat(chatId: string) {
|
||||
const session = await prisma.chatSession.findUnique({ where: { id: chatId } });
|
||||
if (!session) return null;
|
||||
return {
|
||||
publicAccessToken: session.publicAccessToken,
|
||||
lastEventId: session.lastEventId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllSessions() {
|
||||
const sessions = await prisma.chatSession.findMany();
|
||||
const result: Record<string, { publicAccessToken: string; lastEventId?: string }> = {};
|
||||
for (const s of sessions) {
|
||||
result[s.id] = {
|
||||
publicAccessToken: s.publicAccessToken,
|
||||
lastEventId: s.lastEventId ?? undefined,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* chat.headStart first-turn endpoint.
|
||||
*
|
||||
* The browser transport POSTs first-turn messages here when the
|
||||
* `headStart` option is set on `useTriggerChatTransport`. This
|
||||
* handler:
|
||||
*
|
||||
* 1. Creates the chat.agent session and triggers a `handover-prepare`
|
||||
* run (atomic, one round-trip), so the agent boots in parallel.
|
||||
* 2. Runs `streamText` step 1 right here in the warm Next.js process
|
||||
* and returns the SSE stream directly to the browser — no waiting
|
||||
* on the agent's cold start.
|
||||
* 3. On step 1's tool-call boundary, hands ownership of the durable
|
||||
* session.out stream over to the agent run, which executes tools
|
||||
* and continues from step 2+ (or exits clean for pure-text turns).
|
||||
*
|
||||
* Subsequent turns bypass this endpoint — the transport hydrates the
|
||||
* session PAT from response headers and writes directly to
|
||||
* `session.in` for turn 2 onward.
|
||||
*
|
||||
* The TTFC win: cold-start agent boot (~488ms) + onTurnStart hooks
|
||||
* (~316ms) overlap with the LLM TTFB instead of stacking before it,
|
||||
* so the user-perceived first chunk arrives ~50% sooner. The agent
|
||||
* still owns tool execution and everything after — heavy deps stay
|
||||
* where they belong.
|
||||
*/
|
||||
import { chat } from "@trigger.dev/sdk/chat-server";
|
||||
import { streamText } from "ai";
|
||||
import { anthropic } from "@ai-sdk/anthropic";
|
||||
// ⚠️ Imports MUST come from `chat-tools-schemas` only — see the
|
||||
// header comment in that file for the bundle-isolation rationale.
|
||||
// Importing `src/trigger/chat-tools.ts` here would drag E2B,
|
||||
// turndown, the trigger SDK runtime, etc. into the Next.js bundle
|
||||
// and defeat the whole point of `chat.headStart`.
|
||||
import { headStartTools } from "@/lib/chat-tools-schemas";
|
||||
|
||||
export const POST = chat.headStart({
|
||||
agentId: "ai-chat",
|
||||
run: async ({ chat: chatHelper }) => {
|
||||
return streamText({
|
||||
// `toStreamTextOptions` wires `messages` (converted from
|
||||
// UIMessages), `tools`, `stopWhen: stepCountIs(1)`, and the
|
||||
// combined `abortSignal`. Customer adds model + system prompt on
|
||||
// top — anything else `streamText` accepts is fair game.
|
||||
...chatHelper.toStreamTextOptions({ tools: headStartTools }),
|
||||
// Match the agent's default (`DEFAULT_MODEL` in `lib/models.ts`)
|
||||
// so step 1 and step 2+ run on the same provider — no jarring
|
||||
// tone/style shift mid-turn, and TTFC comparisons stay honest.
|
||||
model: anthropic("claude-sonnet-4-6"),
|
||||
system:
|
||||
"You are a helpful AI assistant. Be concise and friendly. Use the available tools when relevant.",
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
getChatMessages,
|
||||
getSessionForChat,
|
||||
getChatList,
|
||||
} from "@/app/actions";
|
||||
import { ChatView } from "@/components/chat-view";
|
||||
import { DEFAULT_MODEL } from "@/lib/models";
|
||||
|
||||
export default async function ChatPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ chatId: string }>;
|
||||
}) {
|
||||
const { chatId } = await params;
|
||||
|
||||
// Hydrate any persisted session PAT from a previous visit. For brand
|
||||
// new chats `getSessionForChat` returns null and the client-side
|
||||
// `chat-view.tsx` mount triggers `startChatSession` with the
|
||||
// user-selected `taskMode` — the server-rendered page can't see the
|
||||
// dropdown's React-context state.
|
||||
const [messages, session, chatList] = await Promise.all([
|
||||
getChatMessages(chatId),
|
||||
getSessionForChat(chatId),
|
||||
getChatList(),
|
||||
]);
|
||||
|
||||
const chatMeta = chatList.find((c) => c.id === chatId);
|
||||
const isNewChat = !chatMeta;
|
||||
const model = chatMeta?.model ?? DEFAULT_MODEL;
|
||||
|
||||
return (
|
||||
<ChatView
|
||||
chatId={chatId}
|
||||
initialMessages={messages}
|
||||
initialSession={session}
|
||||
isNewChat={isNewChat}
|
||||
model={model}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getChatList } from "@/app/actions";
|
||||
import { ChatSettingsProvider } from "@/components/chat-settings-context";
|
||||
import { ChatSidebarWrapper } from "@/components/chat-sidebar-wrapper";
|
||||
|
||||
export default async function ChatsLayout({ children }: { children: React.ReactNode }) {
|
||||
const chatList = await getChatList();
|
||||
|
||||
return (
|
||||
<ChatSettingsProvider>
|
||||
<main className="flex h-screen">
|
||||
<ChatSidebarWrapper initialChatList={chatList} />
|
||||
<div className="flex-1">{children}</div>
|
||||
</main>
|
||||
</ChatSettingsProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getChatList } from "@/app/actions";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function ChatsPage() {
|
||||
const chatList = await getChatList();
|
||||
|
||||
if (chatList.length > 0) {
|
||||
redirect(`/chats/${chatList[0]!.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-gray-400">No conversations yet. Start a new chat.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@source "../../../node_modules/streamdown/dist/*.js";
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import "streamdown/styles.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AI Chat — Trigger.dev",
|
||||
description: "AI SDK useChat powered by Trigger.dev durable tasks",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-gray-50 text-gray-900 antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/chats");
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { generateId } from "ai";
|
||||
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
|
||||
import type { ChatUiMessage } from "@/lib/chat-tools-schemas";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Chat } from "@/components/chat";
|
||||
import { ChatSidebar } from "@/components/chat-sidebar";
|
||||
import { DEFAULT_MODEL } from "@/lib/models";
|
||||
import {
|
||||
mintChatAccessToken,
|
||||
startChatSession,
|
||||
getChatList,
|
||||
getChatMessages,
|
||||
deleteChat as deleteChatAction,
|
||||
deleteAllChats,
|
||||
updateChatTitle,
|
||||
deleteSessionAction,
|
||||
} from "@/app/actions";
|
||||
|
||||
type ChatMeta = {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
type SessionInfo = {
|
||||
publicAccessToken: string;
|
||||
lastEventId?: string;
|
||||
};
|
||||
|
||||
type ChatAppProps = {
|
||||
taskMode: string;
|
||||
onTaskModeChange: (mode: string) => void;
|
||||
initialChatList: ChatMeta[];
|
||||
initialActiveChatId: string | null;
|
||||
initialMessages: ChatUiMessage[];
|
||||
initialSessions: Record<string, SessionInfo>;
|
||||
};
|
||||
|
||||
export function ChatApp({
|
||||
taskMode,
|
||||
onTaskModeChange,
|
||||
initialChatList,
|
||||
initialActiveChatId,
|
||||
initialMessages,
|
||||
initialSessions,
|
||||
}: ChatAppProps) {
|
||||
const [chatList, setChatList] = useState<ChatMeta[]>(initialChatList);
|
||||
const [activeChatId, setActiveChatId] = useState<string | null>(initialActiveChatId);
|
||||
const [messages, setMessages] = useState<ChatUiMessage[]>(initialMessages);
|
||||
const [sessions, setSessions] = useState<Record<string, SessionInfo>>(initialSessions);
|
||||
|
||||
// Model for new chats (before first message is sent)
|
||||
const [newChatModel, setNewChatModel] = useState(DEFAULT_MODEL);
|
||||
const [idleTimeoutInSeconds, setIdleTimeoutInSeconds] = useState(60);
|
||||
|
||||
const handleSessionChange = useCallback((chatId: string, session: SessionInfo | null) => {
|
||||
if (session) {
|
||||
setSessions((prev) => ({ ...prev, [chatId]: session }));
|
||||
} else {
|
||||
setSessions((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[chatId];
|
||||
return next;
|
||||
});
|
||||
deleteSessionAction(chatId);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const transport = useTriggerChatTransport({
|
||||
task: taskMode,
|
||||
// Pure mint — server action calls `auth.createPublicToken({ scopes:
|
||||
// { sessions: chatId } })`. Fired on 401/403 refresh.
|
||||
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
|
||||
// Session create — server action wraps `chat.createStartSessionAction`.
|
||||
// Transport invokes it on `preload(chatId)` and lazily on first
|
||||
// `sendMessage` for any chatId without a cached PAT. `clientData`
|
||||
// is threaded through to `triggerConfig.basePayload.metadata` so
|
||||
// the first run sees the same shape as per-turn `metadata`.
|
||||
startSession: ({ chatId, taskId, clientData }) =>
|
||||
startChatSession({ chatId, taskId, clientData }),
|
||||
baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL,
|
||||
sessions: initialSessions,
|
||||
onSessionChange: handleSessionChange,
|
||||
clientData: { userId: "user_123" },
|
||||
});
|
||||
|
||||
// Load messages when active chat changes
|
||||
useEffect(() => {
|
||||
if (!activeChatId) {
|
||||
setMessages([]);
|
||||
return;
|
||||
}
|
||||
// Don't reload if we already have the initial messages for the initial chat
|
||||
if (activeChatId === initialActiveChatId && messages === initialMessages) {
|
||||
return;
|
||||
}
|
||||
getChatMessages(activeChatId).then(setMessages);
|
||||
}, [activeChatId]);
|
||||
|
||||
function handleNewChat() {
|
||||
const id = generateId();
|
||||
setActiveChatId(id);
|
||||
setMessages([]);
|
||||
setNewChatModel(DEFAULT_MODEL);
|
||||
void idleTimeoutInSeconds;
|
||||
}
|
||||
|
||||
function handleSelectChat(id: string) {
|
||||
setActiveChatId(id);
|
||||
}
|
||||
|
||||
async function handleDeleteChat(id: string) {
|
||||
await deleteChatAction(id);
|
||||
const list = await getChatList();
|
||||
setChatList(list);
|
||||
if (activeChatId === id) {
|
||||
if (list.length > 0) {
|
||||
setActiveChatId(list[0]!.id);
|
||||
} else {
|
||||
setActiveChatId(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWipeAll() {
|
||||
await deleteAllChats();
|
||||
setChatList([]);
|
||||
setActiveChatId(null);
|
||||
setMessages([]);
|
||||
setSessions({});
|
||||
}
|
||||
|
||||
const handleFirstMessage = useCallback(async (chatId: string, text: string) => {
|
||||
const title = text.slice(0, 40).trim() || "New chat";
|
||||
await updateChatTitle(chatId, title);
|
||||
const list = await getChatList();
|
||||
setChatList(list);
|
||||
}, []);
|
||||
|
||||
const handleMessagesChange = useCallback(async (_chatId: string, _messages: ChatUiMessage[]) => {
|
||||
// Messages are persisted server-side via onTurnComplete.
|
||||
// Refresh the chat list to update timestamps.
|
||||
const list = await getChatList();
|
||||
setChatList(list);
|
||||
}, []);
|
||||
|
||||
// Determine the model for the active chat
|
||||
const activeChatMeta = chatList.find((c) => c.id === activeChatId);
|
||||
const isNewChat = activeChatId != null && !activeChatMeta;
|
||||
const activeModel = isNewChat ? newChatModel : activeChatMeta?.model ?? DEFAULT_MODEL;
|
||||
|
||||
// Get session for the active chat
|
||||
const activeSession = activeChatId ? sessions[activeChatId] : undefined;
|
||||
|
||||
return (
|
||||
<main className="flex h-screen">
|
||||
<ChatSidebar
|
||||
chats={chatList}
|
||||
activeChatId={activeChatId}
|
||||
onSelectChat={handleSelectChat}
|
||||
onNewChat={handleNewChat}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
onWipeAll={handleWipeAll}
|
||||
idleTimeoutInSeconds={idleTimeoutInSeconds}
|
||||
onIdleTimeoutChange={setIdleTimeoutInSeconds}
|
||||
taskMode={taskMode}
|
||||
onTaskModeChange={onTaskModeChange}
|
||||
useHandover={false}
|
||||
onUseHandoverChange={() => {}}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
{activeChatId ? (
|
||||
<Chat
|
||||
key={activeChatId}
|
||||
chatId={activeChatId}
|
||||
initialMessages={messages}
|
||||
transport={transport}
|
||||
resume={messages.length > 0}
|
||||
model={activeModel}
|
||||
isNewChat={isNewChat}
|
||||
onModelChange={isNewChat ? setNewChatModel : undefined}
|
||||
session={activeSession}
|
||||
dashboardUrl={process.env.NEXT_PUBLIC_TRIGGER_DASHBOARD_URL}
|
||||
projectDashboardPath={process.env.NEXT_PUBLIC_TRIGGER_PROJECT_DASHBOARD_PATH}
|
||||
onFirstMessage={handleFirstMessage}
|
||||
onMessagesChange={handleMessagesChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-gray-400">No conversation selected</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNewChat}
|
||||
className="mt-3 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
|
||||
>
|
||||
Start a new chat
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||
|
||||
type ChatSettings = {
|
||||
taskMode: string;
|
||||
setTaskMode: (mode: string) => void;
|
||||
idleTimeoutInSeconds: number;
|
||||
setIdleTimeoutInSeconds: (seconds: number) => void;
|
||||
/**
|
||||
* When true, first-turn messages are POSTed to `/api/chat`
|
||||
* (`chat.handover` route handler) instead of triggering the agent
|
||||
* directly. Subsequent turns bypass the endpoint regardless.
|
||||
*/
|
||||
useHandover: boolean;
|
||||
setUseHandover: (on: boolean) => void;
|
||||
};
|
||||
|
||||
const ChatSettingsContext = createContext<ChatSettings | null>(null);
|
||||
|
||||
export function ChatSettingsProvider({ children }: { children: ReactNode }) {
|
||||
const [taskMode, setTaskMode] = useState("ai-chat");
|
||||
const [idleTimeoutInSeconds, setIdleTimeoutInSeconds] = useState(60);
|
||||
const [useHandover, setUseHandover] = useState(false);
|
||||
|
||||
const value: ChatSettings = {
|
||||
taskMode,
|
||||
setTaskMode,
|
||||
idleTimeoutInSeconds,
|
||||
setIdleTimeoutInSeconds,
|
||||
useHandover,
|
||||
setUseHandover,
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const Provider = ChatSettingsContext.Provider as any;
|
||||
|
||||
return <Provider value={value}>{children}</Provider>;
|
||||
}
|
||||
|
||||
export function useChatSettings() {
|
||||
const ctx = useContext(ChatSettingsContext);
|
||||
if (!ctx) throw new Error("useChatSettings must be used within ChatSettingsProvider");
|
||||
return ctx;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { ChatSidebar } from "@/components/chat-sidebar";
|
||||
import { useChatSettings } from "@/components/chat-settings-context";
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { generateId } from "ai";
|
||||
import { getChatList, deleteChat as deleteChatAction, deleteAllChats } from "@/app/actions";
|
||||
|
||||
type ChatMeta = {
|
||||
id: string;
|
||||
title: string;
|
||||
model: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
export function ChatSidebarWrapper({
|
||||
initialChatList,
|
||||
}: {
|
||||
initialChatList: ChatMeta[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [chatList, setChatList] = useState(initialChatList);
|
||||
const {
|
||||
taskMode,
|
||||
setTaskMode,
|
||||
idleTimeoutInSeconds,
|
||||
setIdleTimeoutInSeconds,
|
||||
useHandover,
|
||||
setUseHandover,
|
||||
} = useChatSettings();
|
||||
|
||||
// Extract active chatId from URL
|
||||
const activeChatId =
|
||||
pathname?.startsWith("/chats/") ? (pathname.split("/chats/")[1]?.split("/")[0] ?? null) : null;
|
||||
|
||||
const refreshChatList = useCallback(async () => {
|
||||
const list = await getChatList();
|
||||
setChatList(list);
|
||||
}, []);
|
||||
|
||||
// Refresh chat list on navigation
|
||||
useEffect(() => {
|
||||
refreshChatList();
|
||||
}, [pathname, refreshChatList]);
|
||||
|
||||
function handleSelectChat(id: string) {
|
||||
router.push(`/chats/${id}`);
|
||||
}
|
||||
|
||||
function handleNewChat() {
|
||||
const id = generateId();
|
||||
router.push(`/chats/${id}`);
|
||||
}
|
||||
|
||||
async function handleDeleteChat(id: string) {
|
||||
await deleteChatAction(id);
|
||||
const list = await getChatList();
|
||||
setChatList(list);
|
||||
if (activeChatId === id) {
|
||||
if (list.length > 0) {
|
||||
router.push(`/chats/${list[0]!.id}`);
|
||||
} else {
|
||||
router.push("/chats");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWipeAll() {
|
||||
if (!confirm("Delete ALL chats? This cannot be undone.")) return;
|
||||
await deleteAllChats();
|
||||
setChatList([]);
|
||||
router.push("/chats");
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatSidebar
|
||||
chats={chatList}
|
||||
activeChatId={activeChatId}
|
||||
onSelectChat={handleSelectChat}
|
||||
onNewChat={handleNewChat}
|
||||
onDeleteChat={handleDeleteChat}
|
||||
onWipeAll={handleWipeAll}
|
||||
idleTimeoutInSeconds={idleTimeoutInSeconds}
|
||||
onIdleTimeoutChange={setIdleTimeoutInSeconds}
|
||||
taskMode={taskMode}
|
||||
onTaskModeChange={setTaskMode}
|
||||
useHandover={useHandover}
|
||||
onUseHandoverChange={setUseHandover}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
"use client";
|
||||
|
||||
type ChatMeta = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
function timeAgo(ts: number): string {
|
||||
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||||
if (seconds < 60) return "just now";
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
type ChatSidebarProps = {
|
||||
chats: ChatMeta[];
|
||||
activeChatId: string | null;
|
||||
onSelectChat: (id: string) => void;
|
||||
onNewChat: () => void;
|
||||
onDeleteChat: (id: string) => void;
|
||||
onWipeAll: () => void;
|
||||
idleTimeoutInSeconds: number;
|
||||
onIdleTimeoutChange: (seconds: number) => void;
|
||||
taskMode: string;
|
||||
onTaskModeChange: (mode: string) => void;
|
||||
useHandover: boolean;
|
||||
onUseHandoverChange: (on: boolean) => void;
|
||||
};
|
||||
|
||||
export function ChatSidebar({
|
||||
chats,
|
||||
activeChatId,
|
||||
onSelectChat,
|
||||
onNewChat,
|
||||
onDeleteChat,
|
||||
onWipeAll,
|
||||
idleTimeoutInSeconds,
|
||||
onIdleTimeoutChange,
|
||||
taskMode,
|
||||
onTaskModeChange,
|
||||
useHandover,
|
||||
onUseHandoverChange,
|
||||
}: ChatSidebarProps) {
|
||||
const sorted = [...chats].sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-64 shrink-0 flex-col border-r border-gray-200 bg-gray-50">
|
||||
<div className="p-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
+ New Chat
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{sorted.length === 0 && (
|
||||
<p className="px-3 py-8 text-center text-xs text-gray-400">No conversations yet</p>
|
||||
)}
|
||||
|
||||
{sorted.map((chat) => (
|
||||
<button
|
||||
key={chat.id}
|
||||
type="button"
|
||||
onClick={() => onSelectChat(chat.id)}
|
||||
className={`group flex w-full items-start gap-2 px-3 py-2.5 text-left text-sm hover:bg-gray-100 ${
|
||||
activeChatId === chat.id ? "bg-white" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-medium text-gray-800">{chat.title}</div>
|
||||
<div className="text-[10px] text-gray-400">{timeAgo(chat.updatedAt)}</div>
|
||||
</div>
|
||||
<span
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteChat(chat.id);
|
||||
}}
|
||||
className="mt-0.5 hidden shrink-0 rounded p-0.5 text-xs text-gray-400 hover:bg-red-100 hover:text-red-600 group-hover:inline-block"
|
||||
>
|
||||
×
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 border-t border-gray-200 px-3 py-2.5 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="shrink-0">Idle timeout</span>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step={5}
|
||||
value={idleTimeoutInSeconds}
|
||||
onChange={(e) => onIdleTimeoutChange(Number(e.target.value))}
|
||||
className="w-16 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-600 outline-none focus:border-blue-500"
|
||||
/>
|
||||
<span>s</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500">
|
||||
<span className="shrink-0">Task</span>
|
||||
<select
|
||||
value={taskMode}
|
||||
onChange={(e) => onTaskModeChange(e.target.value)}
|
||||
className="flex-1 rounded border border-gray-300 px-1.5 py-0.5 text-xs text-gray-600 outline-none focus:border-blue-500"
|
||||
>
|
||||
<option value="ai-chat">ai-chat (chat.agent)</option>
|
||||
<option value="ai-chat-hydrated">ai-chat-hydrated (hydrated)</option>
|
||||
<option value="ai-chat-raw">ai-chat-raw (raw task)</option>
|
||||
<option value="ai-chat-session">ai-chat-session (session)</option>
|
||||
<option value="upgrade-test">upgrade-test (requestUpgrade after 3 turns)</option>
|
||||
<option value="stress-emit">stress-emit (UI stress test)</option>
|
||||
</select>
|
||||
</div>
|
||||
<label
|
||||
className="flex items-center gap-2 text-xs text-gray-500"
|
||||
title="Route first-turn messages through /api/chat (chat.handover) so step 1 streams from the Next.js process while the agent run boots in parallel."
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={useHandover}
|
||||
onChange={(e) => onUseHandoverChange(e.target.checked)}
|
||||
className="h-3 w-3 rounded border-gray-300"
|
||||
/>
|
||||
<span>Use handover (1st turn)</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onWipeAll}
|
||||
className="w-full rounded border border-red-300 px-2 py-1 text-xs text-red-600 hover:bg-red-50"
|
||||
>
|
||||
Wipe all chats
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
|
||||
import type { ChatUiMessage } from "@/lib/chat-tools-schemas";
|
||||
import { Chat } from "@/components/chat";
|
||||
import { useChatSettings } from "@/components/chat-settings-context";
|
||||
import {
|
||||
mintChatAccessToken,
|
||||
startChatSession,
|
||||
updateChatTitle,
|
||||
deleteSessionAction,
|
||||
} from "@/app/actions";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type SessionInfo = {
|
||||
publicAccessToken: string;
|
||||
lastEventId?: string;
|
||||
};
|
||||
|
||||
type ChatViewProps = {
|
||||
chatId: string;
|
||||
initialMessages: ChatUiMessage[];
|
||||
initialSession: SessionInfo | null;
|
||||
isNewChat: boolean;
|
||||
model: string;
|
||||
};
|
||||
|
||||
export function ChatView({
|
||||
chatId,
|
||||
initialMessages,
|
||||
initialSession,
|
||||
isNewChat,
|
||||
model,
|
||||
}: ChatViewProps) {
|
||||
const router = useRouter();
|
||||
const { taskMode, useHandover } = useChatSettings();
|
||||
|
||||
const [currentSession, setCurrentSession] = useState<SessionInfo | null>(initialSession);
|
||||
|
||||
const handleSessionChange = useCallback((id: string, session: SessionInfo | null) => {
|
||||
if (session) {
|
||||
setCurrentSession(session);
|
||||
} else {
|
||||
setCurrentSession(null);
|
||||
deleteSessionAction(id);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const transport = useTriggerChatTransport({
|
||||
task: taskMode,
|
||||
// Pure mint — server action calls `auth.createPublicToken({ scopes:
|
||||
// { sessions: chatId } })` and returns the JWT. Fired on 401/403 to
|
||||
// refresh the session PAT. Never creates a session.
|
||||
accessToken: ({ chatId }) => mintChatAccessToken(chatId),
|
||||
// Session create — server action wraps `chat.createStartSessionAction`
|
||||
// (secret-key auth, server-side authorization). Idempotent on
|
||||
// `(env, externalId)`. Transport invokes it on `preload(chatId)`
|
||||
// and lazily on first `sendMessage` for any chatId without a
|
||||
// cached PAT. `clientData` is the transport's typed `clientData`
|
||||
// option, threaded through so the first run's `payload.metadata`
|
||||
// matches per-turn `metadata`.
|
||||
startSession: ({ chatId, taskId, clientData }) =>
|
||||
startChatSession({ chatId, taskId, clientData }),
|
||||
baseURL: process.env.NEXT_PUBLIC_TRIGGER_API_URL,
|
||||
sessions: initialSession ? { [chatId]: initialSession } : {},
|
||||
onSessionChange: handleSessionChange,
|
||||
clientData: { userId: "user_123" },
|
||||
multiTab: true,
|
||||
// Head-start URL: opt-in fast-path for the first message of a
|
||||
// brand-new chat. The transport POSTs to `/api/chat` (which
|
||||
// exports `chat.handover({ agentId, run })`) so step 1's LLM
|
||||
// call runs in the warm Next.js process while the trigger agent
|
||||
// run boots in parallel. After turn 1 the transport hydrates
|
||||
// session state from response headers and writes directly to
|
||||
// `session.in` for turn 2 onward — same direct-trigger path as
|
||||
// when `headStart` is unset.
|
||||
headStart: useHandover ? "/api/chat" : undefined,
|
||||
});
|
||||
|
||||
const handleFirstMessage = useCallback(
|
||||
async (cId: string, text: string) => {
|
||||
const title = text.slice(0, 40).trim() || "New chat";
|
||||
await updateChatTitle(cId, title);
|
||||
router.refresh();
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleMessagesChange = useCallback(
|
||||
async (_cId: string, _msgs: ChatUiMessage[]) => {
|
||||
router.refresh();
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const activeSession = currentSession ?? undefined;
|
||||
|
||||
return (
|
||||
<Chat
|
||||
key={chatId}
|
||||
chatId={chatId}
|
||||
initialMessages={initialMessages}
|
||||
transport={transport}
|
||||
resume={initialMessages.length > 0 || !!initialSession}
|
||||
model={model}
|
||||
isNewChat={isNewChat}
|
||||
session={activeSession}
|
||||
dashboardUrl={process.env.NEXT_PUBLIC_TRIGGER_DASHBOARD_URL}
|
||||
projectDashboardPath={process.env.NEXT_PUBLIC_TRIGGER_PROJECT_DASHBOARD_PATH}
|
||||
onFirstMessage={handleFirstMessage}
|
||||
onMessagesChange={handleMessagesChange}
|
||||
handoverEnabled={useHandover}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,995 @@
|
||||
"use client";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import {
|
||||
lastAssistantMessageIsCompleteWithApprovalResponses,
|
||||
lastAssistantMessageIsCompleteWithToolCalls,
|
||||
} from "ai";
|
||||
import type { ChatUiMessage } from "@/lib/chat-tools-schemas";
|
||||
import type { TriggerChatTransport } from "@trigger.dev/sdk/chat";
|
||||
|
||||
// Structural type mirroring @trigger.dev/sdk/ai's CompactionChunkData.
|
||||
// Importing from the `ai` subpath drags the full chat.agent module —
|
||||
// including skills' `node:child_process` import — into the client
|
||||
// bundle. Keeping this inline is a type-only dependency.
|
||||
type CompactionChunkData = {
|
||||
status: "compacting" | "compacted";
|
||||
totalTokens?: number;
|
||||
};
|
||||
import { usePendingMessages, useMultiTabChat } from "@trigger.dev/sdk/chat/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { MODEL_OPTIONS } from "@/lib/models";
|
||||
|
||||
function ToolInvocation({
|
||||
part,
|
||||
onApprove,
|
||||
onDeny,
|
||||
onToolOutput,
|
||||
}: {
|
||||
part: any;
|
||||
onApprove?: (approvalId: string) => void;
|
||||
onDeny?: (approvalId: string) => void;
|
||||
onToolOutput?: (tool: string, toolCallId: string, output: unknown) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const toolName = part.type.startsWith("tool-") ? part.type.slice(5) : "tool";
|
||||
const state = part.state ?? "input-available";
|
||||
const args = part.input;
|
||||
const result = part.output;
|
||||
|
||||
const isLoading = state === "input-streaming" || state === "input-available";
|
||||
const isError = state === "output-error";
|
||||
const needsApproval = state === "approval-requested";
|
||||
const wasApproved = state === "approval-responded" && part.approval?.approved === true;
|
||||
const wasDenied = state === "approval-responded" && part.approval?.approved === false;
|
||||
|
||||
return (
|
||||
<div className="my-1 rounded border border-gray-200 bg-gray-50 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left font-medium text-gray-700 hover:bg-gray-100"
|
||||
>
|
||||
{isLoading && (
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600" />
|
||||
)}
|
||||
{needsApproval && <span className="text-amber-500">⚠</span>}
|
||||
{wasApproved && <span className="text-green-600">✓</span>}
|
||||
{wasDenied && <span className="text-red-600">✗</span>}
|
||||
{!isLoading && !needsApproval && !wasApproved && !wasDenied && !isError && (
|
||||
<span className="text-green-600">✓</span>
|
||||
)}
|
||||
{isError && <span className="text-red-600">✗</span>}
|
||||
<span>{toolName}</span>
|
||||
{needsApproval && <span className="text-amber-500 text-[10px]">needs approval</span>}
|
||||
<span className="ml-auto text-gray-400">{expanded ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
|
||||
{needsApproval && (
|
||||
<div className="flex gap-2 border-t border-gray-200 px-3 py-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onApprove?.(part.approval.id)}
|
||||
className="rounded bg-green-600 px-3 py-1 text-xs font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDeny?.(part.approval.id)}
|
||||
className="rounded bg-red-600 px-3 py-1 text-xs font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* askUser tool: show question + option buttons when input-available */}
|
||||
{toolName === "askUser" && state === "input-available" && args?.question && (
|
||||
<div className="border-t border-gray-200 px-3 py-2 space-y-2">
|
||||
<div className="font-medium text-gray-700">{args.question}</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(args.options ?? []).map((opt: any) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onToolOutput?.(toolName, part.toolCallId, {
|
||||
skipped: false,
|
||||
answers: [{ questionId: args.question, optionId: opt.id, text: opt.label }],
|
||||
})
|
||||
}
|
||||
className="rounded border border-blue-300 bg-blue-50 px-3 py-1.5 text-xs text-blue-700 hover:bg-blue-100"
|
||||
title={opt.description}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-gray-200 px-3 py-2 space-y-2">
|
||||
{args && Object.keys(args).length > 0 && (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-gray-500">Input</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-white p-2 text-gray-800">
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{state === "output-available" && result !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-gray-500">Output</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-white p-2 text-gray-800">
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{isError && result !== undefined && (
|
||||
<div>
|
||||
<div className="mb-1 font-medium text-red-500">Error</div>
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap rounded bg-red-50 p-2 text-red-700">
|
||||
{typeof result === "string" ? result : JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ResearchProgress({ part }: { part: any }) {
|
||||
const data = part.data as {
|
||||
status: "fetching" | "done";
|
||||
query: string;
|
||||
current: number;
|
||||
total: number;
|
||||
currentUrl?: string;
|
||||
completedUrls: string[];
|
||||
};
|
||||
|
||||
const isDone = data.status === "done";
|
||||
|
||||
return (
|
||||
<div className="my-2 rounded border border-blue-200 bg-blue-50 px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-2 font-medium text-blue-700">
|
||||
{isDone ? (
|
||||
<span className="text-green-600">✓</span>
|
||||
) : (
|
||||
<span className="inline-block h-3 w-3 animate-spin rounded-full border-2 border-blue-300 border-t-blue-600" />
|
||||
)}
|
||||
<span>
|
||||
{isDone
|
||||
? `Research complete — ${data.total} sources fetched`
|
||||
: `Researching "${data.query}" (${data.current}/${data.total})`}
|
||||
</span>
|
||||
</div>
|
||||
{data.currentUrl && !isDone && (
|
||||
<div className="mt-1 truncate text-blue-500">Fetching {data.currentUrl}</div>
|
||||
)}
|
||||
{data.completedUrls.length > 0 && (
|
||||
<div className="mt-1 space-y-0.5 text-blue-400">
|
||||
{data.completedUrls.map((url, i) => (
|
||||
<div key={i} className="truncate">
|
||||
✓ {url}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type TtfbEntry = { turn: number; ttfbMs: number };
|
||||
|
||||
function DebugPanel({
|
||||
chatId,
|
||||
model,
|
||||
status,
|
||||
session,
|
||||
dashboardUrl,
|
||||
projectDashboardPath,
|
||||
messageCount,
|
||||
ttfbHistory,
|
||||
}: {
|
||||
chatId: string;
|
||||
model: string;
|
||||
status: string;
|
||||
session?: { publicAccessToken: string; lastEventId?: string; isStreaming?: boolean };
|
||||
dashboardUrl?: string;
|
||||
projectDashboardPath?: string;
|
||||
messageCount: number;
|
||||
ttfbHistory: TtfbEntry[];
|
||||
}) {
|
||||
const runsUrl =
|
||||
dashboardUrl && projectDashboardPath
|
||||
? `${dashboardUrl}${projectDashboardPath}/env/dev/runs?tags=${encodeURIComponent(`chat:${chatId}`)}`
|
||||
: undefined;
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const latestTtfb = ttfbHistory.length > 0 ? ttfbHistory[ttfbHistory.length - 1]! : undefined;
|
||||
const avgTtfb =
|
||||
ttfbHistory.length > 0
|
||||
? Math.round(ttfbHistory.reduce((sum, e) => sum + e.ttfbMs, 0) / ttfbHistory.length)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className="shrink-0 border-t border-gray-200 bg-gray-50 text-xs text-gray-500">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="flex w-full items-center gap-2 px-4 py-1.5 hover:bg-gray-100"
|
||||
>
|
||||
<span className="font-medium">Debug</span>
|
||||
<span
|
||||
className={`inline-block h-1.5 w-1.5 rounded-full ${
|
||||
status === "streaming"
|
||||
? "bg-green-500"
|
||||
: session?.isStreaming
|
||||
? "bg-yellow-500"
|
||||
: "bg-gray-300"
|
||||
}`}
|
||||
/>
|
||||
<span>{status}</span>
|
||||
{latestTtfb && (
|
||||
<span className="font-mono text-blue-600">
|
||||
TTFB {latestTtfb.ttfbMs.toLocaleString()}ms
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto text-gray-400">{open ? "▲" : "▼"}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-gray-200 px-4 py-2 space-y-1">
|
||||
<Row label="Chat ID" value={chatId} mono />
|
||||
<Row label="Model" value={model} />
|
||||
<Row label="Status" value={status} />
|
||||
<Row label="Messages" value={String(messageCount)} />
|
||||
{runsUrl && <Row label="Runs" value="View in dashboard" link={runsUrl} />}
|
||||
{session ? (
|
||||
<>
|
||||
<Row label="Last Event ID" value={session.lastEventId ?? "—"} mono />
|
||||
<Row label="Streaming" value={session.isStreaming ? "yes" : "no"} />
|
||||
</>
|
||||
) : (
|
||||
<Row label="Session" value="none" />
|
||||
)}
|
||||
{ttfbHistory.length > 0 && (
|
||||
<>
|
||||
<div className="mt-2 border-t border-gray-200 pt-2">
|
||||
<span className="font-medium text-gray-600">TTFB</span>
|
||||
{avgTtfb !== undefined && (
|
||||
<span className="ml-2 text-gray-400">avg {avgTtfb.toLocaleString()}ms</span>
|
||||
)}
|
||||
</div>
|
||||
{ttfbHistory.map((entry) => (
|
||||
<div key={entry.turn} className="flex items-center gap-2">
|
||||
<span className="w-24 shrink-0 text-gray-400">Turn {entry.turn}</span>
|
||||
<span className="font-mono">{entry.ttfbMs.toLocaleString()}ms</span>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
link,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
link?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-24 shrink-0 text-gray-400">{label}</span>
|
||||
{link ? (
|
||||
<a
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`truncate text-blue-600 underline ${mono ? "font-mono" : ""}`}
|
||||
>
|
||||
{value}
|
||||
</a>
|
||||
) : (
|
||||
<span className={`truncate ${mono ? "font-mono" : ""}`}>{value}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ChatProps = {
|
||||
chatId: string;
|
||||
initialMessages: ChatUiMessage[];
|
||||
transport: TriggerChatTransport;
|
||||
resume?: boolean;
|
||||
model: string;
|
||||
isNewChat: boolean;
|
||||
onModelChange?: (model: string) => void;
|
||||
session?: { publicAccessToken: string; lastEventId?: string; isStreaming?: boolean };
|
||||
dashboardUrl?: string;
|
||||
projectDashboardPath?: string;
|
||||
onFirstMessage?: (chatId: string, text: string) => void;
|
||||
onMessagesChange?: (chatId: string, messages: ChatUiMessage[]) => void;
|
||||
/** Whether the transport is configured to route first-turn through `chat.handover`. */
|
||||
handoverEnabled?: boolean;
|
||||
};
|
||||
|
||||
export function Chat({
|
||||
chatId,
|
||||
initialMessages,
|
||||
transport,
|
||||
resume: resumeProp,
|
||||
model,
|
||||
isNewChat,
|
||||
onModelChange,
|
||||
session,
|
||||
dashboardUrl,
|
||||
projectDashboardPath,
|
||||
onFirstMessage,
|
||||
onMessagesChange,
|
||||
handoverEnabled = false,
|
||||
}: ChatProps) {
|
||||
const [input, setInput] = useState("");
|
||||
const hasCalledFirstMessage = useRef(false);
|
||||
|
||||
// TTFB tracking
|
||||
const sendTimestamp = useRef<number | null>(null);
|
||||
const turnCounter = useRef(0);
|
||||
const [ttfbHistory, setTtfbHistory] = useState<TtfbEntry[]>([]);
|
||||
|
||||
const {
|
||||
messages,
|
||||
setMessages,
|
||||
sendMessage,
|
||||
stop: aiStop,
|
||||
addToolApprovalResponse,
|
||||
addToolOutput,
|
||||
regenerate,
|
||||
status,
|
||||
error,
|
||||
} = useChat({
|
||||
id: chatId,
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
resume: resumeProp,
|
||||
sendAutomaticallyWhen: (opts) =>
|
||||
lastAssistantMessageIsCompleteWithApprovalResponses(opts) ||
|
||||
lastAssistantMessageIsCompleteWithToolCalls(opts),
|
||||
});
|
||||
|
||||
// Multi-tab coordination: sync messages between tabs
|
||||
const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages);
|
||||
|
||||
// Use transport.stopGeneration for reliable stop after reconnect.
|
||||
// Once the AI SDK passes abortSignal through reconnectToStream,
|
||||
// aiStop() alone will suffice. Until then, this covers both cases.
|
||||
const stop = useCallback(() => {
|
||||
transport.stopGeneration(chatId);
|
||||
aiStop();
|
||||
}, [transport, chatId, aiStop]);
|
||||
|
||||
// Tool approval callbacks
|
||||
const handleApprove = useCallback(
|
||||
(approvalId: string) => {
|
||||
addToolApprovalResponse({ id: approvalId, approved: true });
|
||||
},
|
||||
[addToolApprovalResponse, chatId, messages, status]
|
||||
);
|
||||
|
||||
const handleDeny = useCallback(
|
||||
(approvalId: string) => {
|
||||
addToolApprovalResponse({ id: approvalId, approved: false, reason: "User denied" });
|
||||
},
|
||||
[addToolApprovalResponse, chatId]
|
||||
);
|
||||
|
||||
// Notify parent of first user message (for chat metadata creation)
|
||||
useEffect(() => {
|
||||
if (hasCalledFirstMessage.current) return;
|
||||
const firstUser = messages.find((m) => m.role === "user");
|
||||
if (firstUser) {
|
||||
hasCalledFirstMessage.current = true;
|
||||
const text = firstUser.parts
|
||||
.filter((p: any) => p.type === "text")
|
||||
.map((p: any) => p.text)
|
||||
.join(" ");
|
||||
onFirstMessage?.(chatId, text);
|
||||
}
|
||||
}, [messages, chatId, onFirstMessage]);
|
||||
|
||||
// TTFB detection: record when first assistant content appears after send
|
||||
useEffect(() => {
|
||||
if (status !== "streaming") return;
|
||||
if (sendTimestamp.current === null) return;
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
if (lastMsg?.role === "assistant") {
|
||||
const ttfbMs = Date.now() - sendTimestamp.current;
|
||||
const turn = turnCounter.current;
|
||||
sendTimestamp.current = null;
|
||||
setTtfbHistory((prev) => [...prev, { turn, ttfbMs }]);
|
||||
}
|
||||
}, [status, messages]);
|
||||
|
||||
// Pending messages — handles steering messages during streaming
|
||||
const pending = usePendingMessages<ChatUiMessage>({
|
||||
transport,
|
||||
chatId,
|
||||
status,
|
||||
messages,
|
||||
setMessages,
|
||||
sendMessage,
|
||||
metadata: { model },
|
||||
});
|
||||
|
||||
// Expose test helpers for automated testing via Chrome DevTools.
|
||||
// All actions go through refs so closures always call the latest version.
|
||||
const stateRef = useRef({ status, messages, pending: pending.pending, error });
|
||||
stateRef.current = { status, messages, pending: pending.pending, error };
|
||||
|
||||
// Diagnostic: when the AI SDK transitions into an error state, log the
|
||||
// root cause once. Useful for catching transient mid-flow errors that
|
||||
// surface as `status: "error"` but leave no obvious clue otherwise.
|
||||
const prevErrorRef = useRef<unknown>(null);
|
||||
useEffect(() => {
|
||||
if (error && error !== prevErrorRef.current) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("[chat.error]", {
|
||||
message: error.message,
|
||||
name: error.name,
|
||||
stack: error.stack?.split("\n").slice(0, 6).join("\n"),
|
||||
chatId,
|
||||
status,
|
||||
msgCount: messages.length,
|
||||
lastEventId: transport.getSession(chatId)?.lastEventId ?? null,
|
||||
});
|
||||
}
|
||||
prevErrorRef.current = error;
|
||||
}, [error, chatId, status, messages.length, transport]);
|
||||
|
||||
const actionsRef = useRef({
|
||||
steer: pending.steer,
|
||||
queue: pending.queue,
|
||||
promote: pending.promoteToSteering,
|
||||
send: (text: string) => {
|
||||
turnCounter.current++;
|
||||
sendTimestamp.current = Date.now();
|
||||
sendMessage({ text }, { metadata: { model } });
|
||||
},
|
||||
stop,
|
||||
});
|
||||
actionsRef.current = {
|
||||
steer: pending.steer,
|
||||
queue: pending.queue,
|
||||
promote: pending.promoteToSteering,
|
||||
send: (text: string) => {
|
||||
turnCounter.current++;
|
||||
sendTimestamp.current = Date.now();
|
||||
sendMessage({ text }, { metadata: { model } });
|
||||
},
|
||||
stop,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// ── Test bridge ──────────────────────────────────────────────────
|
||||
//
|
||||
// Exposes `window.__chat` so an automated driver (Chrome DevTools
|
||||
// MCP, Playwright, etc.) can exercise the chat end-to-end without
|
||||
// clicking buttons. The bridge is mounted only when this component
|
||||
// is alive — unmount clears `window.__chat`, so each chat page owns
|
||||
// the namespace.
|
||||
//
|
||||
// Bridge surface groups:
|
||||
//
|
||||
// - **State accessors** (always fresh via refs): `status`, `messages`,
|
||||
// `pending`, `chatId`, plus the full `session` object (sessionId,
|
||||
// runId, lastEventId, isStreaming) and its convenience unwraps
|
||||
// `sessionId` / `runId` / `lastEventId`.
|
||||
// - **Actions**: `send`, `stop`, `steer`, `queue`, `promote`, and
|
||||
// `setMessages` so scripts can inject fixture state for
|
||||
// refresh-replay tests. `stop` calls both `transport.stopGeneration`
|
||||
// and `aiStop()` — same surface the UI's Stop button uses.
|
||||
// - **Waiters** — resolve a Promise when an async condition holds:
|
||||
// `waitForStatus(target, timeoutMs)`,
|
||||
// `waitForMessage(predicate, timeoutMs)`,
|
||||
// `waitForFirstAssistantText(timeoutMs)` (convenience — resolves
|
||||
// once any assistant message has a non-empty text part),
|
||||
// `steerOnToolCall(text)`. Default timeout 30s; rejects on timeout.
|
||||
// - **Scripted helpers** (`steerAfterDelay`, `queueAfterDelay`) for
|
||||
// fire-at-time side effects.
|
||||
//
|
||||
// Waiters poll at 50ms. That's tight enough for text-delta races
|
||||
// and light enough that the React tree doesn't feel it.
|
||||
const POLL_MS = 50;
|
||||
const DEFAULT_TIMEOUT_MS = 30_000;
|
||||
|
||||
const waitFor = <T,>(
|
||||
check: () => T | false | null | undefined,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS,
|
||||
label = "condition"
|
||||
): Promise<T> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const start = Date.now();
|
||||
const immediate = check();
|
||||
if (immediate) {
|
||||
resolve(immediate as T);
|
||||
return;
|
||||
}
|
||||
const interval = setInterval(() => {
|
||||
const got = check();
|
||||
if (got) {
|
||||
clearInterval(interval);
|
||||
resolve(got as T);
|
||||
return;
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
clearInterval(interval);
|
||||
reject(new Error(`__chat: timed out after ${timeoutMs}ms waiting for ${label}`));
|
||||
}
|
||||
}, POLL_MS);
|
||||
});
|
||||
|
||||
(window as any).__chat = {
|
||||
// ── State ─────────────────────────────────────────────────────
|
||||
get status() {
|
||||
return stateRef.current.status;
|
||||
},
|
||||
get messages() {
|
||||
return stateRef.current.messages;
|
||||
},
|
||||
get pending() {
|
||||
return stateRef.current.pending;
|
||||
},
|
||||
get session() {
|
||||
// Live session state from the transport (sessionId, runId,
|
||||
// lastEventId, isStreaming). Falls back to the SSR-supplied
|
||||
// session for runs the transport hasn't observed yet.
|
||||
return transport.getSession(chatId) ?? session ?? null;
|
||||
},
|
||||
get sessionId() {
|
||||
// Sessions-as-run-manager: sessionId is no longer surfaced
|
||||
// through the transport. The chat is addressed by `chatId` and
|
||||
// any consumer that wants the friendlyId must look it up via
|
||||
// `sessions.retrieve(chatId)` server-side.
|
||||
return null;
|
||||
},
|
||||
get runId() {
|
||||
// Sessions-as-run-manager: runs come and go inside the Session
|
||||
// and are managed server-side. The transport doesn't track the
|
||||
// live runId anymore; consumers wanting it should query
|
||||
// `sessions.retrieve(chatId)` server-side.
|
||||
return null;
|
||||
},
|
||||
get lastEventId() {
|
||||
return transport.getSession(chatId)?.lastEventId ?? null;
|
||||
},
|
||||
get error() {
|
||||
// Surface the AI SDK's last error so smoke tests can capture the
|
||||
// root cause when status flips to "error" mid-flow.
|
||||
const err = stateRef.current.error;
|
||||
if (!err) return null;
|
||||
return {
|
||||
message: (err as Error).message,
|
||||
name: (err as Error).name,
|
||||
stack: (err as Error).stack?.split("\n").slice(0, 6).join("\n"),
|
||||
};
|
||||
},
|
||||
chatId,
|
||||
/** True when the transport is configured to route first-turn through `chat.handover`. */
|
||||
handoverEnabled,
|
||||
|
||||
// ── Actions ───────────────────────────────────────────────────
|
||||
steer: (text: string) => actionsRef.current.steer(text),
|
||||
queue: (text: string) => actionsRef.current.queue(text),
|
||||
promote: (id: string) => actionsRef.current.promote(id),
|
||||
send: (text: string) => actionsRef.current.send(text),
|
||||
stop: () => actionsRef.current.stop(),
|
||||
sendAction: (action: unknown) => transport.sendAction(chatId, action),
|
||||
regenerate: () => regenerate(),
|
||||
|
||||
// ── Waiters ───────────────────────────────────────────────────
|
||||
waitForStatus: (target: string, timeoutMs = DEFAULT_TIMEOUT_MS) =>
|
||||
waitFor(
|
||||
() => (stateRef.current.status === target ? (true as const) : false),
|
||||
timeoutMs,
|
||||
`status === "${target}" (current: "${stateRef.current.status}")`
|
||||
),
|
||||
waitForMessage: <M = any,>(
|
||||
predicate: (m: any, all: any[]) => boolean,
|
||||
timeoutMs = DEFAULT_TIMEOUT_MS
|
||||
): Promise<M> =>
|
||||
waitFor(
|
||||
() => stateRef.current.messages.find((m) => predicate(m, stateRef.current.messages)) as M | undefined,
|
||||
timeoutMs,
|
||||
"matching message"
|
||||
),
|
||||
waitForFirstAssistantText: (timeoutMs = DEFAULT_TIMEOUT_MS) =>
|
||||
waitFor(
|
||||
() => {
|
||||
const assistant = stateRef.current.messages.find((m) => m.role === "assistant");
|
||||
if (!assistant) return false;
|
||||
const text = (assistant.parts ?? [])
|
||||
.filter((p: any) => p.type === "text" && typeof p.text === "string" && p.text.length > 0)
|
||||
.map((p: any) => p.text)
|
||||
.join("");
|
||||
return text.length > 0 ? { id: assistant.id, text } : false;
|
||||
},
|
||||
timeoutMs,
|
||||
"first assistant text"
|
||||
),
|
||||
steerOnToolCall: (text: string, timeoutMs = DEFAULT_TIMEOUT_MS) =>
|
||||
waitFor(
|
||||
() => {
|
||||
const lastMsg = stateRef.current.messages[stateRef.current.messages.length - 1];
|
||||
const hasTool =
|
||||
lastMsg?.role === "assistant" &&
|
||||
lastMsg.parts?.some((p: any) => p.type?.startsWith("tool-"));
|
||||
if (!hasTool) return false;
|
||||
actionsRef.current.steer(text);
|
||||
return true as const;
|
||||
},
|
||||
timeoutMs,
|
||||
"tool call"
|
||||
),
|
||||
|
||||
// ── Scripted helpers ─────────────────────────────────────────
|
||||
steerAfterDelay: (text: string, ms: number) =>
|
||||
new Promise<void>((r) =>
|
||||
setTimeout(() => {
|
||||
actionsRef.current.steer(text);
|
||||
r();
|
||||
}, ms)
|
||||
),
|
||||
queueAfterDelay: (text: string, ms: number) =>
|
||||
new Promise<void>((r) =>
|
||||
setTimeout(() => {
|
||||
actionsRef.current.queue(text);
|
||||
r();
|
||||
}, ms)
|
||||
),
|
||||
};
|
||||
return () => {
|
||||
delete (window as any).__chat;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chatId]);
|
||||
|
||||
// Persist messages when a turn completes
|
||||
const prevStatus = useRef(status);
|
||||
useEffect(() => {
|
||||
const turnCompleted = prevStatus.current === "streaming" && status === "ready";
|
||||
prevStatus.current = status;
|
||||
if (!turnCompleted) return;
|
||||
if (messages.length > 0) {
|
||||
onMessagesChange?.(chatId, messages);
|
||||
}
|
||||
}, [status, messages, chatId, onMessagesChange]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-white">
|
||||
{/* Model selector for new chats */}
|
||||
{isNewChat && messages.length === 0 && onModelChange && (
|
||||
<div className="shrink-0 border-b border-gray-200 bg-gray-50 px-4 py-2 flex items-center gap-2">
|
||||
<span className="text-xs text-gray-500">Model:</span>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => onModelChange(e.target.value)}
|
||||
className="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-600 outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
|
||||
>
|
||||
{MODEL_OPTIONS.map((m) => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model badge for existing chats */}
|
||||
{(!isNewChat || messages.length > 0) && (
|
||||
<div className="shrink-0 border-b border-gray-200 bg-gray-50 px-4 py-2">
|
||||
<span className="rounded bg-gray-200 px-1.5 py-0.5 text-[10px] font-medium text-gray-500">
|
||||
{model}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{messages.length === 0 && (
|
||||
<p className="pt-20 text-center text-sm text-gray-400">
|
||||
Send a message to start chatting.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<div className={`max-w-[80%] ${message.role === "user" ? "" : "w-full"}`}>
|
||||
<div
|
||||
className={`rounded-lg px-4 py-2 text-sm ${
|
||||
message.role === "user" ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === "text") {
|
||||
if (message.role === "assistant") {
|
||||
return <Streamdown key={i}>{part.text}</Streamdown>;
|
||||
}
|
||||
return <span key={i}>{part.text}</span>;
|
||||
}
|
||||
|
||||
if (part.type === "reasoning") {
|
||||
return (
|
||||
<details key={i} className="my-1">
|
||||
<summary className="cursor-pointer text-xs text-gray-400">
|
||||
Thinking...
|
||||
</summary>
|
||||
<div className="mt-1 rounded bg-gray-50 p-2 text-xs text-gray-500 whitespace-pre-wrap">
|
||||
{part.text}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
// Transient status parts — hide from rendered output
|
||||
if (
|
||||
part.type === "data-turn-status" ||
|
||||
part.type === "data-background-context-injected"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (part.type === "data-research-progress") {
|
||||
return <ResearchProgress key={i} part={part} />;
|
||||
}
|
||||
|
||||
if (part.type === "data-compaction") {
|
||||
const data = (part as any).data as CompactionChunkData;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`my-2 flex items-center gap-2 rounded-md border px-3 py-2 text-xs ${
|
||||
data.status === "compacting"
|
||||
? "border-blue-200 bg-blue-50 text-blue-700"
|
||||
: "border-amber-200 bg-amber-50 text-amber-700"
|
||||
}`}
|
||||
>
|
||||
<span>{data.status === "compacting" ? "⏳" : "✂️"}</span>
|
||||
<span>
|
||||
{data.status === "compacting"
|
||||
? `Compacting conversation${
|
||||
data.totalTokens
|
||||
? ` (${data.totalTokens.toLocaleString()} tokens)`
|
||||
: ""
|
||||
}...`
|
||||
: "Conversation compacted"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type.startsWith("tool-")) {
|
||||
return (
|
||||
<ToolInvocation
|
||||
key={i}
|
||||
part={part}
|
||||
onApprove={handleApprove}
|
||||
onDeny={handleDeny}
|
||||
onToolOutput={(tool, toolCallId, output) =>
|
||||
addToolOutput({ tool, toolCallId, output })
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (pending.isInjectionPoint(part)) {
|
||||
const injectedMsgs = pending.getInjectedMessages(part);
|
||||
if (injectedMsgs.length === 0) return null;
|
||||
return (
|
||||
<div key={i} className="my-2 flex justify-end">
|
||||
<div className="max-w-[60%]">
|
||||
{injectedMsgs.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="rounded-lg bg-purple-100 px-3 py-1.5 text-sm text-purple-800"
|
||||
>
|
||||
{m.text}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-0.5 text-right text-[10px] text-purple-400">
|
||||
injected mid-response
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (part.type.startsWith("data-")) {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="my-1 rounded border border-gray-200 bg-gray-50 p-2 text-xs text-gray-500"
|
||||
>
|
||||
<span className="font-medium">{part.type}</span>
|
||||
<pre className="mt-1 overflow-x-auto whitespace-pre-wrap">
|
||||
{JSON.stringify((part as any).data, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{status === "streaming" && messages[messages.length - 1]?.role !== "assistant" && (
|
||||
<div className="flex justify-start">
|
||||
<div className="rounded-lg bg-gray-100 px-4 py-2 text-sm text-gray-400">
|
||||
Thinking...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pending.pending.map((msg) => (
|
||||
<div key={msg.id} className="flex justify-end">
|
||||
<div className="max-w-[80%]">
|
||||
<div
|
||||
className={`rounded-lg px-4 py-2 text-sm text-white opacity-75 ${
|
||||
msg.mode === "steering" ? "bg-purple-600" : "bg-gray-500"
|
||||
}`}
|
||||
>
|
||||
{msg.text}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-end gap-2">
|
||||
<span className="text-[10px] text-gray-400">
|
||||
{msg.mode === "steering"
|
||||
? "Steering — waiting for injection point"
|
||||
: "Queued for next turn"}
|
||||
</span>
|
||||
{msg.mode === "queued" && status === "streaming" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => pending.promoteToSteering(msg.id)}
|
||||
className="text-[10px] text-purple-500 hover:text-purple-700 underline"
|
||||
>
|
||||
Steer instead
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="shrink-0 border-t border-red-100 bg-red-50 px-4 py-2 text-sm text-red-600">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Debug panel */}
|
||||
<DebugPanel
|
||||
chatId={chatId}
|
||||
model={model}
|
||||
status={status}
|
||||
session={session}
|
||||
dashboardUrl={dashboardUrl}
|
||||
projectDashboardPath={projectDashboardPath}
|
||||
messageCount={messages.length}
|
||||
ttfbHistory={ttfbHistory}
|
||||
/>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (!input.trim()) return;
|
||||
if (status !== "streaming") {
|
||||
turnCounter.current++;
|
||||
sendTimestamp.current = Date.now();
|
||||
}
|
||||
pending.steer(input);
|
||||
setInput("");
|
||||
}}
|
||||
className="shrink-0 border-t border-gray-200 bg-white p-4"
|
||||
>
|
||||
{isReadOnly && (
|
||||
<div className="mb-2 rounded border border-amber-200 bg-amber-50 px-3 py-1.5 text-xs text-amber-700">
|
||||
This chat is active in another tab. Messages are read-only.
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={isReadOnly ? "Chat is active in another tab" : "Type a message..."}
|
||||
disabled={isReadOnly}
|
||||
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-400"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!input.trim() || isReadOnly}
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
{/* Preload — only visible before the first message lands. After
|
||||
the user sends, the transport creates the session lazily, so
|
||||
session becomes truthy and this button hides itself. The
|
||||
transport tracks an in-flight preload internally; double-clicks
|
||||
are a no-op. */}
|
||||
{messages.length === 0 && !session && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void transport.preload(chatId);
|
||||
}}
|
||||
className="rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
Preload
|
||||
</button>
|
||||
)}
|
||||
{status === "streaming" && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={!input.trim()}
|
||||
onClick={() => {
|
||||
if (!input.trim()) return;
|
||||
pending.queue(input);
|
||||
setInput("");
|
||||
}}
|
||||
className="rounded-lg bg-gray-500 px-4 py-2 text-sm font-medium text-white hover:bg-gray-600 disabled:opacity-50"
|
||||
>
|
||||
Queue
|
||||
</button>
|
||||
)}
|
||||
{status === "streaming" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={stop}
|
||||
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
{/* Undo — server-side `chat.history.slice(0, -2)` via the
|
||||
`undo` action, optimistically reflected in the local
|
||||
`useChat` state. Drops the last user / assistant exchange.
|
||||
Only meaningful when there's at least one full exchange
|
||||
and the chat isn't currently streaming. */}
|
||||
{status !== "streaming" && messages.length >= 2 && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isReadOnly}
|
||||
onClick={() => {
|
||||
void transport.sendAction(chatId, { type: "undo" });
|
||||
setMessages((prev) => prev.slice(0, -2));
|
||||
}}
|
||||
className="rounded-lg bg-amber-500 px-4 py-2 text-sm font-medium text-white hover:bg-amber-600 disabled:opacity-50"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user