From 8e675a4e935d25457c234c55ded049542a3ec0f2 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 12 May 2026 15:05:01 +0100 Subject: [PATCH 001/238] fix(core): retry TASK_PROCESS_SIGSEGV under the user's retry policy (#3552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes [TRI-9234](https://linear.app/triggerdotdev/issue/TRI-9234/retry-task-process-sigsegv-errors-respecting-user-retry-config) ## What this changes SIGSEGV crashes (`TASK_PROCESS_SIGSEGV`) will now be **retried when an attempt fails**, in line with the task's configured retry settings (`retry.maxAttempts` etc.) — the same path SIGTERM and uncaught exceptions already use. Previously SIGSEGV was hard-classified as non-retriable and failed the run on the first segfault, ignoring the user's retry policy. Tasks without a retry policy still fail fast on the first SIGSEGV. Behaviour is unchanged for OOM kills (separate machine-bump retry path) and SIGKILL_TIMEOUT. ## Deploy **Only the webapp needs to ship.** The retry decision lives entirely in the webapp: - V2 path: `internal-packages/run-engine` (bundled into the webapp) - V1 path: `apps/webapp/app/v3/services/completeAttempt.server.ts` No supervisor, CLI, SDK, or customer-task-image changes required. Customers do not need to redeploy. The `@trigger.dev/core` changeset is just keeping the public package in sync — the published npm version isn't what makes the fix work. ## Why retry SIGSEGV in Node tasks is frequently non-deterministic across processes: - **Native addon races** (`sharp`, `canvas`, `better-sqlite3`, `node-rdkafka`, `bcrypt`, …) — libuv thread-pool work stepping on V8 handles. Different heap layout / thread schedule on a fresh process → retry often succeeds. - **JIT / GC interaction** — V8 turbofan deopt or GC during a native callback. Timing-dependent. - **Near-OOM in native code** — when RSS approaches the cgroup limit, native allocations fail and poorly-written addons dereference NULL → SIGSEGV instead of clean OOM-kill. - **Host / hardware issues** — bit flips, kernel quirks. Retry lands on a different host. The genuinely deterministic case (a user-code bug always tripping the same addon) is real, but a subset — and `maxAttempts` bounds the damage. ## Pre-existing inconsistency this resolves - `shouldRetryError` returned `false` for `TASK_PROCESS_SIGSEGV` → `fail_run`. - `shouldLookupRetrySettings` already listed `TASK_PROCESS_SIGSEGV` as retry-config-aware — but that branch was unreachable because `shouldRetryError` short-circuited first in `retrying.ts:86-90`. - We already retry `TASK_RUN_UNCAUGHT_EXCEPTION` (clearly a user-code bug) under the user's retry policy; refusing to retry SIGSEGV was the odd one out. ## Test plan - [x] `pnpm exec vitest run test/errors.test.ts` in `packages/core` — 26/26 pass (4 new) - [x] `pnpm run build --filter @trigger.dev/core` - [ ] CI green on PR 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) --- .changeset/retry-sigsegv.md | 5 +++++ packages/core/src/v3/errors.ts | 2 +- packages/core/test/errors.test.ts | 36 ++++++++++++++++++++++++++++++- 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 .changeset/retry-sigsegv.md diff --git a/.changeset/retry-sigsegv.md b/.changeset/retry-sigsegv.md new file mode 100644 index 00000000000..5a53c351efe --- /dev/null +++ b/.changeset/retry-sigsegv.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Retry `TASK_PROCESS_SIGSEGV` task crashes under the user's retry policy instead of failing the run on the first segfault. SIGSEGV in Node tasks is frequently non-deterministic (native addon races, JIT/GC interaction, near-OOM in native code, host issues), so retrying on a fresh process often succeeds. The retry is gated by the task's existing `retry` config + `maxAttempts` — same path `TASK_PROCESS_SIGTERM` and uncaught exceptions already use — so tasks without a retry policy still fail fast. diff --git a/packages/core/src/v3/errors.ts b/packages/core/src/v3/errors.ts index a538ca9357b..90650bbd18f 100644 --- a/packages/core/src/v3/errors.ts +++ b/packages/core/src/v3/errors.ts @@ -361,7 +361,6 @@ export function shouldRetryError(error: TaskRunError): boolean { case "CONFIGURED_INCORRECTLY": case "TASK_ALREADY_RUNNING": case "TASK_PROCESS_SIGKILL_TIMEOUT": - case "TASK_PROCESS_SIGSEGV": case "TASK_PROCESS_OOM_KILLED": case "TASK_PROCESS_MAYBE_OOM_KILLED": case "TASK_RUN_CANCELLED": @@ -398,6 +397,7 @@ export function shouldRetryError(error: TaskRunError): boolean { case "TASK_RUN_UNCAUGHT_EXCEPTION": case "TASK_PROCESS_EXITED_WITH_NON_ZERO_CODE": case "TASK_PROCESS_SIGTERM": + case "TASK_PROCESS_SIGSEGV": return true; default: diff --git a/packages/core/test/errors.test.ts b/packages/core/test/errors.test.ts index dee6509d3a2..9a94366d845 100644 --- a/packages/core/test/errors.test.ts +++ b/packages/core/test/errors.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from "vitest"; -import { truncateStack, truncateMessage, parseError, sanitizeError } from "../src/v3/errors.js"; +import { + truncateStack, + truncateMessage, + parseError, + sanitizeError, + shouldRetryError, + shouldLookupRetrySettings, +} from "../src/v3/errors.js"; +import type { TaskRunError } from "../src/v3/schemas/common.js"; // Helper: build a fake stack with N frames function buildStack(messageLines: string[], frameCount: number): string { @@ -238,3 +246,29 @@ describe("truncateStack message line bounding", () => { expect(result).toContain("...[truncated]"); }); }); + +describe("shouldRetryError + shouldLookupRetrySettings", () => { + const internal = (code: string): TaskRunError => + ({ type: "INTERNAL_ERROR", code } as TaskRunError); + + it("retries SIGSEGV (changed from non-retriable) and looks up retry settings", () => { + const err = internal("TASK_PROCESS_SIGSEGV"); + expect(shouldRetryError(err)).toBe(true); + expect(shouldLookupRetrySettings(err)).toBe(true); + }); + + it("retries SIGTERM via the same path", () => { + const err = internal("TASK_PROCESS_SIGTERM"); + expect(shouldRetryError(err)).toBe(true); + expect(shouldLookupRetrySettings(err)).toBe(true); + }); + + it("still does not retry SIGKILL timeout", () => { + expect(shouldRetryError(internal("TASK_PROCESS_SIGKILL_TIMEOUT"))).toBe(false); + }); + + it("still does not retry OOM kills (handled by the separate machine-bump path)", () => { + expect(shouldRetryError(internal("TASK_PROCESS_OOM_KILLED"))).toBe(false); + expect(shouldRetryError(internal("TASK_PROCESS_MAYBE_OOM_KILLED"))).toBe(false); + }); +}); From 3cbe9f2307022bcdcfe2cc6ae909f3a5dee8c610 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 12 May 2026 16:21:30 +0100 Subject: [PATCH 002/238] chore: add .claude/REVIEW.md with CI drift check (#3561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Adds `.claude/REVIEW.md` — a repo-specific source of truth for what AI / agent code reviewers should treat as critical in this codebase (rolling-deploy safety, hot-table indexes, recovery-path queries, testcontainers usage, etc.). Pairs with a Claude-based PR audit that flags drift between REVIEW.md and the code as it evolves. ## How the audit works Mirrors the existing `.github/workflows/claude-md-audit.yml` pattern. On non-draft, non-fork PRs that touch code, `anthropics/claude-code-action` reads REVIEW.md, samples the PR diff, and posts a sticky comment with up to 3 of: - `[stale]` — rule cites a path / function / table that's been removed or renamed - `[contradiction]` — code in the PR violates a current rule - `[missing]` — PR introduces a new pattern future reviewers should know about - `[obsolete]` — rule asserts a constraint the repo has moved past If nothing's off, posts `✅ REVIEW.md looks current for this PR.` ## Test plan - [ ] Convert this PR to ready-for-review, confirm the audit runs and posts a sticky comment - [ ] Verify the audit doesn't run on fork PRs (gated by `head.repo.full_name == github.repository`) - [ ] Verify suggestions are actionable on at least one follow-up PR --- .claude/REVIEW.md | 49 ++++++++++++++++ .github/workflows/check-review-md.yml | 83 +++++++++++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 .claude/REVIEW.md create mode 100644 .github/workflows/check-review-md.yml diff --git a/.claude/REVIEW.md b/.claude/REVIEW.md new file mode 100644 index 00000000000..67f7a9f15cb --- /dev/null +++ b/.claude/REVIEW.md @@ -0,0 +1,49 @@ +# REVIEW.md — Trigger.dev OSS + +Repo-specific signal for anyone (human or agent) reviewing a PR in this codebase. Calibrates what counts as critical, what to always check, and what to skip. + +## What makes a 🔴 Important finding here + +Reserve 🔴 for things that would page someone or block a rollback. In this codebase, that means: + +- **Rolling-deploy breakage.** Old and new versions of the webapp/supervisor run side-by-side during deploys. A change is broken if: + - A Lua script's behavior changes for a given key set without versioning (rename the script with a behavior-descriptive suffix like `Tracked` rather than `V2` — both versions must coexist safely). + - A Redis data shape used by both versions changes in place. New shapes need a new key namespace. + - A migration is not backward-compatible with the prior image. +- **Schema / migration safety.** Prisma migrations must be backward-compatible with the prior deploy. Adding NOT NULL without a default, dropping a column an old image still reads, renaming a column — all 🔴. +- **Queue / concurrency correctness.** RunQueue, MarQS (V1, legacy), redis-worker — any change to enqueue / dequeue / locking semantics. Re-derive the invariant on paper before flagging or accepting. +- **Missing index on a hot table.** New Prisma queries against `TaskRun`, `TaskRunExecutionSnapshot`, `JobRun`, `Project`, etc. must use an existing index. Check `internal-packages/database/prisma/schema.prisma` for the relevant `@@index` lines — don't guess and don't propose `EXPLAIN`. +- **Recovery-path queries.** Any `TaskRun.findFirst` / `findMany` added to a schedule, run-recovery, or restart loop. Recovery fan-outs (Redis crash, restart storms) turn "rare indexed query" into a DB incident. 🔴 even if indexed. +- **Aggregations on hot tables.** No `COUNT` / `GROUP BY` on `TaskRun` or other multi-million-row tables. Use Redis or ClickHouse for counts. +- **Prod Redis blast-radius.** New code paths that `SCAN` with broad patterns (`*foo*`) on prod-shaped Redis, or `EVAL` Lua with `SCAN` loops inside. Both are 🔴. +- **`@trigger.dev/core` direct import** from anywhere outside the SDK package. Always import from `@trigger.dev/sdk`. Core direct imports are 🔴 — they break the public API contract. +- **Heavy execute-deps imported into request-handler bundles.** Specifically `chat.handover` and similar split-bundle entry points must not transitively import the agent task's execute path. Watch for new imports added at module top-level of route files. +- **V1 engine code modified in a "V2 only" PR.** The `apps/webapp/app/v3/` directory contains both. If the PR description says V2-only but it touches `triggerTaskV1`, `cancelTaskRunV1`, `MarQS`, etc. — 🔴. + +## Always check + +- **Tests use testcontainers, not mocks.** Vitest with `redisTest` / `postgresTest` / `containerTest` from `@internal/testcontainers`. Any new `vi.mock(...)` on Redis, Postgres, BullMQ, or other infra is wrong here — 🔴 if added in production-path tests, 🟡 if isolated unit test. +- **Public-package changes have a changeset.** `pnpm run changeset:add` produces `.changeset/*.md`. Required for any edit under `packages/*`. Missing → 🟡; missing on a breaking change → 🔴. +- **Server-only changes have `.server-changes/*.md`.** Required for `apps/webapp/`, `apps/supervisor/` edits with no public-package change. Body should be 1-2 sentences (it has to fit as one bullet in a future changelog). Missing → 🟡. +- **Lua script naming.** Coexisting scripts use behavior-descriptive suffixes (`Tracked`), never `V2`. Old name must keep working until the next deploy clears it. +- **RunQueue payload shape.** V2 run-queue payload's `projectId` is consumed by `workerQueueResolver` for override matching. If a PR drops it from the payload, 🔴. +- **`safeSend` scope.** Defensive IPC wrappers belong on loop / interval / handler contexts, not one-shot terminal sends. If the PR adds `safeSend` to a single terminal call for consistency, 🟡 with a "remove this" suggestion. +- **Zod version.** Pinned to `3.25.76` monorepo-wide. New package adding zod with a different version or range — 🔴. + +## Skip (do NOT flag) + +- Anything Prettier / ESLint catches. CI runs both. +- TypeScript style preferences (`type` vs `interface`) — already covered by repo standards. +- Test coverage exhortations as a generic suggestion. Only flag missing tests when a specific code path is genuinely untested and the path has prior incidents. +- `agentcrumbs` markers (`// @crumbs`, `// #region @crumbs`) and `agentcrumbs` imports — these are temporary debug instrumentation stripped before merge. +- `// removed comments for removed code`, renamed `_unused` vars, re-exported types as "backwards compatibility shims" — also covered by repo standards. +- Suggestions to "add error handling" without naming a specific scenario that breaks. +- Documentation prose nitpicks in `docs/*` MDX files unless factually wrong. + +## Things V1/legacy that should NOT block a PR + +The `apps/webapp/app/v3/` directory name is misleading — most code there is V2. Only specific files are V1-only legacy: `MarQS` queue, `triggerTaskV1`, `cancelTaskRunV1`, and a handful of others (see `apps/webapp/CLAUDE.md` for the exact list). Don't flag "you should refactor this to use V2" on those — they're frozen. + +## Confidence calibration for this repo + +The most common false-positive pattern: speculating about race conditions in code paths the agent doesn't have runtime visibility into. If the only evidence is "this *could* race", drop it. If you can point to a specific interleaving with file:line for each step, surface it. diff --git a/.github/workflows/check-review-md.yml b/.github/workflows/check-review-md.yml new file mode 100644 index 00000000000..ecf44a47b27 --- /dev/null +++ b/.github/workflows/check-review-md.yml @@ -0,0 +1,83 @@ +name: 🔎 REVIEW.md Drift Audit + +on: + pull_request: + types: [opened, ready_for_review, synchronize] + paths-ignore: + - "docs/**" + - ".changeset/**" + - ".server-changes/**" + - "references/**" + +concurrency: + group: review-md-drift-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + audit: + if: >- + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + use_sticky_comment: true + allowed_bots: "devin-ai-integration[bot]" + + claude_args: | + --max-turns 15 + --allowedTools "Read,Glob,Grep,Bash(git diff:*)" + + prompt: | + You are auditing this PR for drift against `.claude/REVIEW.md`. + + ## Context + + `.claude/REVIEW.md` is the repo's source of truth for what AI / agent code reviewers should treat as critical findings (rolling-deploy safety, hot-table indexes, recovery-path queries, testcontainers usage, Lua versioning, etc.). It is consumed by review agents to calibrate severity. If REVIEW.md goes stale, every future agent review degrades. + + ## Your task + + 1. Read `.claude/REVIEW.md` in full. + 2. Run `git diff origin/main...HEAD --name-only` to see which files changed in this PR. + 3. Sample the diff itself for any of these four signals: + - **Stale references** — does any rule cite a file, directory, function, table, Prisma model, or package name that has been removed or renamed in this PR or already gone from `main`? + - **Contradictions** — does code in this PR violate a current REVIEW.md rule? (Only flag if one side is clearly wrong — do not re-review the PR.) + - **Missing rules** — does this PR introduce a new pattern future reviewers should know about? Examples: a new hot table, a new Lua-script versioning convention, a new safety wrapper, a new "must always check" invariant. + - **Obsolete rules** — has the repo moved past a constraint REVIEW.md still asserts (e.g. a deprecated path is gone, a pattern is now linted, V1 code is deleted)? + + ## Response format + + If nothing needs changing: + + ✅ REVIEW.md looks current for this PR. + + Otherwise: + + 📝 **REVIEW.md updates suggested:** + + - **[stale]** `` — + - **[contradiction]** `` — + - **[missing]** under `##
` — + - **[obsolete]** `` — + + ## Rules + + - Keep it tight. Maximum 3 suggestions per audit. Pick the highest-signal ones. + - Only flag things that would actually mislead a future reviewer. Style nits and wording preferences do not count. + - Do NOT review the PR itself. Do NOT propose rules outside REVIEW.md's existing sections. + - Do NOT propose adding rules for one-off PR specifics that don't generalize to future PRs. + - If REVIEW.md does not exist in the repo, respond with `(skip)` and stop. From e4981d1b1116510b3d4563488697abe6b01b5b65 Mon Sep 17 00:00:00 2001 From: Matt Aitken Date: Tue, 12 May 2026 17:16:20 +0100 Subject: [PATCH 003/238] feat(webapp): consolidate auth path + add comprehensive auth tests (#3499) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Consolidates the webapp's authentication and authorization into a small set of route helpers, replacing the ad-hoc `requireUser` / `requireUserId` / `authenticatedEnvironmentForAuthentication` calls scattered across routes. Same security model, but the per-request flow (authenticate → authorize → load) now lives in one place per route family. Introduces a plugin seam (`@trigger.dev/plugins`) that lets the cloud build install a richer RBAC implementation without touching webapp code. The OSS fallback keeps the pre-RBAC permissive behaviour intact, so self-hosted deployments work unchanged. Adds a comprehensive end-to-end auth test suite that didn't exist before — 193 `it()` blocks (vitest reports ~199 after `it.each` expansion) covering API key, PAT and JWT auth across the public API surface, plus dashboard session auth for admin pages. ## Changes ### Plugin contract — `@trigger.dev/plugins` `RoleBaseAccessController` interface authoritative for both OSS (fallback) and cloud (enterprise plugin): - `authenticateBearer(request, { allowJWT? })` — API-key / public-JWT auth, returns env + ability - `authenticateSession(request, { userId, organizationId?, projectId? })` — dashboard auth, caller resolves `userId` from the session cookie and passes it in (no `helpers.getSessionUserId` callback — decouples the plugin host from session-cookie code) - `authenticatePat(request, { organizationId?, projectId? })` — PAT auth, returns identity + `lastAccessedAt` so the host can throttle the per-request update - `authenticateAuthorize*` variants for the auth-and-check-in-one-call cases - `isUsingPlugin(): Promise` — capability flag for UI / branching where plugin-present-ness matters; replaces the sentinel-string coupling that had `personalAccessToken.server` matching `"RBAC plugin not installed"` literally ### Dashboard auth (started, partial rollout) Admin and settings pages migrated to a unified `dashboardLoader` / `dashboardAction` helper that authenticates the session, runs an authorization check, and exposes the result to the route. Other dashboard routes still on the old pattern; remaining migration tracked in TRI-8730. Migrated routes: - `admin.*` (14 admin / back-office / feature-flags / LLM-models / notifications / orgs / concurrency pages) - `_app.orgs.$organizationSlug.settings.team` - `_app.orgs.$organizationSlug.settings.roles` ### API / realtime / engine auth (complete for the migrated families) 71 routes migrated to a unified `apiBuilder` that centralizes Bearer / PAT / Public-JWT authentication and applies the per-route authorization check before the handler runs. Includes: - `api.v1.*` and `api.v2.*` and `api.v3.*` — tasks, runs, batches, queues, prompts, deployments, query, sessions, waitpoints, packets, workers, idempotency keys - `realtime.v1.*` — runs, batches, sessions, streams - `engine.v1.*` — dev / worker-action protocols 29 routes still on the legacy `authenticateApiRequest*` helpers — tracked as a post-deploy follow-up in TRI-9228. Multi-resource auth direction is now explicit at the call site via `anyResource(...)` (OR) and `everyResource(...)` (AND). Bare arrays no longer typecheck — fixes a class of bug where a JWT scoped to one resource could implicitly access others under OR semantics. PAT auth path consolidated: was three DB queries per request (legacy `authenticateApiRequestWithPersonalAccessToken` findFirst + `rbac.authenticatePat` join + `lastAccessedAt` update). Now one query in the steady state — plugin returns `lastAccessedAt`, host smart-skips the update via JS-side throttle when fresh. Side effect: action aliases preserved historic JWT scope semantics where the new model is stricter (e.g. a `write:tasks` JWT now also satisfies `trigger` / `batchTrigger` / `update` actions on the same resource — matched at the auth boundary, not in the route handler). ### Backwards-compat fixes The strict-match model regressed several real-world JWT shapes. Each preserved via explicit `anyResource(...)` entries in the route's authz block: - **Batch retrieve routes** (`api.v1.batches.$batchId`, `api.v2.*`, `realtime.v1.batches.*`) accept `read:runs` JWTs again (pre-RBAC literal-match superScope behaviour) - **Runs list routes** (`api.v1.runs`, `realtime.v1.runs`) accept type-level `read:tasks` / `read:tags` on unfiltered queries (matched the legacy `Object.keys` iteration semantic) - **PAT/OAT auth shape** normalized through `toAuthenticated` so all auth methods return the same slim `AuthenticatedEnvironment` (was: API-key returned the slim shape but PAT/OAT returned raw Prisma `Decimal` / no `orgMember`) - **Scope `:` preservation** in resource ids — `read:tags:env:staging` now correctly identifies the tag id as `env:staging`, not `env` ### Slim `AuthenticatedEnvironment` Extracted to `@trigger.dev/core/v3/auth/environment` — a structural shape independent of `@trigger.dev/database`. The plugin contract returns this; webapp consumers import from there; the cloud plugin (Drizzle) returns the same shape without Prisma's `Decimal` class leaking into the public surface. Lets internal-packages (run-engine, etc.) refer to `AuthenticatedEnvironment` without pulling Prisma in. ### Auth test suite (new — `*.e2e.full.test.ts`) 193 e2e tests run against a real spawned webapp + Postgres (no mocks). Coverage matrix: - **API key auth** — read / write / trigger / batchTrigger / deploy actions across runs, batches, deployments, prompts, queues, query, sessions, input-streams, waitpoints, tasks, idempotency keys; multi-key resources (a run carries batch / tag / task identifiers — auth must accept any matching scope) - **Personal Access Token auth** — comprehensive matrix: scope match, scope mismatch, missing scope, expired token, malformed token - **Public JWT auth** — sub-vs-URL environment resolution, expired JWTs, signature verification, scope checking, otu (one-time-use) token semantics, branch-environment signing-key fallback - **Dashboard session auth** — admin-only pages reject non-admins; per-action gating - **Cross-cutting edge cases** — revoked API key grace window, JWT cross-environment isolation, MissingResource branch behaviour ### Hygiene cleanups - Deleted dead `app/services/authorization.server.ts` (legacy `checkAuthorization` + types — no live consumers post-migration) and its orphaned test - Dropped the never-populated `scopes` field from `ApiAuthenticationResultSuccess` - `scheduleEmail` moved out of `email.server.ts` into its own module — breaks a `commonWorker → marqs/V1` import chain that was poisoning the auth test graph - OSS Roles page shows a deployment-aware empty state ("Roles aren't available in this self-hosted deployment" vs the plan-upsell copy) via `rbac.isUsingPlugin()` - Team action handler: explicit per-intent ability gates (`manage:billing` for purchase-seats, `manage:members` for set-role + remove-member with self-leave carve-out) ### Cross-repo coordination All public-package contract changes paired in `triggerdotdev/cloud#763` (rbac-packages branch) — the enterprise plugin implements the same `RoleBaseAccessController` interface against Drizzle. ## Test plan - [x] `pnpm run typecheck --filter webapp` clean - [x] `pnpm --filter webapp exec vitest run --config vitest.e2e.full.config.ts` — 193/193 pass (requires Docker for testcontainers) - [x] Spot-check an authed API endpoint with a valid + invalid API key against a local stack - [x] Spot-check the migrated admin pages render and gate non-admins --------- Co-authored-by: Claude Opus 4.7 (1M context) --- .changeset/plugin-auth-path.md | 5 + .github/workflows/e2e-webapp-auth-full.yml | 120 + .server-changes/plugin-auth-path.md | 6 + .../OrganizationSettingsSideMenu.tsx | 14 + .../app/components/primitives/Select.tsx | 10 +- apps/webapp/app/env.server.ts | 3 + apps/webapp/app/models/member.server.ts | 40 +- apps/webapp/app/models/project.server.ts | 2 +- .../app/models/runtimeEnvironment.server.ts | 172 +- .../app/presenters/TeamPresenter.server.ts | 33 +- .../v3/ApiRunListPresenter.server.ts | 4 +- .../v3/EnvironmentQueuePresenter.server.ts | 5 +- .../route.tsx | 183 +- .../route.tsx | 396 +++ .../route.tsx | 464 ++- .../route.tsx | 10 +- .../app/routes/account.tokens/route.tsx | 150 +- apps/webapp/app/routes/admin._index.tsx | 38 +- .../app/routes/admin.back-office._index.tsx | 16 +- .../routes/admin.back-office.orgs.$orgId.tsx | 94 +- apps/webapp/app/routes/admin.back-office.tsx | 16 +- apps/webapp/app/routes/admin.concurrency.tsx | 22 +- .../webapp/app/routes/admin.feature-flags.tsx | 96 +- .../app/routes/admin.llm-models.$modelId.tsx | 227 +- .../app/routes/admin.llm-models._index.tsx | 210 +- .../admin.llm-models.missing.$model.tsx | 45 +- .../admin.llm-models.missing._index.tsx | 34 +- .../app/routes/admin.llm-models.new.tsx | 150 +- .../webapp/app/routes/admin.notifications.tsx | 84 +- apps/webapp/app/routes/admin.orgs.tsx | 27 +- apps/webapp/app/routes/admin.tsx | 17 +- .../app/routes/api.v1.batches.$batchId.ts | 17 +- apps/webapp/app/routes/api.v1.deployments.ts | 3 +- .../api.v1.idempotencyKeys.$key.reset.ts | 3 +- .../api.v1.projects.$projectRef.runs.ts | 15 + ...pi.v1.prompts.$slug.override.reactivate.ts | 3 +- .../routes/api.v1.prompts.$slug.override.ts | 3 +- .../routes/api.v1.prompts.$slug.promote.ts | 3 +- .../webapp/app/routes/api.v1.prompts.$slug.ts | 6 +- .../routes/api.v1.prompts.$slug.versions.ts | 3 +- .../app/routes/api.v1.prompts._index.ts | 3 +- .../routes/api.v1.query.dashboards._index.ts | 3 +- apps/webapp/app/routes/api.v1.query.schema.ts | 3 +- apps/webapp/app/routes/api.v1.query.ts | 14 +- .../app/routes/api.v1.runs.$runId.events.ts | 23 +- .../api.v1.runs.$runId.spans.$spanId.ts | 23 +- .../app/routes/api.v1.runs.$runId.trace.ts | 23 +- apps/webapp/app/routes/api.v1.runs.ts | 25 +- .../routes/api.v1.sessions.$session.close.ts | 3 +- ...i.v1.sessions.$session.end-and-continue.ts | 12 +- .../app/routes/api.v1.sessions.$session.ts | 17 +- apps/webapp/app/routes/api.v1.sessions.ts | 43 +- .../routes/api.v1.tasks.$taskId.trigger.ts | 3 +- apps/webapp/app/routes/api.v1.tasks.batch.ts | 20 +- ...ens.$waitpointFriendlyId.callback.$hash.ts | 1 + ...ts.tokens.$waitpointFriendlyId.complete.ts | 3 +- .../app/routes/api.v2.batches.$batchId.ts | 11 +- .../routes/api.v2.runs.$runParam.cancel.ts | 3 +- apps/webapp/app/routes/api.v2.tasks.batch.ts | 20 +- apps/webapp/app/routes/api.v3.batches.ts | 9 +- apps/webapp/app/routes/api.v3.runs.$runId.ts | 23 +- ...hots.$snapshotFriendlyId.attempts.start.ts | 4 +- apps/webapp/app/routes/invite-resend.tsx | 2 +- .../routes/realtime.v1.batches.$batchId.ts | 11 +- .../app/routes/realtime.v1.runs.$runId.ts | 23 +- apps/webapp/app/routes/realtime.v1.runs.ts | 19 +- ...ealtime.v1.sessions.$session.$io.append.ts | 10 +- .../realtime.v1.sessions.$session.$io.ts | 11 +- .../realtime.v1.streams.$runId.$streamId.ts | 31 +- ...ltime.v1.streams.$runId.input.$streamId.ts | 22 +- .../runEngine/concerns/batchLimits.server.ts | 13 +- apps/webapp/app/services/apiAuth.server.ts | 60 +- .../app/services/authorization.server.ts | 113 - apps/webapp/app/services/email.server.ts | 10 - .../mfa/multiFactorAuthentication.server.ts | 2 +- .../services/personalAccessToken.server.ts | 103 +- .../webapp/app/services/platform.v3.server.ts | 32 +- .../app/services/projectCreated.server.ts | 35 + apps/webapp/app/services/rbac.server.ts | 29 + .../app/services/realtime/jwtAuth.server.ts | 4 +- .../routeBuilders/apiBuilder.server.ts | 363 +- .../routeBuilders/dashboardBuilder.server.ts | 117 + .../routeBuilders/dashboardBuilder.ts | 141 + .../app/services/scheduleEmail.server.ts | 16 + .../app/services/upsertBranch.server.ts | 2 +- apps/webapp/app/utils/pathBuilder.ts | 4 + .../environmentVariablesRepository.server.ts | 27 +- .../app/v3/remoteImageBuilder.server.ts | 14 +- apps/webapp/package.json | 1 + apps/webapp/test/README.md | 65 + apps/webapp/test/api-auth.e2e.test.ts | 306 ++ apps/webapp/test/auth-api.e2e.full.test.ts | 2977 +++++++++++++++++ .../test/auth-cross-cutting.e2e.full.test.ts | 216 ++ .../test/auth-dashboard.e2e.full.test.ts | 122 + apps/webapp/test/authorization.test.ts | 423 --- .../webapp/test/helpers/seedTestApiSession.ts | 47 + apps/webapp/test/helpers/seedTestPAT.ts | 59 + apps/webapp/test/helpers/seedTestRun.ts | 61 + apps/webapp/test/helpers/seedTestSession.ts | 58 + .../test/helpers/seedTestUserProject.ts | 67 + apps/webapp/test/helpers/seedTestWaitpoint.ts | 29 + apps/webapp/test/helpers/sharedTestServer.ts | 53 + .../test/setup/global-e2e-full-setup.ts | 28 + apps/webapp/test/utils/tracing.ts | 30 +- .../webapp/test/validateGitBranchName.test.ts | 2 +- apps/webapp/vitest.config.ts | 5 +- apps/webapp/vitest.e2e.full.config.ts | 20 + .../migration.sql | 5 + .../database/prisma/schema.prisma | 10 + internal-packages/rbac/package.json | 24 + internal-packages/rbac/src/ability.test.ts | 164 + internal-packages/rbac/src/ability.ts | 63 + internal-packages/rbac/src/fallback.ts | 439 +++ internal-packages/rbac/src/index.ts | 258 ++ internal-packages/rbac/src/loader.test.ts | 69 + internal-packages/rbac/tsconfig.json | 17 + internal-packages/rbac/vitest.config.ts | 10 + .../src/engine/systems/runAttemptSystem.ts | 12 +- .../run-engine/src/shared/index.ts | 29 +- internal-packages/testcontainers/src/utils.ts | 19 +- .../testcontainers/src/webapp.ts | 47 +- packages/core/package.json | 30 + packages/core/src/v3/auth/environment.ts | 108 + .../core/src/v3/utils}/gitBranch.ts | 15 +- packages/plugins/CHANGELOG.md | 7 + packages/plugins/package.json | 46 + packages/plugins/src/index.ts | 23 + packages/plugins/src/rbac.ts | 277 ++ packages/plugins/tsconfig.json | 7 + packages/plugins/tsup.config.ts | 11 + pnpm-lock.yaml | 103 +- 131 files changed, 8901 insertions(+), 1713 deletions(-) create mode 100644 .changeset/plugin-auth-path.md create mode 100644 .github/workflows/e2e-webapp-auth-full.yml create mode 100644 .server-changes/plugin-auth-path.md create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx delete mode 100644 apps/webapp/app/services/authorization.server.ts create mode 100644 apps/webapp/app/services/projectCreated.server.ts create mode 100644 apps/webapp/app/services/rbac.server.ts create mode 100644 apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts create mode 100644 apps/webapp/app/services/routeBuilders/dashboardBuilder.ts create mode 100644 apps/webapp/app/services/scheduleEmail.server.ts create mode 100644 apps/webapp/test/README.md create mode 100644 apps/webapp/test/auth-api.e2e.full.test.ts create mode 100644 apps/webapp/test/auth-cross-cutting.e2e.full.test.ts create mode 100644 apps/webapp/test/auth-dashboard.e2e.full.test.ts delete mode 100644 apps/webapp/test/authorization.test.ts create mode 100644 apps/webapp/test/helpers/seedTestApiSession.ts create mode 100644 apps/webapp/test/helpers/seedTestPAT.ts create mode 100644 apps/webapp/test/helpers/seedTestRun.ts create mode 100644 apps/webapp/test/helpers/seedTestSession.ts create mode 100644 apps/webapp/test/helpers/seedTestUserProject.ts create mode 100644 apps/webapp/test/helpers/seedTestWaitpoint.ts create mode 100644 apps/webapp/test/helpers/sharedTestServer.ts create mode 100644 apps/webapp/test/setup/global-e2e-full-setup.ts create mode 100644 apps/webapp/vitest.e2e.full.config.ts create mode 100644 internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql create mode 100644 internal-packages/rbac/package.json create mode 100644 internal-packages/rbac/src/ability.test.ts create mode 100644 internal-packages/rbac/src/ability.ts create mode 100644 internal-packages/rbac/src/fallback.ts create mode 100644 internal-packages/rbac/src/index.ts create mode 100644 internal-packages/rbac/src/loader.test.ts create mode 100644 internal-packages/rbac/tsconfig.json create mode 100644 internal-packages/rbac/vitest.config.ts create mode 100644 packages/core/src/v3/auth/environment.ts rename {apps/webapp/app/v3 => packages/core/src/v3/utils}/gitBranch.ts (73%) create mode 100644 packages/plugins/CHANGELOG.md create mode 100644 packages/plugins/package.json create mode 100644 packages/plugins/src/index.ts create mode 100644 packages/plugins/src/rbac.ts create mode 100644 packages/plugins/tsconfig.json create mode 100644 packages/plugins/tsup.config.ts diff --git a/.changeset/plugin-auth-path.md b/.changeset/plugin-auth-path.md new file mode 100644 index 00000000000..7ce08b71a33 --- /dev/null +++ b/.changeset/plugin-auth-path.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/plugins": patch +--- + +The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces. diff --git a/.github/workflows/e2e-webapp-auth-full.yml b/.github/workflows/e2e-webapp-auth-full.yml new file mode 100644 index 00000000000..a00ca7a4195 --- /dev/null +++ b/.github/workflows/e2e-webapp-auth-full.yml @@ -0,0 +1,120 @@ +name: "🛡️ E2E Tests: Webapp Auth (full)" + +# Comprehensive RBAC auth test suite — see TRI-8731. Runs separately from +# the smoke e2e-webapp.yml because it covers every route family with a +# pass/fail matrix and would otherwise dominate per-PR CI time. +# +# Triggered: +# - Manually via workflow_dispatch. +# - Nightly via schedule. +# - On pull requests touching auth-relevant files only (paths filter). + +permissions: + contents: read + +on: + workflow_dispatch: + schedule: + - cron: "0 4 * * *" # 04:00 UTC daily + pull_request: + paths: + - "apps/webapp/app/services/routeBuilders/**" + - "apps/webapp/app/services/rbac.server.ts" + - "apps/webapp/app/services/apiAuth.server.ts" + - "apps/webapp/app/services/personalAccessToken.server.ts" + - "apps/webapp/app/services/sessionStorage.server.ts" + - "apps/webapp/app/routes/api.v*.**" + - "apps/webapp/app/routes/realtime.v*.**" + - "apps/webapp/test/**/*.e2e.full.test.ts" + - "apps/webapp/test/setup/global-e2e-full-setup.ts" + - "apps/webapp/test/helpers/sharedTestServer.ts" + - "apps/webapp/test/helpers/seedTestSession.ts" + - "apps/webapp/vitest.e2e.full.config.ts" + - "internal-packages/rbac/**" + - "packages/plugins/**" + - ".github/workflows/e2e-webapp-auth-full.yml" + +jobs: + e2eAuthFull: + name: "🛡️ E2E Auth Tests (full)" + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + steps: + - name: 🔧 Disable IPv6 + run: | + sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1 + sudo sysctl -w net.ipv6.conf.default.disable_ipv6=1 + sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=1 + + - name: 🔧 Configure docker address pool + run: | + CONFIG='{ + "default-address-pools" : [ + { + "base" : "172.17.0.0/12", + "size" : 20 + }, + { + "base" : "192.168.0.0/16", + "size" : 24 + } + ] + }' + mkdir -p /etc/docker + echo "$CONFIG" | sudo tee /etc/docker/daemon.json + + - name: 🔧 Restart docker daemon + run: sudo systemctl restart docker + + - name: ⬇️ Checkout repo + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + # Don't leave the GITHUB_TOKEN in .git/config — this job + # doesn't need to push and the persisted creds would be + # readable from any subsequent step (zizmor/artipacked). + persist-credentials: false + + - name: ⎔ Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + with: + version: 10.33.2 + + - name: ⎔ Setup node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 20.20.0 + cache: "pnpm" + + - name: 🐳 Login to DockerHub + if: ${{ env.DOCKERHUB_USERNAME }} + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: 🐳 Skipping DockerHub login (no secrets available) + if: ${{ !env.DOCKERHUB_USERNAME }} + run: echo "DockerHub login skipped because secrets are not available." + + - name: 🐳 Pre-pull testcontainer images + if: ${{ env.DOCKERHUB_USERNAME }} + run: | + docker pull postgres:14 + docker pull redis:7.2 + docker pull testcontainers/ryuk:0.11.0 + + - name: 📥 Download deps + run: pnpm install --frozen-lockfile + + - name: 📀 Generate Prisma Client + run: pnpm run generate + + - name: 🏗️ Build Webapp + run: pnpm run build --filter webapp + + - name: 🛡️ Run Webapp Full Auth E2E Tests + run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.full.config.ts --reporter=default + env: + WEBAPP_TEST_VERBOSE: "1" diff --git a/.server-changes/plugin-auth-path.md b/.server-changes/plugin-auth-path.md new file mode 100644 index 00000000000..c8269125ffc --- /dev/null +++ b/.server-changes/plugin-auth-path.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Webapp now supports a plugin system. Initially consolidates authentication and authorization paths. diff --git a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx index c8cd131d962..3c17ff482ba 100644 --- a/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx +++ b/apps/webapp/app/components/navigation/OrganizationSettingsSideMenu.tsx @@ -4,6 +4,7 @@ import { Cog8ToothIcon, CreditCardIcon, LockClosedIcon, + ShieldCheckIcon, UserGroupIcon, } from "@heroicons/react/20/solid"; import { ArrowLeftIcon } from "@heroicons/react/24/solid"; @@ -14,6 +15,7 @@ import { useFeatures } from "~/hooks/useFeatures"; import { type MatchedOrganization } from "~/hooks/useOrganizations"; import { cn } from "~/utils/cn"; import { + organizationRolesPath, organizationSettingsPath, organizationSlackIntegrationPath, organizationTeamPath, @@ -45,9 +47,11 @@ export type BuildInfo = { export function OrganizationSettingsSideMenu({ organization, buildInfo, + isUsingPlugin, }: { organization: MatchedOrganization; buildInfo: BuildInfo; + isUsingPlugin: boolean; }) { const { isManagedCloud } = useFeatures(); const featureFlags = useFeatureFlags(); @@ -128,6 +132,16 @@ export function OrganizationSettingsSideMenu({ to={organizationTeamPath(organization)} data-action="team" /> + {isUsingPlugin && ( + + )} : undefined; + // In a Combobox context we wrap the caller's render in ComboboxItem + // so combobox keyboard nav still works. Outside a Combobox we pass + // the render through verbatim — without this, callers like + // SelectLinkItem (which uses render to swap in a ) get their + // render prop silently dropped, which is why those rows looked + // clickable but didn't navigate. + const render = combobox + ? + : props.render; const ref = React.useRef(null); const select = Ariakit.useSelectContext(); const selectValue = select?.useState("value"); diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index e530182bff8..97cccbc1710 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -1542,6 +1542,9 @@ const EnvironmentSchema = z // Private connections PRIVATE_CONNECTIONS_ENABLED: z.string().optional(), PRIVATE_CONNECTIONS_AWS_ACCOUNT_IDS: z.string().optional(), + + // Force RBAC to not use the plugin + RBAC_FORCE_FALLBACK: BoolEnv.default(false), }) .and(GithubAppEnvSchema) .and(S2EnvSchema) diff --git a/apps/webapp/app/models/member.server.ts b/apps/webapp/app/models/member.server.ts index 04c1df1b41f..b88fc7e11c0 100644 --- a/apps/webapp/app/models/member.server.ts +++ b/apps/webapp/app/models/member.server.ts @@ -1,6 +1,8 @@ import { type Prisma, prisma } from "~/db.server"; import { createEnvironment } from "./organization.server"; import { customAlphabet } from "nanoid"; +import { logger } from "~/services/logger.server"; +import { rbac } from "~/services/rbac.server"; const tokenValueLength = 40; const tokenGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", tokenValueLength); @@ -86,10 +88,19 @@ export async function inviteMembers({ slug, emails, userId, + rbacRoleId, }: { slug: string; emails: string[]; userId: string; + /** + * Optional RBAC role to attach to the invite. When set, accepted + * invites trigger `rbac.setUserRole(rbacRoleId)` after the OrgMember + * is created. + * + * `OrgMemberInvite.role` is still set if the plugin isn't installed. + */ + rbacRoleId?: string | null; }) { const org = await prisma.organization.findFirst({ where: { slug, members: { some: { userId } } }, @@ -107,6 +118,7 @@ export async function inviteMembers({ organizationId: org.id, inviterId: userId, role: "MEMBER", + rbacRoleId: rbacRoleId ?? null, } satisfies Prisma.OrgMemberInviteCreateManyInput) ); @@ -163,7 +175,7 @@ export async function acceptInvite({ user: { id: string; email: string }; inviteId: string; }) { - return await prisma.$transaction(async (tx) => { + const result = await prisma.$transaction(async (tx) => { // 1. Delete the invite and get the invite details const invite = await tx.orgMemberInvite.delete({ where: { @@ -207,8 +219,32 @@ export async function acceptInvite({ }, }); - return { remainingInvites, organization: invite.organization }; + return { + remainingInvites, + organization: invite.organization, + inviteRole: invite.role, + rbacRoleId: invite.rbacRoleId, + }; }); + + // If the invite carried an explicit RBAC role. Errors are logged, not fatal. + if (result.rbacRoleId) { + const roleResult = await rbac.setUserRole({ + userId: user.id, + organizationId: result.organization.id, + roleId: result.rbacRoleId, + }); + if (!roleResult.ok) { + logger.error("acceptInvite: skipped RBAC role assignment", { + organizationId: result.organization.id, + userId: user.id, + rbacRoleId: result.rbacRoleId, + reason: roleResult.error, + }); + } + } + + return { remainingInvites: result.remainingInvites, organization: result.organization }; } export async function declineInvite({ diff --git a/apps/webapp/app/models/project.server.ts b/apps/webapp/app/models/project.server.ts index 0dc634b5ab7..d084bec8add 100644 --- a/apps/webapp/app/models/project.server.ts +++ b/apps/webapp/app/models/project.server.ts @@ -4,7 +4,7 @@ import { $replica, prisma } from "~/db.server"; import type { Prisma, Project } from "@trigger.dev/database"; import { type Organization, createEnvironment } from "./organization.server"; import { env } from "~/env.server"; -import { projectCreated } from "~/services/platform.v3.server"; +import { projectCreated } from "~/services/projectCreated.server"; export type { Project } from "@trigger.dev/database"; const externalRefGenerator = customAlphabet("abcdefghijklmnopqrstuvwxyz", 20); diff --git a/apps/webapp/app/models/runtimeEnvironment.server.ts b/apps/webapp/app/models/runtimeEnvironment.server.ts index 9db3bb3133b..64b1da3be49 100644 --- a/apps/webapp/app/models/runtimeEnvironment.server.ts +++ b/apps/webapp/app/models/runtimeEnvironment.server.ts @@ -3,18 +3,100 @@ import type { Prisma, PrismaClientOrTransaction, RuntimeEnvironment } from "@tri import { $replica, prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { getUsername } from "~/utils/username"; -import { sanitizeBranchName } from "~/v3/gitBranch"; +import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; export type { RuntimeEnvironment }; +// Prisma include shape that maps cleanly to the slim AuthenticatedEnvironment. +// Use this everywhere we fetch an env that flows to handlers — keeps the +// returned shape consistent (and the Decimal coercion in toAuthenticated() +// strips Prisma's Decimal class from the public surface). +export const authIncludeBase = { + project: true, + organization: true, + orgMember: { + select: { + userId: true, + user: { select: { id: true, displayName: true, name: true } }, + }, + }, +} satisfies Prisma.RuntimeEnvironmentInclude; + +export const authIncludeWithParent = { + ...authIncludeBase, + parentEnvironment: { select: { id: true, apiKey: true } }, +} satisfies Prisma.RuntimeEnvironmentInclude; + +type PrismaEnvWithAuth = Prisma.RuntimeEnvironmentGetPayload<{ include: typeof authIncludeBase }>; +type PrismaEnvWithAuthAndParent = Prisma.RuntimeEnvironmentGetPayload<{ + include: typeof authIncludeWithParent; +}>; + +// Coerce a Prisma RuntimeEnvironment payload to the slim +// AuthenticatedEnvironment shape. Drops the columns handlers don't read +// and converts `concurrencyLimitBurstFactor` from Prisma's Decimal to a +// plain number (lossless at this scale). The optional union accepts both +// query shapes — with parentEnvironment loaded, or without it. +export function toAuthenticated( + env: PrismaEnvWithAuth | PrismaEnvWithAuthAndParent, +): AuthenticatedEnvironment { + return { + id: env.id, + slug: env.slug, + type: env.type, + apiKey: env.apiKey, + organizationId: env.organizationId, + projectId: env.projectId, + orgMemberId: env.orgMemberId, + parentEnvironmentId: env.parentEnvironmentId, + branchName: env.branchName, + archivedAt: env.archivedAt, + paused: env.paused, + shortcode: env.shortcode, + maximumConcurrencyLimit: env.maximumConcurrencyLimit, + // Coerce Prisma's Decimal to a plain number — the slim type accepts + // both, but downstream consumers shouldn't have to narrow before + // doing arithmetic. Lossless at this scale (Decimal(4,2)). + concurrencyLimitBurstFactor: env.concurrencyLimitBurstFactor.toNumber(), + builtInEnvironmentVariableOverrides: env.builtInEnvironmentVariableOverrides, + createdAt: env.createdAt, + updatedAt: env.updatedAt, + project: { + id: env.project.id, + slug: env.project.slug, + name: env.project.name, + externalRef: env.project.externalRef, + engine: env.project.engine, + deletedAt: env.project.deletedAt, + defaultWorkerGroupId: env.project.defaultWorkerGroupId, + organizationId: env.project.organizationId, + builderProjectId: env.project.builderProjectId, + }, + organization: { + id: env.organization.id, + slug: env.organization.slug, + title: env.organization.title, + streamBasinName: env.organization.streamBasinName, + maximumConcurrencyLimit: env.organization.maximumConcurrencyLimit, + runsEnabled: env.organization.runsEnabled, + maximumDevQueueSize: env.organization.maximumDevQueueSize, + maximumDeployedQueueSize: env.organization.maximumDeployedQueueSize, + featureFlags: env.organization.featureFlags, + apiRateLimiterConfig: env.organization.apiRateLimiterConfig, + batchRateLimitConfig: env.organization.batchRateLimitConfig, + batchQueueConcurrencyConfig: env.organization.batchQueueConcurrencyConfig, + }, + orgMember: env.orgMember, + parentEnvironment: "parentEnvironment" in env ? env.parentEnvironment : null, + }; +} + export async function findEnvironmentByApiKey( apiKey: string, branchName: string | undefined ): Promise { const include = { - project: true, - organization: true, - orgMember: true, + ...authIncludeBase, childEnvironments: branchName ? { where: { @@ -67,23 +149,33 @@ export async function findEnvironmentByApiKey( const childEnvironment = environment.childEnvironments.at(0); if (childEnvironment) { - return { + return toAuthenticated({ ...childEnvironment, apiKey: environment.apiKey, orgMember: environment.orgMember, organization: environment.organization, project: environment.project, - }; + }); } //A branch was specified but no child environment was found return null; } - return environment; + return toAuthenticated(environment); } -/** @deprecated We don't use public api keys anymore */ +/** + * @deprecated We don't use public API keys (`pk_*` tokens) anymore — public + * access goes through public JWTs (see `isPublicJWT` / `validatePublicJwtKey`). + * + * Still exported because a handful of pre-RBAC routes that haven't been + * migrated to the apiBuilder still wire this lookup into their + * `authenticateApiKey` / `authenticateApiKeyWithFailure` flow. The new RBAC + * fallback (`internal-packages/rbac/src/fallback.ts`) intentionally does NOT + * call this — any pk_*-authenticated request that hits an apiBuilder route + * returns 401. That's a deliberate cutover, not an oversight. + */ export async function findEnvironmentByPublicApiKey( apiKey: string, branchName: string | undefined @@ -92,50 +184,29 @@ export async function findEnvironmentByPublicApiKey( where: { pkApiKey: apiKey, }, - include: { - project: true, - organization: true, - orgMember: true, - }, + include: authIncludeBase, }); - //don't return deleted projects - if (environment?.project.deletedAt !== null) { + if (!environment || environment.project.deletedAt !== null) { return null; } - return environment; + return toAuthenticated(environment); } -export async function findEnvironmentById( - id: string -): Promise< - | (AuthenticatedEnvironment & { parentEnvironment: { id: string; apiKey: string } | null }) - | null -> { +export async function findEnvironmentById(id: string): Promise { const environment = await $replica.runtimeEnvironment.findFirst({ where: { id, }, - include: { - project: true, - organization: true, - orgMember: true, - parentEnvironment: { - select: { - id: true, - apiKey: true, - }, - }, - }, + include: authIncludeWithParent, }); - //don't return deleted projects - if (environment?.project.deletedAt !== null) { + if (!environment || environment.project.deletedAt !== null) { return null; } - return environment; + return toAuthenticated(environment); } export async function findEnvironmentBySlug( @@ -143,7 +214,7 @@ export async function findEnvironmentBySlug( envSlug: string, userId: string ): Promise { - return $replica.runtimeEnvironment.findFirst({ + const environment = await $replica.runtimeEnvironment.findFirst({ where: { projectId: projectId, slug: envSlug, @@ -161,12 +232,9 @@ export async function findEnvironmentBySlug( }, ], }, - include: { - project: true, - organization: true, - orgMember: true, - }, + include: authIncludeBase, }); + return environment ? toAuthenticated(environment) : null; } export async function findEnvironmentFromRun( @@ -178,24 +246,16 @@ export async function findEnvironmentFromRun( id: runId, }, include: { - runtimeEnvironment: { - include: { - project: true, - organization: true, - orgMember: true, - }, - }, + runtimeEnvironment: { include: authIncludeBase }, }, }); - - if (!taskRun) { - return null; - } - - return taskRun?.runtimeEnvironment; + return taskRun?.runtimeEnvironment ? toAuthenticated(taskRun.runtimeEnvironment) : null; } -export async function createNewSession(environment: RuntimeEnvironment, ipAddress: string) { +export async function createNewSession( + environment: Pick, + ipAddress: string +) { const session = await prisma.runtimeEnvironmentSession.create({ data: { environmentId: environment.id, diff --git a/apps/webapp/app/presenters/TeamPresenter.server.ts b/apps/webapp/app/presenters/TeamPresenter.server.ts index 8b84a65a67c..f2e5da61a87 100644 --- a/apps/webapp/app/presenters/TeamPresenter.server.ts +++ b/apps/webapp/app/presenters/TeamPresenter.server.ts @@ -1,4 +1,5 @@ import { getTeamMembersAndInvites } from "~/models/member.server"; +import { rbac } from "~/services/rbac.server"; import { getCurrentPlan, getLimit, getPlans } from "~/services/platform.v3.server"; import { BasePresenter } from "./v3/basePresenter.server"; @@ -13,11 +14,30 @@ export class TeamPresenter extends BasePresenter { return; } - const [baseLimit, currentPlan, plans] = await Promise.all([ - getLimit(organizationId, "teamMembers", 100_000_000), - getCurrentPlan(organizationId), - getPlans(), - ]); + const [baseLimit, currentPlan, plans, roles, assignableRoleIds, memberRoleMap] = + await Promise.all([ + getLimit(organizationId, "teamMembers", 100_000_000), + getCurrentPlan(organizationId), + getPlans(), + // RBAC role catalogue (system roles + any org-defined custom + // roles). The default fallback returns []; an installed plugin + // may return the seeded system roles plus any custom roles. + rbac.allRoles(organizationId), + // Plan-gated subset — the Teams page disables dropdown options not + // in this set. Server-side enforcement is independent (setUserRole + // rejects a plan-gated assignment regardless of UI state). + rbac.getAssignableRoleIds(organizationId), + // Per-member current role in a single round-trip. + rbac.getUserRoles( + result.members.map((m) => m.user.id), + organizationId + ), + ]); + + const memberRoles = result.members.map((m) => ({ + userId: m.user.id, + role: memberRoleMap.get(m.user.id) ?? null, + })); const canPurchaseSeats = currentPlan?.v3Subscription?.plan?.limits.teamMembers.canExceed === true; @@ -38,6 +58,9 @@ export class TeamPresenter extends BasePresenter { seatPricing, maxSeatQuota, planSeatLimit, + roles, + assignableRoleIds, + memberRoles, }; } } diff --git a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts index 254ec18d1c0..aa6e15e0fa5 100644 --- a/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts @@ -149,10 +149,10 @@ type ApiRunListSearchParams = z.infer; export class ApiRunListPresenter extends BasePresenter { public async call( - project: Project, + project: Pick, searchParams: ApiRunListSearchParams, apiVersion: API_VERSIONS, - environment?: RuntimeEnvironment + environment?: Pick ) { return this.trace("call", async (span) => { const options: RunListOptions = { diff --git a/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts index 10201094376..5bcdee6b0a9 100644 --- a/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts +++ b/apps/webapp/app/presenters/v3/EnvironmentQueuePresenter.server.ts @@ -47,7 +47,10 @@ export class EnvironmentQueuePresenter extends BasePresenter { running, queued, concurrencyLimit: environment.maximumConcurrencyLimit, - burstFactor: environment.concurrencyLimitBurstFactor.toNumber(), + burstFactor: + typeof environment.concurrencyLimitBurstFactor === "number" + ? environment.concurrencyLimitBurstFactor + : environment.concurrencyLimitBurstFactor.toNumber(), runsEnabled: environment.type === "DEVELOPMENT" || organization.runsEnabled, queueSizeLimit, }; diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx index 44990abaa6e..f77c19ffbdd 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.invite/route.tsx @@ -25,13 +25,15 @@ import { Input } from "~/components/primitives/Input"; import { InputGroup } from "~/components/primitives/InputGroup"; import { Label } from "~/components/primitives/Label"; import { Paragraph } from "~/components/primitives/Paragraph"; +import { Select, SelectItem } from "~/components/primitives/Select"; import { $replica } from "~/db.server"; import { env } from "~/env.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { inviteMembers } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; -import { scheduleEmail } from "~/services/email.server"; +import { scheduleEmail } from "~/services/scheduleEmail.server"; +import { rbac } from "~/services/rbac.server"; import { requireUserId } from "~/services/session.server"; import { acceptInvitePath, organizationTeamPath, v3BillingPath } from "~/utils/pathBuilder"; import { PurchaseSeatsModal } from "../_app.orgs.$organizationSlug.settings.team/route"; @@ -63,9 +65,77 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { throw new Response("Not Found", { status: 404 }); } - return typedjson(result); + // Inviter's own role drives the "below their level" filter on the + // dropdown. Plus assignable role IDs already encode the org's plan + // tier — the intersection is what we offer. + const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([ + rbac.getUserRole({ userId, organizationId: organization.id }), + rbac.getAssignableRoleIds(organization.id), + rbac.systemRoles(organization.id), + ]); + + // Build the dropdown's offerable set server-side: roles that are + // (a) assignable on the current plan AND (b) at or below the + // inviter's own level. The client just renders these — it doesn't + // need to know about the system-role catalogue or the ladder. + const assignableSet = new Set(assignableRoleIds); + const offerableRoleIds = systemRoles + ? result.roles + .filter( + (r) => + assignableSet.has(r.id) && + isAtOrBelow(systemRoles, inviterRole?.id ?? null, r.id) + ) + .map((r) => r.id) + : []; + + return typedjson({ ...result, offerableRoleIds }); }; +// Sentinel for "no RBAC role attached to invite" — the runtime +// fallback will derive a role from the legacy OrgMember.role write at +// accept time. Used when the org has no RBAC plugin installed (the +// dropdown is hidden) or as a defensive default. +const NO_RBAC_ROLE = "__no_rbac_role__"; + +// An inviter can only assign a role at or below their own. The +// plugin's systemRoles array is in canonical order (highest authority +// first), so array index drives the ladder — earlier index = higher +// rank. Plan-tier filtering happens separately via assignableRoleIds; +// the ladder is the absolute hierarchy. Custom roles aren't in the +// table and are refused (TRI-8747's follow-up will handle them). +type LadderRole = { id: string }; + +function buildRoleLevel(roles: ReadonlyArray): Record { + const level: Record = {}; + roles.forEach((r, i) => { + // Top of the array = highest level. Subtract from length so larger + // numbers always mean "more authority" — no off-by-one when a role + // is added or removed. + level[r.id] = roles.length - i; + }); + return level; +} + +function isAtOrBelow( + roles: ReadonlyArray, + inviterRoleId: string | null, + invitedRoleId: string +): boolean { + // No RBAC role on inviter (e.g. the runtime fallback couldn't derive + // one) → fall back to the legacy OrgMember.role check the calling + // code already enforces. Allow the invite to proceed; the action + // would have already failed earlier if the inviter wasn't allowed + // to invite at all. + if (!inviterRoleId) return true; + const level = buildRoleLevel(roles); + const inviter = level[inviterRoleId]; + const invited = level[invitedRoleId]; + // Custom roles aren't in the level table — refuse. + if (inviter === undefined || invited === undefined) return false; + return invited <= inviter; +} + const schema = z.object({ emails: z.preprocess((i) => { if (typeof i === "string") return [i]; @@ -80,6 +150,7 @@ const schema = z.object({ return [""]; }, z.string().email().array().nonempty("At least one email is required")), + rbacRoleId: z.string().optional(), }); export const action: ActionFunction = async ({ request, params }) => { @@ -94,11 +165,62 @@ export const action: ActionFunction = async ({ request, params }) => { return json(submission); } + // Resolve the RBAC role choice. NO_RBAC_ROLE / undefined / unknown + // role → don't pass one through; the runtime fallback handles it. + // Validation: the chosen role must be in the org's assignable set + // (plan-tier) and at or below the inviter's own level. + let resolvedRbacRoleId: string | null = null; + const submittedRbacRoleId = submission.value.rbacRoleId; + if ( + submittedRbacRoleId && + submittedRbacRoleId !== NO_RBAC_ROLE + ) { + const org = await $replica.organization.findFirst({ + where: { slug: organizationSlug }, + select: { id: true }, + }); + if (!org) { + return json({ errors: { body: "Organization not found" } }, { status: 404 }); + } + const [inviterRole, assignableRoleIds, systemRoles] = await Promise.all([ + rbac.getUserRole({ userId, organizationId: org.id }), + rbac.getAssignableRoleIds(org.id), + rbac.systemRoles(org.id), + ]); + if (!systemRoles) { + // No plugin installed but the form somehow submitted a role id — + // ignore it (fall through to legacy behaviour rather than 400). + resolvedRbacRoleId = null; + } else { + const assignable = new Set(assignableRoleIds); + if (!assignable.has(submittedRbacRoleId)) { + return json( + { errors: { body: "You can't invite someone with this role on your current plan" } }, + { status: 400 } + ); + } + if ( + !isAtOrBelow( + systemRoles, + inviterRole?.id ?? null, + submittedRbacRoleId + ) + ) { + return json( + { errors: { body: "You can only invite members at or below your own role" } }, + { status: 403 } + ); + } + resolvedRbacRoleId = submittedRbacRoleId; + } + } + try { const invites = await inviteMembers({ slug: organizationSlug, emails: submission.value.emails, userId, + rbacRoleId: resolvedRbacRoleId, }); for (const invite of invites) { @@ -128,12 +250,35 @@ export const action: ActionFunction = async ({ request, params }) => { }; export default function Page() { - const { limits, canPurchaseSeats, seatPricing, extraSeats, maxSeatQuota, planSeatLimit } = - useTypedLoaderData(); + const { + limits, + canPurchaseSeats, + seatPricing, + extraSeats, + maxSeatQuota, + planSeatLimit, + roles, + offerableRoleIds, + } = useTypedLoaderData(); const [total, setTotal] = useState(limits.used); const organization = useOrganization(); const lastSubmission = useActionData(); + // The loader filtered the catalogue to roles this inviter can + // actually assign (plan tier × strict-below-my-level). With no plugin + // installed, offerableRoleIds is [] and the picker hides entirely. + const offerableSet = new Set(offerableRoleIds); + const offerable = roles.filter((r) => offerableSet.has(r.id)); + const showRolePicker = offerable.length > 0; + + // Default to the lowest-tier offered role (the loader returns roles + // in its allRoles order, which the plugin emits Owner→Member; the + // last entry is the most restrictive). + const defaultRoleId = showRolePicker + ? offerable[offerable.length - 1].id + : NO_RBAC_ROLE; + const [selectedRoleId, setSelectedRoleId] = useState(defaultRoleId); + const [form, { emails }] = useForm({ id: "invite-members", // TODO: type this @@ -232,6 +377,36 @@ export default function Page() { ))} + {showRolePicker ? ( + + + + + defaultValue={defaultRoleId} + items={offerable} + variant="tertiary/medium" + dropdownIcon + text={(v) => + offerable.find((r) => r.id === v)?.name ?? "Pick a role" + } + setValue={(next) => { + if (typeof next === "string") setSelectedRoleId(next); + }} + > + {(items) => + items.map((role) => ( + + {role.name} + + )) + } + + + Invitees join with this role. They can be promoted later + from the Team page. + + + ) : null} limits.limit}> diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx new file mode 100644 index 00000000000..79f2356250a --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.roles/route.tsx @@ -0,0 +1,396 @@ +import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; +import { type MetaFunction } from "@remix-run/react"; +import { useState } from "react"; +import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; +import { PageBody, PageContainer } from "~/components/layout/AppLayout"; +import { Badge } from "~/components/primitives/Badge"; +import { Button } from "~/components/primitives/Buttons"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog"; +import { Header3 } from "~/components/primitives/Headers"; +import { NavBar, PageTitle } from "~/components/primitives/PageHeader"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + Table, + TableBlankRow, + TableBody, + TableCell, + TableHeader, + TableHeaderCell, + TableRow, +} from "~/components/primitives/Table"; +import { cn } from "~/utils/cn"; +import { $replica } from "~/db.server"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { rbac } from "~/services/rbac.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; +import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; +import { TextLink } from "~/components/primitives/TextLink"; + +export const meta: MetaFunction = () => { + return [ + { + title: `Roles | Trigger.dev`, + }, + ]; +}; + +const Params = z.object({ + organizationSlug: z.string(), +}); + +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ + where: { slug }, + select: { id: true }, + }); + return org?.id ?? null; +} + +export const loader = dashboardLoader( + { + params: Params, + context: async (params) => { + const orgId = await resolveOrgIdFromSlug(params.organizationSlug); + return orgId ? { organizationId: orgId } : {}; + }, + authorization: { action: "read", resource: { type: "members" } }, + }, + async ({ context }) => { + const orgId = context.organizationId; + if (!orgId) { + throw new Response("Not Found", { status: 404 }); + } + + const [roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin] = + await Promise.all([ + rbac.allRoles(orgId), + rbac.getAssignableRoleIds(orgId), + rbac.allPermissions(orgId), + rbac.systemRoles(orgId), + // OSS self-host: no enterprise plugin → no role infrastructure to + // show. Render a "roles aren't available" layout in that case + // rather than the plan-upsell empty state (which assumes a cloud + // plan and would be misleading). + rbac.isUsingPlugin(), + ]); + + return typedjson({ + roles, + assignableRoleIds, + allPermissions, + systemRoles, + isUsingPlugin, + }); + } +); + +type LoaderData = UseDataFunctionReturn; +type LoaderRole = LoaderData["roles"][number]; +type LoaderPermission = LoaderData["allPermissions"][number]; +type RolePermission = LoaderRole["permissions"][number]; + +// Permissions are bucketed by `permission.group` from the plugin. +// Section order = first-seen order in `allPermissions()`. Permissions +// without a group fall into "Other" at the bottom. +const FALLBACK_GROUP = "Other"; + +export default function Page() { + const { roles, assignableRoleIds, allPermissions, systemRoles, isUsingPlugin } = + useTypedLoaderData(); + const organization = useOrganization(); + const plan = useCurrentPlan(); + const planCode = plan?.v3Subscription?.plan?.code; + const isEnterprise = planCode === "enterprise"; + + // Map role-id → role for fast cell lookup. Each role's permissions are + // already the expanded `effectivePermissions` output (system roles + // populated server-side; custom roles too) so cells just filter that + // list by permission name. + const rolesById = new Map(roles.map((r) => [r.id, r])); + const assignable = new Set(assignableRoleIds); + + // Column ordering follows the plugin's canonical systemRoles order + // (highest authority first), then any custom roles in the order + // rbac.allRoles returned them. systemRoles is null when no plugin is + // installed; fall through to whatever order rbac.allRoles returns. + // Each entry's `available` flag reflects plan-tier eligibility — we + // render unavailable system roles too, but PlanBadge tags them so + // customers see the comparison and know what an upgrade unlocks. + const systemRoleOrder = systemRoles ?? []; + const systemRoleIdSet = new Set(systemRoleOrder.map((r) => r.id)); + const systemColumns = systemRoleOrder.flatMap((meta) => { + const role = rolesById.get(meta.id); + return role ? [{ role, fallbackName: meta.name }] : []; + }); + const customColumns = roles + .filter((r) => !systemRoleIdSet.has(r.id)) + .map((role) => ({ role, fallbackName: role.name })); + const columns = [...systemColumns, ...customColumns]; + + const grouped = groupPermissions(allPermissions); + + return ( + + + + {/* Suppress the Enterprise-upsell button on OSS — there's no + plan to upgrade to in a self-hosted deployment, and the + dialog copy ("Available on the Enterprise plan") doesn't + apply. The not-supported empty state below makes the + absence of role infrastructure clear instead. */} + {isUsingPlugin && !isEnterprise ? : null} + + +
+
+ + Roles control what each team member can do in {organization.title}. + Compare what each role grants below; assign a role to a team member from the{" "} + Team page. + +
+
+ {columns.length === 0 ? ( + + ) : ( + + + + Permission + {columns.map(({ role }) => ( + +
+ {role.name} + +
+
+ ))} + Description +
+
+ + {grouped.length === 0 ? ( + + + No permissions to display. + + + ) : ( + grouped.flatMap(({ group, permissions }) => [ + + + + {group} + + + , + ...permissions.map((permission) => ( + + + {permission.name} + + {columns.map(({ role }) => ( + + + + ))} + + + {permission.description || ( + + )} + + + + )), + ]) + )} + +
+ )} +
+
+
+
+ ); +} + +function EmptyState({ isUsingPlugin }: { isUsingPlugin: boolean }) { + // Two distinct empty states: + // + // 1. Plugin loaded, but rbac.allRoles returned nothing the org can + // use under its plan tier. The plan-upsell copy is correct — + // upgrade unlocks the role infrastructure. + // 2. No plugin loaded (OSS self-host). There's no "plan" to upgrade + // to. RBAC simply isn't part of this deployment; we use a + // permissive ability for every authenticated user and rely on + // org-membership for access control. Surface that honestly + // instead of dangling a fake upgrade carrot. + if (!isUsingPlugin) { + return ( +
+ Roles aren't available in this self-hosted deployment. + + All members have full access. Role-Based Access Controls are available in Trigger.dev + Cloud or with an enterprise self-hosted license. + +
+ ); + } + return ( +
+ No roles available on this plan. + + Upgrade to Pro to unlock RBAC. + +
+ ); +} + +function PlanBadge({ + roleId, + assignable, + systemRoleIdSet, +}: { + roleId: string; + assignable: ReadonlySet; + systemRoleIdSet: ReadonlySet; +}) { + // Roles the org's plan doesn't permit get a small upgrade-tier hint + // in the column header. The cell rendering is identical regardless + // — the comparison value is still useful even on Free/Hobby. + if (assignable.has(roleId)) return null; + // System roles render as "Pro" (the gating tier where they unlock — + // Free/Hobby see Owner+Admin only, Pro adds the rest). Custom roles + // render as "Enterprise" — only Enterprise plans can create or assign + // them. + if (systemRoleIdSet.has(roleId)) { + return Pro; + } + return Enterprise; +} + +// Render a single (role × permission) cell. Filters the role's +// effectivePermissions list to entries matching this permission name +// and emits an icon + optional condition badge based on the rules. +function RoleCell({ + permissionName, + rolePermissions, +}: { + permissionName: string; + rolePermissions: RolePermission[]; +}) { + const matching = rolePermissions.filter((p) => p.name === permissionName); + + if (matching.length === 0) { + // No rule matches — the role denies this permission by omission. + return ( + + + + ); + } + + const allowed = matching.filter((p) => !p.inverted); + const denied = matching.filter((p) => p.inverted); + + // Only inverted rules apply — the role explicitly denies this + // permission. Render as ✗ in error colour. + if (allowed.length === 0) { + return ( + + + + ); + } + + // At least one allow rule applies. If there's a conditional cannot + // rule, replace the ✓ with just the condition label so the user sees + // the restriction without a misleading tick. Plain unconditional + // allow keeps the ✓. + const conditionalDeny = denied.find((p) => p.conditions); + if (conditionalDeny?.conditions) { + return ( + {conditionLabel(conditionalDeny.conditions)} + ); + } + return ( + + + + ); +} + +// Render a CASL conditions object into a tier badge label. Only +// `envType` is recognised today (the catalogue's only allowed condition); +// extending this requires adding a new branch when ALLOWED_CONDITIONS +// grows. +function conditionLabel(conditions: Record): string { + if (typeof conditions.envType === "string") { + if (conditions.envType === "PRODUCTION") return "Non-prod only"; + return `Non-${conditions.envType.toLowerCase()} only`; + } + return JSON.stringify(conditions); +} + +function groupPermissions( + permissions: LoaderPermission[] +): { group: string; permissions: LoaderPermission[] }[] { + // Insertion-ordered map: groups appear in the order their first + // permission was seen. Plugins that want a specific section order + // just emit permissions in that order from `allPermissions()`. + const buckets = new Map(); + for (const permission of permissions) { + const group = permission.group ?? FALLBACK_GROUP; + const list = buckets.get(group) ?? []; + list.push(permission); + buckets.set(group, list); + } + return Array.from(buckets, ([group, permissions]) => ({ group, permissions })); +} + +function CreateRoleUpsell() { + const [open, setOpen] = useState(false); + return ( + + + + + + Custom roles are an Enterprise feature +
+ + Define your own roles with bespoke permission sets — perfect for "Member, but no + production deploys" or a vendor/contractor role. Available on the Enterprise plan. + + + Get in touch and we'll walk you through the Enterprise plan and how custom roles fit + your team. + +
+
+ + +
+
+
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx index dc71bc5585f..c95ca471f85 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.settings.team/route.tsx @@ -9,7 +9,7 @@ import { useFetcher, useNavigation, } from "@remix-run/react"; -import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime"; +import { json } from "@remix-run/server-runtime"; import { tryCatch } from "@trigger.dev/core/utils"; import { useEffect, useRef, useState } from "react"; import { type UseDataFunctionReturn, typedjson, useTypedLoaderData } from "remix-typedjson"; @@ -41,24 +41,27 @@ import { Label } from "~/components/primitives/Label"; import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; import { Paragraph } from "~/components/primitives/Paragraph"; import * as Property from "~/components/primitives/PropertyTable"; +import { Select, SelectItem, SelectLinkItem } from "~/components/primitives/Select"; import { SpinnerWhite } from "~/components/primitives/Spinner"; import { SimpleTooltip } from "~/components/primitives/Tooltip"; -import { cn } from "~/utils/cn"; import { $replica } from "~/db.server"; import { useOrganization } from "~/hooks/useOrganizations"; import { useUser } from "~/hooks/useUser"; import { removeTeamMember } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; import { TeamPresenter } from "~/presenters/TeamPresenter.server"; -import { requireUserId } from "~/services/session.server"; +import { rbac } from "~/services/rbac.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; +import { cn } from "~/utils/cn"; +import { formatCurrency, formatNumber } from "~/utils/numberFormatter"; import { inviteTeamMemberPath, + organizationRolesPath, organizationTeamPath, resendInvitePath, revokeInvitePath, v3BillingPath, } from "~/utils/pathBuilder"; -import { formatCurrency, formatNumber } from "~/utils/numberFormatter"; import { SetSeatsAddOnService } from "~/v3/services/setSeatsAddOn.server"; import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route"; @@ -74,31 +77,51 @@ const Params = z.object({ organizationSlug: z.string(), }); -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const { organizationSlug } = Params.parse(params); - - const organization = await $replica.organization.findFirst({ - where: { slug: organizationSlug }, +// Resolve slug → orgId in the dashboardLoader's context callback so the +// rbac.authenticateSession call gets a real organizationId. The result +// is cached for the duration of the request and reused by the handler +// below (we re-find by slug there to get a typed value — the context +// only sees the loosely typed return type). +async function resolveOrgIdFromSlug(slug: string): Promise { + const org = await $replica.organization.findFirst({ + where: { slug }, select: { id: true }, }); + return org?.id ?? null; +} - if (!organization) { - throw new Response("Not Found", { status: 404 }); - } +export const loader = dashboardLoader( + { + params: Params, + context: async (params) => { + const orgId = await resolveOrgIdFromSlug(params.organizationSlug); + return orgId ? { organizationId: orgId } : {}; + }, + authorization: { action: "read", resource: { type: "members" } }, + }, + async ({ user, ability, context }) => { + const orgId = context.organizationId; + if (!orgId) { + throw new Response("Not Found", { status: 404 }); + } - const presenter = new TeamPresenter(); - const result = await presenter.call({ - userId, - organizationId: organization.id, - }); + const presenter = new TeamPresenter(); + const result = await presenter.call({ + userId: user.id, + organizationId: orgId, + }); - if (!result) { - throw new Response("Not Found", { status: 404 }); - } + if (!result) { + throw new Response("Not Found", { status: 404 }); + } - return typedjson(result); -}; + // Pre-compute manage authority server-side so the UI gating matches + // the action gating (the action enforces it independently). + const canManageMembers = ability.can("manage", { type: "members" }); + + return typedjson({ ...result, canManageMembers }); + } +); const schema = z.object({ memberId: z.string(), @@ -111,89 +134,157 @@ const PurchaseSchema = z.discriminatedUnion("action", [ }), z.object({ action: z.literal("quota-increase"), - amount: z.coerce - .number() - .int("Must be a whole number") - .min(1, "Amount must be greater than 0"), + amount: z.coerce.number().int("Must be a whole number").min(1, "Amount must be greater than 0"), }), ]); -export const action = async ({ request, params }: ActionFunctionArgs) => { - const userId = await requireUserId(request); - const { organizationSlug } = params; - invariant(organizationSlug, "organizationSlug not found"); +const SetRoleSchema = z.object({ + userId: z.string(), + roleId: z.string(), +}); - const formData = await request.formData(); - const formType = formData.get("_formType"); +export const action = dashboardAction( + { + params: Params, + context: async (params) => { + const orgId = await resolveOrgIdFromSlug(params.organizationSlug); + return orgId ? { organizationId: orgId } : {}; + }, + // No top-level authorization — different intents have different + // requirements. Each branch inside checks the right ability: + // set-role → manage:members + // purchase-seats → manage:billing + // remove-member → manage:members (skipped for self-leave) + // Don't rely on the model-layer (removeTeamMember / + // SetSeatsAddOnService) for enforcement — those are defense in + // depth; the route layer is where the ability gate belongs. + }, + async ({ user, ability, request, params, context }) => { + const userId = user.id; + const { organizationSlug } = params; + invariant(organizationSlug, "organizationSlug not found"); - if (formType === "purchase-seats") { - const org = await $replica.organization.findFirst({ - where: { slug: organizationSlug }, - select: { id: true }, - }); + const formData = await request.formData(); + const formType = formData.get("_formType"); - if (!org) { - return json({ ok: false, error: "Organization not found" } as const); + if (formType === "set-role") { + if (!ability.can("manage", { type: "members" })) { + return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); + } + const orgId = context.organizationId; + if (!orgId) { + return json({ ok: false, error: "Organization not found" } as const, { status: 404 }); + } + const submission = parse(formData, { schema: SetRoleSchema }); + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } + const result = await rbac.setUserRole({ + userId: submission.value.userId, + organizationId: orgId, + roleId: submission.value.roleId, + }); + if (!result.ok) { + return json({ ok: false, error: result.error } as const, { status: 400 }); + } + return json({ ok: true } as const); } - const submission = parse(formData, { schema: PurchaseSchema }); + if (formType === "purchase-seats") { + // Adjusting seat count is a billing operation. Pre-RBAC the team + // page's loader gated the entire route on Owner/Admin, so reaching + // this action implied authority. Post-RBAC the loader requires + // `read:members` (broader audience), so gate the seat purchase + // explicitly here against the right ability rather than relying + // on the SetSeatsAddOnService for enforcement at the model layer. + if (!ability.can("manage", { type: "billing" })) { + return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); + } + // Reuse the orgId the dashboardBuilder already resolved in the + // context callback (single slug → orgId lookup per request, + // regardless of whether the OSS fallback or cloud plugin + // services the auth — the plugin takes `organizationId` as + // input and doesn't re-resolve from a slug). + const orgId = context.organizationId; + if (!orgId) { + return json({ ok: false, error: "Organization not found" } as const); + } - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } + const submission = parse(formData, { schema: PurchaseSchema }); - const service = new SetSeatsAddOnService(); - const [error, result] = await tryCatch( - service.call({ - userId, - organizationId: org.id, - action: submission.value.action, - amount: submission.value.amount, - }) - ); + if (!submission.value || submission.intent !== "submit") { + return json(submission); + } - if (error) { - submission.error.amount = [error instanceof Error ? error.message : "Unknown error"]; - return json(submission); + const service = new SetSeatsAddOnService(); + const [error, result] = await tryCatch( + service.call({ + userId, + organizationId: orgId, + action: submission.value.action, + amount: submission.value.amount, + }) + ); + + if (error) { + submission.error.amount = [error instanceof Error ? error.message : "Unknown error"]; + return json(submission); + } + + if (!result.success) { + submission.error.amount = [result.error]; + return json(submission); + } + + return json({ ok: true } as const); } - if (!result.success) { - submission.error.amount = [result.error]; + const submission = parse(formData, { schema }); + + if (!submission.value || submission.intent !== "submit") { return json(submission); } - return json({ ok: true } as const); - } - - const submission = parse(formData, { schema }); + // Default intent: remove a member or leave the org. Self-leave (the + // actor removing their own membership) is always allowed. Removing + // another member requires `manage:members` — pre-RBAC the + // `removeTeamMember` model fn only verified the actor was a member + // of the target org, so any org member could remove any other + // member by id; this gate fixes that latent permissions hole. + const targetMember = await $replica.orgMember.findFirst({ + where: { id: submission.value.memberId }, + select: { userId: true }, + }); + const isSelfLeave = targetMember?.userId === userId; + if (!isSelfLeave && !ability.can("manage", { type: "members" })) { + return json({ ok: false, error: "Unauthorized" } as const, { status: 403 }); + } - if (!submission.value || submission.intent !== "submit") { - return json(submission); - } + try { + const deletedMember = await removeTeamMember({ + userId, + memberId: submission.value.memberId, + slug: organizationSlug, + }); - try { - const deletedMember = await removeTeamMember({ - userId, - memberId: submission.value.memberId, - slug: organizationSlug, - }); + if (deletedMember.userId === userId) { + return redirectWithSuccessMessage("/", request, `You left the organization`); + } - if (deletedMember.userId === userId) { - return redirectWithSuccessMessage("/", request, `You left the organization`); + return redirectWithSuccessMessage( + organizationTeamPath(deletedMember.organization), + request, + `Removed ${deletedMember.user.name ?? "member"} from team` + ); + } catch (error: any) { + return json({ errors: { body: error.message } }, { status: 400 }); } - - return redirectWithSuccessMessage( - organizationTeamPath(deletedMember.organization), - request, - `Removed ${deletedMember.user.name ?? "member"} from team` - ); - } catch (error: any) { - return json({ errors: { body: error.message } }, { status: 400 }); } -}; +); type Member = UseDataFunctionReturn["members"][number]; type Invite = UseDataFunctionReturn["invites"][number]; +type Role = UseDataFunctionReturn["roles"][number]; export default function Page() { const { @@ -205,7 +296,16 @@ export default function Page() { seatPricing, maxSeatQuota, planSeatLimit, + roles, + assignableRoleIds, + memberRoles, + canManageMembers, } = useTypedLoaderData(); + // Build a userId → roleId map so the dropdown's defaultValue matches + // each member's current assignment without re-querying. + const memberRoleByUserId = new Map( + memberRoles.flatMap((m) => (m.role ? [[m.userId, m.role.id]] : [])) + ); const user = useUser(); const organization = useOrganization(); @@ -242,10 +342,31 @@ export default function Page() { ))} - {requiresUpgrade ? ( + {!canManageMembers ? ( + // Gate the invite affordance on manage:members. The action + // route enforces this independently — hiding it here just + // avoids dead UI for non-managers. + + Invite a team member + + } + content="You don't have permission to invite team members" + disableHoverableContent + /> + ) : requiresUpgrade ? ( + Invite a team member } @@ -291,34 +412,57 @@ export default function Page() { )} - Active team members -
    +
    + Active team members + {roles.length > 0 ? ( + + View all role permissions → + + ) : null} +
    +
    {members.map((member) => ( -
  • - -
    - - {member.user.name}{" "} - {member.user.id === user.id && ( - (You) - )} - - {member.user.email} +
    +
    + +
    + + {member.user.name}{" "} + {member.user.id === user.id && ( + (You) + )} + + {member.user.email} +
    -
    + +
    -
  • +
    ))} -
+ @@ -387,10 +531,12 @@ function LeaveRemoveButton({ userId, member, memberCount, + canManageMembers, }: { userId: string; member: Member; memberCount: number; + canManageMembers: boolean; }) { const organization = useOrganization(); @@ -409,7 +555,8 @@ function LeaveRemoveButton({ ); } - //you leave the team + //you leave the team — leaving is always permitted regardless of + //manage:members; non-managers can still leave on their own. return ( + Remove from team + + } + disableHoverableContent + content="You don't have permission to remove team members" + /> + ); + } return ( (); + const assignable = new Set(assignableRoleIds); + // With no RBAC plugin installed, the loader returns no roles — + // render nothing rather than an empty dropdown. + if (roles.length === 0) return null; + + const isSubmitting = fetcher.state === "submitting"; + const error = + fetcher.data && "error" in fetcher.data && fetcher.data.error ? fetcher.data.error : null; + + const picker = ( + + ); + + return ( +
+ {canManageMembers ? ( + picker + ) : ( + // Disabled + {showRolePicker && }
@@ -265,6 +376,37 @@ function CreatePersonalAccessToken() { {tokenName.error} + {showRolePicker && ( + + + + value={selectedRoleId} + setValue={(v) => setSelectedRoleId(v)} + items={roles} + variant="tertiary/small" + dropdownIcon + text={(v) => roles.find((r) => r.id === v)?.name ?? "Select a role"} + > + {(items) => + items.map((role) => ( + + + {role.name} + {role.description ? ( + {role.description} + ) : null} + + + )) + } + + + The token can act with up to this role. Your current role in each org is the + actual ceiling — the token never grants more than you have. + + + )} + diff --git a/apps/webapp/app/routes/admin._index.tsx b/apps/webapp/app/routes/admin._index.tsx index e847fc759ad..3005934d226 100644 --- a/apps/webapp/app/routes/admin._index.tsx +++ b/apps/webapp/app/routes/admin._index.tsx @@ -1,6 +1,5 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { Button, LinkButton } from "~/components/primitives/Buttons"; @@ -17,8 +16,8 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; -import { adminGetUsers } from "~/models/admin.server"; -import { requireUserId } from "~/services/session.server"; +import { adminGetUsers, redirectWithImpersonation } from "~/models/admin.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { createSearchParams } from "~/utils/searchParams"; export const SearchParams = z.object({ @@ -28,17 +27,34 @@ export const SearchParams = z.object({ export type SearchParams = z.infer; -export const loader = async ({ request }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ user, request }) => { + const searchParams = createSearchParams(request.url, SearchParams); + if (!searchParams.success) { + throw new Error(searchParams.error); + } + const result = await adminGetUsers(user.id, searchParams.params.getAll()); - const searchParams = createSearchParams(request.url, SearchParams); - if (!searchParams.success) { - throw new Error(searchParams.error); + return typedjson(result); } - const result = await adminGetUsers(userId, searchParams.params.getAll()); +); - return typedjson(result); -}; +const FormSchema = z.object({ id: z.string() }); + +export const action = dashboardAction( + { authorization: { requireSuper: true } }, + async ({ request }) => { + if (request.method.toLowerCase() !== "post") { + return new Response("Method not allowed", { status: 405 }); + } + + const payload = Object.fromEntries(await request.formData()); + const { id } = FormSchema.parse(payload); + + return redirectWithImpersonation(request, id, "/"); + } +); export default function AdminDashboardRoute() { const { users, filters, page, pageCount } = useTypedLoaderData(); diff --git a/apps/webapp/app/routes/admin.back-office._index.tsx b/apps/webapp/app/routes/admin.back-office._index.tsx index 15e6f699b9a..e2226aebb4a 100644 --- a/apps/webapp/app/routes/admin.back-office._index.tsx +++ b/apps/webapp/app/routes/admin.back-office._index.tsx @@ -1,17 +1,15 @@ -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect, typedjson } from "remix-typedjson"; +import { typedjson } from "remix-typedjson"; import { LinkButton } from "~/components/primitives/Buttons"; import { Header2 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { requireUser } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; -export async function loader({ request }: LoaderFunctionArgs) { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async () => { + return typedjson({}); } - return typedjson({}); -} +); export default function BackOfficeIndex() { return ( diff --git a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx index 260fef32d96..627c8bd5297 100644 --- a/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx +++ b/apps/webapp/app/routes/admin.back-office.orgs.$orgId.tsx @@ -1,6 +1,6 @@ import { useNavigation, useSearchParams } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { useEffect } from "react"; +import { z } from "zod"; import { redirect, typedjson, @@ -36,59 +36,50 @@ import { CopyableText } from "~/components/primitives/CopyableText"; import { Header1 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { prisma } from "~/db.server"; -import { requireUser } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; const SAVED_QUERY_KEY = "saved"; -export async function loader({ request, params }: LoaderFunctionArgs) { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); - } - - const orgId = params.orgId; - if (!orgId) { - throw new Response(null, { status: 404 }); - } - - const org = await prisma.organization.findFirst({ - where: { id: orgId }, - select: { - id: true, - slug: true, - title: true, - createdAt: true, - apiRateLimiterConfig: true, - batchRateLimitConfig: true, - maximumProjectCount: true, - }, - }); - - if (!org) { - throw new Response(null, { status: 404 }); - } - - const apiEffective = resolveEffectiveApiRateLimit(org.apiRateLimiterConfig); - const batchEffective = resolveEffectiveBatchRateLimit( - org.batchRateLimitConfig - ); +const ParamsSchema = z.object({ + orgId: z.string(), +}); + +export const loader = dashboardLoader( + { authorization: { requireSuper: true }, params: ParamsSchema }, + async ({ params }) => { + const orgId = params.orgId; + + const org = await prisma.organization.findFirst({ + where: { id: orgId }, + select: { + id: true, + slug: true, + title: true, + createdAt: true, + apiRateLimiterConfig: true, + batchRateLimitConfig: true, + maximumProjectCount: true, + }, + }); + + if (!org) { + throw new Response(null, { status: 404 }); + } - return typedjson({ org, apiEffective, batchEffective }); -} + const apiEffective = resolveEffectiveApiRateLimit(org.apiRateLimiterConfig); + const batchEffective = resolveEffectiveBatchRateLimit(org.batchRateLimitConfig); -export async function action({ request, params }: ActionFunctionArgs) { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); + return typedjson({ org, apiEffective, batchEffective }); } +); - const orgId = params.orgId; - if (!orgId) { - throw new Response(null, { status: 404 }); - } +export const action = dashboardAction( + { authorization: { requireSuper: true }, params: ParamsSchema }, + async ({ user, params, request }) => { + const orgId = params.orgId; - const formData = await request.formData(); - const intent = formData.get("intent"); + const formData = await request.formData(); + const intent = formData.get("intent"); if (intent === MAX_PROJECTS_INTENT) { const result = await handleMaxProjectsAction(formData, orgId, user.id); @@ -129,11 +120,12 @@ export async function action({ request, params }: ActionFunctionArgs) { ); } - return typedjson( - { section: null, errors: { intent: ["Unknown intent."] } }, - { status: 400 } - ); -} + return typedjson( + { section: null, errors: { intent: ["Unknown intent."] } }, + { status: 400 } + ); + } +); export default function BackOfficeOrgPage() { const { org, apiEffective, batchEffective } = diff --git a/apps/webapp/app/routes/admin.back-office.tsx b/apps/webapp/app/routes/admin.back-office.tsx index 026fc13fdc5..3ec9e99b2ca 100644 --- a/apps/webapp/app/routes/admin.back-office.tsx +++ b/apps/webapp/app/routes/admin.back-office.tsx @@ -1,15 +1,13 @@ import { Outlet } from "@remix-run/react"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect, typedjson } from "remix-typedjson"; -import { requireUser } from "~/services/session.server"; +import { typedjson } from "remix-typedjson"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; -export async function loader({ request }: LoaderFunctionArgs) { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async () => { + return typedjson({}); } - return typedjson({}); -} +); export default function BackOfficeLayout() { return ( diff --git a/apps/webapp/app/routes/admin.concurrency.tsx b/apps/webapp/app/routes/admin.concurrency.tsx index a24f7debb9d..630bc100b0b 100644 --- a/apps/webapp/app/routes/admin.concurrency.tsx +++ b/apps/webapp/app/routes/admin.concurrency.tsx @@ -1,23 +1,19 @@ import { InformationCircleIcon } from "@heroicons/react/20/solid"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { Header1 } from "~/components/primitives/Headers"; import { InfoPanel } from "~/components/primitives/InfoPanel"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { requireUser } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server"; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async () => { + const deployedConcurrency = await concurrencyTracker.globalConcurrentRunCount(true); + const devConcurrency = await concurrencyTracker.globalConcurrentRunCount(false); + return typedjson({ deployedConcurrency, devConcurrency }); } - - const deployedConcurrency = await concurrencyTracker.globalConcurrentRunCount(true); - const devConcurrency = await concurrencyTracker.globalConcurrentRunCount(false); - - return typedjson({ deployedConcurrency, devConcurrency }); -}; +); export default function AdminDashboardRoute() { const { deployedConcurrency, devConcurrency } = useTypedLoaderData(); diff --git a/apps/webapp/app/routes/admin.feature-flags.tsx b/apps/webapp/app/routes/admin.feature-flags.tsx index 4066e6a4d9b..02faa7add91 100644 --- a/apps/webapp/app/routes/admin.feature-flags.tsx +++ b/apps/webapp/app/routes/admin.feature-flags.tsx @@ -1,14 +1,16 @@ import { useFetcher } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { useEffect, useState } from "react"; import stableStringify from "json-stable-stringify"; import { json } from "@remix-run/server-runtime"; -import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { LockClosedIcon } from "@heroicons/react/20/solid"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; -import { requireUser } from "~/services/session.server"; +import { + dashboardAction, + dashboardLoader, +} from "~/services/routeBuilders/dashboardBuilder"; import { FEATURE_FLAG, GLOBAL_LOCKED_FLAGS, @@ -38,53 +40,48 @@ import { type WorkerGroup, } from "~/components/admin/FlagControls"; -export const loader = async ({ request }: LoaderFunctionArgs) => { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); - } - - const [globalFlags, workerGroups] = await Promise.all([ - getGlobalFlags(), - prisma.workerInstanceGroup.findMany({ - select: { id: true, name: true }, - orderBy: { name: "asc" }, - }), - ]); - const controlTypes = getAllFlagControlTypes(); - - // Resolve env-based defaults for locked flags - const resolvedDefaults: Record = { - [FEATURE_FLAG.taskEventRepository]: env.EVENT_REPOSITORY_DEFAULT_STORE, - }; - - // Look up worker group name if the flag is set - const workerGroupId = (globalFlags as Record)?.[ - FEATURE_FLAG.defaultWorkerInstanceGroupId - ]; - const workerGroupName = - typeof workerGroupId === "string" - ? workerGroups.find((wg) => wg.id === workerGroupId)?.name - : undefined; - - const { isManagedCloud } = featuresForRequest(request); - - return typedjson({ - globalFlags, - controlTypes, - resolvedDefaults, - workerGroupName, - workerGroups, - isManagedCloud, - }); -}; - -export const action = async ({ request }: ActionFunctionArgs) => { - const user = await requireUser(request); - if (!user.admin) { - throw new Response("Unauthorized", { status: 403 }); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ request }) => { + const [globalFlags, workerGroups] = await Promise.all([ + getGlobalFlags(), + prisma.workerInstanceGroup.findMany({ + select: { id: true, name: true }, + orderBy: { name: "asc" }, + }), + ]); + const controlTypes = getAllFlagControlTypes(); + + // Resolve env-based defaults for locked flags + const resolvedDefaults: Record = { + [FEATURE_FLAG.taskEventRepository]: env.EVENT_REPOSITORY_DEFAULT_STORE, + }; + + // Look up worker group name if the flag is set + const workerGroupId = (globalFlags as Record)?.[ + FEATURE_FLAG.defaultWorkerInstanceGroupId + ]; + const workerGroupName = + typeof workerGroupId === "string" + ? workerGroups.find((wg) => wg.id === workerGroupId)?.name + : undefined; + + const { isManagedCloud } = featuresForRequest(request); + + return typedjson({ + globalFlags, + controlTypes, + resolvedDefaults, + workerGroupName, + workerGroups, + isManagedCloud, + }); } +); +export const action = dashboardAction( + { authorization: { requireSuper: true } }, + async ({ request }) => { let body: unknown; try { body = await request.json(); @@ -156,7 +153,8 @@ export const action = async ({ request }: ActionFunctionArgs) => { ]); return json({ success: true }); -}; + } +); export default function AdminFeatureFlagsRoute() { const { diff --git a/apps/webapp/app/routes/admin.llm-models.$modelId.tsx b/apps/webapp/app/routes/admin.llm-models.$modelId.tsx index 7b51067dd0c..e90752fb28d 100644 --- a/apps/webapp/app/routes/admin.llm-models.$modelId.tsx +++ b/apps/webapp/app/routes/admin.llm-models.$modelId.tsx @@ -1,5 +1,4 @@ import { Form, useActionData, useNavigate } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -8,34 +7,37 @@ import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Input } from "~/components/primitives/Input"; import { Paragraph } from "~/components/primitives/Paragraph"; import { prisma } from "~/db.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server"; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); - - const model = await prisma.llmModel.findUnique({ - where: { friendlyId: params.modelId }, - include: { - pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } }, - }, - }); - - if (!model) throw new Response("Model not found", { status: 404 }); - - // Convert Prisma Decimal to plain numbers for serialization - const serialized = { - ...model, - pricingTiers: model.pricingTiers.map((t) => ({ - ...t, - prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })), - })), - }; - - return typedjson({ model: serialized }); -}; +const ParamsSchema = z.object({ + modelId: z.string(), +}); + +export const loader = dashboardLoader( + { authorization: { requireSuper: true }, params: ParamsSchema }, + async ({ params }) => { + const model = await prisma.llmModel.findUnique({ + where: { friendlyId: params.modelId }, + include: { + pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } }, + }, + }); + + if (!model) throw new Response("Model not found", { status: 404 }); + + // Convert Prisma Decimal to plain numbers for serialization + const serialized = { + ...model, + pricingTiers: model.pricingTiers.map((t) => ({ + ...t, + prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })), + })), + }; + + return typedjson({ model: serialized }); + } +); const SaveSchema = z.object({ modelName: z.string().min(1), @@ -49,100 +51,99 @@ const SaveSchema = z.object({ isHidden: z.string().optional(), }); -export async function action({ request, params }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); - - const friendlyId = params.modelId!; - const existing = await prisma.llmModel.findUnique({ where: { friendlyId } }); - if (!existing) throw new Response("Model not found", { status: 404 }); - const modelId = existing.id; - - const formData = await request.formData(); - const _action = formData.get("_action"); - - if (_action === "delete") { - await prisma.llmModel.delete({ where: { id: modelId } }); - await llmPricingRegistry?.reload(); - return redirect("/admin/llm-models"); - } - - if (_action === "save") { - const raw = Object.fromEntries(formData); - const parsed = SaveSchema.safeParse(raw); - - if (!parsed.success) { - return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 }); +export const action = dashboardAction( + { authorization: { requireSuper: true }, params: ParamsSchema }, + async ({ params, request }) => { + const friendlyId = params.modelId; + const existing = await prisma.llmModel.findUnique({ where: { friendlyId } }); + if (!existing) throw new Response("Model not found", { status: 404 }); + const modelId = existing.id; + + const formData = await request.formData(); + const _action = formData.get("_action"); + + if (_action === "delete") { + await prisma.llmModel.delete({ where: { id: modelId } }); + await llmPricingRegistry?.reload(); + return redirect("/admin/llm-models"); } - const { modelName, matchPattern, pricingTiersJson } = parsed.data; - - // Validate regex — strip (?i) POSIX flag since our registry handles it - try { - const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern; - new RegExp(testPattern); - } catch { - return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 }); - } - - // Parse tiers - let pricingTiers: Array<{ - name: string; - isDefault: boolean; - priority: number; - conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>; - prices: Record; - }>; - try { - pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers; - } catch { - return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 }); - } - - // Update model - const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data; - await prisma.llmModel.update({ - where: { id: modelId }, - data: { - modelName, - matchPattern, - provider: provider || null, - description: description || null, - contextWindow: contextWindow ? parseInt(contextWindow) || null : null, - maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null, - capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [], - isHidden: isHidden === "on", - }, - }); - - // Replace tiers - await prisma.llmPricingTier.deleteMany({ where: { modelId } }); - for (const tier of pricingTiers) { - await prisma.llmPricingTier.create({ + if (_action === "save") { + const raw = Object.fromEntries(formData); + const parsed = SaveSchema.safeParse(raw); + + if (!parsed.success) { + return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 }); + } + + const { modelName, matchPattern, pricingTiersJson } = parsed.data; + + // Validate regex — strip (?i) POSIX flag since our registry handles it + try { + const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern; + new RegExp(testPattern); + } catch { + return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 }); + } + + // Parse tiers + let pricingTiers: Array<{ + name: string; + isDefault: boolean; + priority: number; + conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>; + prices: Record; + }>; + try { + pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers; + } catch { + return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 }); + } + + // Update model + const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data; + await prisma.llmModel.update({ + where: { id: modelId }, data: { - modelId, - name: tier.name, - isDefault: tier.isDefault, - priority: tier.priority, - conditions: tier.conditions, - prices: { - create: Object.entries(tier.prices).map(([usageType, price]) => ({ - modelId, - usageType, - price, - })), - }, + modelName, + matchPattern, + provider: provider || null, + description: description || null, + contextWindow: contextWindow ? parseInt(contextWindow) || null : null, + maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null, + capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [], + isHidden: isHidden === "on", }, }); + + // Replace tiers + await prisma.llmPricingTier.deleteMany({ where: { modelId } }); + for (const tier of pricingTiers) { + await prisma.llmPricingTier.create({ + data: { + modelId, + name: tier.name, + isDefault: tier.isDefault, + priority: tier.priority, + conditions: tier.conditions, + prices: { + create: Object.entries(tier.prices).map(([usageType, price]) => ({ + modelId, + usageType, + price, + })), + }, + }, + }); + } + + await llmPricingRegistry?.reload(); + return typedjson({ success: true }); } - await llmPricingRegistry?.reload(); - return typedjson({ success: true }); + return typedjson({ error: "Unknown action" }, { status: 400 }); } - - return typedjson({ error: "Unknown action" }, { status: 400 }); -} +); export default function AdminLlmModelDetailRoute() { const { model } = useTypedLoaderData(); diff --git a/apps/webapp/app/routes/admin.llm-models._index.tsx b/apps/webapp/app/routes/admin.llm-models._index.tsx index ea2eff72541..585cbb4637b 100644 --- a/apps/webapp/app/routes/admin.llm-models._index.tsx +++ b/apps/webapp/app/routes/admin.llm-models._index.tsx @@ -1,7 +1,5 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { Form, useFetcher, Link } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { Button, LinkButton } from "~/components/primitives/Buttons"; @@ -18,7 +16,7 @@ import { TableRow, } from "~/components/primitives/Table"; import { prisma } from "~/db.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { createSearchParams } from "~/utils/searchParams"; import { seedLlmPricing, syncLlmCatalog } from "@internal/llm-model-catalog"; import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server"; @@ -30,121 +28,119 @@ const SearchParams = z.object({ search: z.string().optional(), }); -export const loader = async ({ request }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ request }) => { + const searchParams = createSearchParams(request.url, SearchParams); + if (!searchParams.success) throw new Error(searchParams.error); + const { page: rawPage, search } = searchParams.params.getAll(); + const page = rawPage ?? 1; + + const where = { + projectId: null as string | null, + ...(search ? { modelName: { contains: search, mode: "insensitive" as const } } : {}), + }; + + const [rawModels, total] = await Promise.all([ + prisma.llmModel.findMany({ + where, + include: { + pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } }, + }, + orderBy: { modelName: "asc" }, + skip: (page - 1) * PAGE_SIZE, + take: PAGE_SIZE, + }), + prisma.llmModel.count({ where }), + ]); + + // Convert Prisma Decimal to plain numbers for serialization + const models = rawModels.map((m) => ({ + ...m, + pricingTiers: m.pricingTiers.map((t) => ({ + ...t, + prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })), + })), + })); - const searchParams = createSearchParams(request.url, SearchParams); - if (!searchParams.success) throw new Error(searchParams.error); - const { page: rawPage, search } = searchParams.params.getAll(); - const page = rawPage ?? 1; - - const where = { - projectId: null as string | null, - ...(search ? { modelName: { contains: search, mode: "insensitive" as const } } : {}), - }; - - const [rawModels, total] = await Promise.all([ - prisma.llmModel.findMany({ - where, - include: { - pricingTiers: { include: { prices: true }, orderBy: { priority: "asc" } }, - }, - orderBy: { modelName: "asc" }, - skip: (page - 1) * PAGE_SIZE, - take: PAGE_SIZE, - }), - prisma.llmModel.count({ where }), - ]); - - // Convert Prisma Decimal to plain numbers for serialization - const models = rawModels.map((m) => ({ - ...m, - pricingTiers: m.pricingTiers.map((t) => ({ - ...t, - prices: t.prices.map((p) => ({ ...p, price: Number(p.price) })), - })), - })); - - return typedjson({ - models, - total, - page, - pageCount: Math.ceil(total / PAGE_SIZE), - filters: { search }, - }); -}; - -export async function action({ request }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); - - const formData = await request.formData(); - const _action = formData.get("_action"); - - if (_action === "seed") { - console.log("[admin] seed action started"); - const result = await seedLlmPricing(prisma); - console.log(`[admin] seed complete: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`); - await llmPricingRegistry?.reload(); - console.log("[admin] registry reloaded after seed"); return typedjson({ - success: true, - message: `Seeded: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`, + models, + total, + page, + pageCount: Math.ceil(total / PAGE_SIZE), + filters: { search }, }); } +); + +export const action = dashboardAction( + { authorization: { requireSuper: true } }, + async ({ request }) => { + const formData = await request.formData(); + const _action = formData.get("_action"); + + if (_action === "seed") { + console.log("[admin] seed action started"); + const result = await seedLlmPricing(prisma); + console.log(`[admin] seed complete: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`); + await llmPricingRegistry?.reload(); + console.log("[admin] registry reloaded after seed"); + return typedjson({ + success: true, + message: `Seeded: ${result.modelsCreated} created, ${result.modelsSkipped} skipped, ${result.modelsUpdated} updated`, + }); + } - if (_action === "sync") { - console.log("[admin] sync catalog action started"); - const result = await syncLlmCatalog(prisma); - console.log(`[admin] sync complete: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`); - await llmPricingRegistry?.reload(); - console.log("[admin] registry reloaded after sync"); - return typedjson({ - success: true, - message: `Synced: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`, - }); - } - - if (_action === "reload") { - console.log("[admin] reload action started"); - await llmPricingRegistry?.reload(); - console.log("[admin] registry reloaded"); - return typedjson({ success: true, message: "Registry reloaded" }); - } - - if (_action === "test") { - const modelString = formData.get("modelString"); - if (typeof modelString !== "string" || !modelString) { - return typedjson({ testResult: null }); + if (_action === "sync") { + console.log("[admin] sync catalog action started"); + const result = await syncLlmCatalog(prisma); + console.log(`[admin] sync complete: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`); + await llmPricingRegistry?.reload(); + console.log("[admin] registry reloaded after sync"); + return typedjson({ + success: true, + message: `Synced: ${result.modelsUpdated} updated, ${result.modelsSkipped} skipped`, + }); } - // Use the registry's match() which handles prefix stripping automatically - const matched = llmPricingRegistry?.match(modelString) ?? null; + if (_action === "reload") { + console.log("[admin] reload action started"); + await llmPricingRegistry?.reload(); + console.log("[admin] registry reloaded"); + return typedjson({ success: true, message: "Registry reloaded" }); + } - return typedjson({ - testResult: { - modelString, - match: matched - ? { friendlyId: matched.friendlyId, modelName: matched.modelName } - : null, - }, - }); - } + if (_action === "test") { + const modelString = formData.get("modelString"); + if (typeof modelString !== "string" || !modelString) { + return typedjson({ testResult: null }); + } + + // Use the registry's match() which handles prefix stripping automatically + const matched = llmPricingRegistry?.match(modelString) ?? null; + + return typedjson({ + testResult: { + modelString, + match: matched + ? { friendlyId: matched.friendlyId, modelName: matched.modelName } + : null, + }, + }); + } - if (_action === "delete") { - const modelId = formData.get("modelId"); - if (typeof modelId === "string") { - await prisma.llmModel.delete({ where: { id: modelId } }); - await llmPricingRegistry?.reload(); + if (_action === "delete") { + const modelId = formData.get("modelId"); + if (typeof modelId === "string") { + await prisma.llmModel.delete({ where: { id: modelId } }); + await llmPricingRegistry?.reload(); + } + return typedjson({ success: true }); } - return typedjson({ success: true }); - } - return typedjson({ error: "Unknown action" }, { status: 400 }); -} + return typedjson({ error: "Unknown action" }, { status: 400 }); + } +); export default function AdminLlmModelsRoute() { const { models, filters, page, pageCount, total } = diff --git a/apps/webapp/app/routes/admin.llm-models.missing.$model.tsx b/apps/webapp/app/routes/admin.llm-models.missing.$model.tsx index 78cb1c4fc91..3c63ce09fc4 100644 --- a/apps/webapp/app/routes/admin.llm-models.missing.$model.tsx +++ b/apps/webapp/app/routes/admin.llm-models.missing.$model.tsx @@ -1,39 +1,40 @@ import { useState } from "react"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { z } from "zod"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Paragraph } from "~/components/primitives/Paragraph"; -import { prisma } from "~/db.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { getMissingModelSamples, type MissingModelSample, } from "~/services/admin/missingLlmModels.server"; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); +const ParamsSchema = z.object({ + model: z.string(), +}); - // Model name is URL-encoded in the URL param - const modelName = decodeURIComponent(params.model ?? ""); - if (!modelName) throw new Response("Missing model param", { status: 400 }); +export const loader = dashboardLoader( + { authorization: { requireSuper: true }, params: ParamsSchema }, + async ({ params, request }) => { + // Model name is URL-encoded in the URL param + const modelName = decodeURIComponent(params.model); + if (!modelName) throw new Response("Missing model param", { status: 400 }); - const url = new URL(request.url); - const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10); + const url = new URL(request.url); + const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10); - let samples: MissingModelSample[] = []; - let error: string | undefined; + let samples: MissingModelSample[] = []; + let error: string | undefined; - try { - samples = await getMissingModelSamples({ model: modelName, lookbackHours, limit: 10 }); - } catch (e) { - error = e instanceof Error ? e.message : "Failed to query ClickHouse"; - } + try { + samples = await getMissingModelSamples({ model: modelName, lookbackHours, limit: 10 }); + } catch (e) { + error = e instanceof Error ? e.message : "Failed to query ClickHouse"; + } - return typedjson({ modelName, samples, lookbackHours, error }); -}; + return typedjson({ modelName, samples, lookbackHours, error }); + } +); export default function AdminMissingModelDetailRoute() { const { modelName, samples, lookbackHours, error } = useTypedLoaderData(); diff --git a/apps/webapp/app/routes/admin.llm-models.missing._index.tsx b/apps/webapp/app/routes/admin.llm-models.missing._index.tsx index fd933cd22e9..7cacb727f9c 100644 --- a/apps/webapp/app/routes/admin.llm-models.missing._index.tsx +++ b/apps/webapp/app/routes/admin.llm-models.missing._index.tsx @@ -1,6 +1,4 @@ import { useSearchParams } from "@remix-run/react"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect } from "@remix-run/server-runtime"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; import { LinkButton } from "~/components/primitives/Buttons"; @@ -14,8 +12,7 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; -import { prisma } from "~/db.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { getMissingLlmModels } from "~/services/admin/missingLlmModels.server"; const LOOKBACK_OPTIONS = [ @@ -30,25 +27,24 @@ const SearchParams = z.object({ lookbackHours: z.coerce.number().optional(), }); -export const loader = async ({ request }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ request }) => { + const url = new URL(request.url); + const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10); - const url = new URL(request.url); - const lookbackHours = parseInt(url.searchParams.get("lookbackHours") ?? "24", 10); + let models: Awaited> = []; + let error: string | undefined; - let models: Awaited> = []; - let error: string | undefined; + try { + models = await getMissingLlmModels({ lookbackHours }); + } catch (e) { + error = e instanceof Error ? e.message : "Failed to query ClickHouse"; + } - try { - models = await getMissingLlmModels({ lookbackHours }); - } catch (e) { - error = e instanceof Error ? e.message : "Failed to query ClickHouse"; + return typedjson({ models, lookbackHours, error }); } - - return typedjson({ models, lookbackHours, error }); -}; +); export default function AdminLlmModelsMissingRoute() { const { models, lookbackHours, error } = useTypedLoaderData(); diff --git a/apps/webapp/app/routes/admin.llm-models.new.tsx b/apps/webapp/app/routes/admin.llm-models.new.tsx index 7f18bf5826a..ab9c7881e2c 100644 --- a/apps/webapp/app/routes/admin.llm-models.new.tsx +++ b/apps/webapp/app/routes/admin.llm-models.new.tsx @@ -1,5 +1,4 @@ import { Form, useActionData, useSearchParams } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { redirect } from "@remix-run/server-runtime"; import { typedjson } from "remix-typedjson"; import { z } from "zod"; @@ -7,16 +6,16 @@ import { useState } from "react"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Input } from "~/components/primitives/Input"; import { prisma } from "~/db.server"; -import { requireUserId } from "~/services/session.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { generateFriendlyId } from "~/v3/friendlyIdentifiers"; import { llmPricingRegistry } from "~/v3/llmPricingRegistry.server"; -export const loader = async ({ request }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); - return typedjson({}); -}; +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async () => { + return typedjson({}); + } +); const CreateSchema = z.object({ modelName: z.string().min(1), @@ -30,83 +29,82 @@ const CreateSchema = z.object({ isHidden: z.string().optional(), }); -export async function action({ request }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); - - const formData = await request.formData(); - const raw = Object.fromEntries(formData); - console.log("[admin] create model form data:", JSON.stringify(raw).slice(0, 500)); - const parsed = CreateSchema.safeParse(raw); +export const action = dashboardAction( + { authorization: { requireSuper: true } }, + async ({ request }) => { + const formData = await request.formData(); + const raw = Object.fromEntries(formData); + console.log("[admin] create model form data:", JSON.stringify(raw).slice(0, 500)); + const parsed = CreateSchema.safeParse(raw); + + if (!parsed.success) { + console.log("[admin] create model validation error:", JSON.stringify(parsed.error.issues)); + return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 }); + } - if (!parsed.success) { - console.log("[admin] create model validation error:", JSON.stringify(parsed.error.issues)); - return typedjson({ error: "Invalid form data", details: parsed.error.issues }, { status: 400 }); - } + const { modelName, matchPattern, pricingTiersJson } = parsed.data; - const { modelName, matchPattern, pricingTiersJson } = parsed.data; + // Validate regex — strip (?i) POSIX flag since our registry handles it + try { + const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern; + new RegExp(testPattern); + } catch { + return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 }); + } - // Validate regex — strip (?i) POSIX flag since our registry handles it - try { - const testPattern = matchPattern.startsWith("(?i)") ? matchPattern.slice(4) : matchPattern; - new RegExp(testPattern); - } catch { - return typedjson({ error: "Invalid regex in matchPattern" }, { status: 400 }); - } + let pricingTiers: Array<{ + name: string; + isDefault: boolean; + priority: number; + conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>; + prices: Record; + }>; + try { + pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers; + } catch { + return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 }); + } - let pricingTiers: Array<{ - name: string; - isDefault: boolean; - priority: number; - conditions: Array<{ usageDetailPattern: string; operator: string; value: number }>; - prices: Record; - }>; - try { - pricingTiers = JSON.parse(pricingTiersJson) as typeof pricingTiers; - } catch { - return typedjson({ error: "Invalid pricing tiers JSON" }, { status: 400 }); - } + const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data; - const { provider, description, contextWindow, maxOutputTokens, capabilities, isHidden } = parsed.data; - - const model = await prisma.llmModel.create({ - data: { - friendlyId: generateFriendlyId("llm_model"), - modelName, - matchPattern, - source: "admin", - provider: provider || null, - description: description || null, - contextWindow: contextWindow ? parseInt(contextWindow) || null : null, - maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null, - capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [], - isHidden: isHidden === "on", - }, - }); - - for (const tier of pricingTiers) { - await prisma.llmPricingTier.create({ + const model = await prisma.llmModel.create({ data: { - modelId: model.id, - name: tier.name, - isDefault: tier.isDefault, - priority: tier.priority, - conditions: tier.conditions, - prices: { - create: Object.entries(tier.prices).map(([usageType, price]) => ({ - modelId: model.id, - usageType, - price, - })), - }, + friendlyId: generateFriendlyId("llm_model"), + modelName, + matchPattern, + source: "admin", + provider: provider || null, + description: description || null, + contextWindow: contextWindow ? parseInt(contextWindow) || null : null, + maxOutputTokens: maxOutputTokens ? parseInt(maxOutputTokens) || null : null, + capabilities: capabilities ? capabilities.split(",").map((s) => s.trim()).filter(Boolean) : [], + isHidden: isHidden === "on", }, }); - } - await llmPricingRegistry?.reload(); - return redirect(`/admin/llm-models/${model.friendlyId}`); -} + for (const tier of pricingTiers) { + await prisma.llmPricingTier.create({ + data: { + modelId: model.id, + name: tier.name, + isDefault: tier.isDefault, + priority: tier.priority, + conditions: tier.conditions, + prices: { + create: Object.entries(tier.prices).map(([usageType, price]) => ({ + modelId: model.id, + usageType, + price, + })), + }, + }, + }); + } + + await llmPricingRegistry?.reload(); + return redirect(`/admin/llm-models/${model.friendlyId}`); + } +); export default function AdminLlmModelNewRoute() { const actionData = useActionData<{ error?: string; details?: unknown[] }>(); diff --git a/apps/webapp/app/routes/admin.notifications.tsx b/apps/webapp/app/routes/admin.notifications.tsx index 548fc16619b..60b74d104cd 100644 --- a/apps/webapp/app/routes/admin.notifications.tsx +++ b/apps/webapp/app/routes/admin.notifications.tsx @@ -1,7 +1,5 @@ import { TrashIcon } from "@heroicons/react/20/solid"; import { useFetcher, useSearchParams } from "@remix-run/react"; -import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect } from "@remix-run/server-runtime"; import { useEffect, useState } from "react"; import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { z } from "zod"; @@ -41,9 +39,8 @@ import { TableHeaderCell, TableRow, } from "~/components/primitives/Table"; -import { prisma } from "~/db.server"; +import { dashboardAction, dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { logger } from "~/services/logger.server"; -import { requireUserId } from "~/services/session.server"; import { archivePlatformNotification, createPlatformNotification, @@ -68,55 +65,54 @@ const SearchParams = z.object({ hideInactive: z.coerce.boolean().optional(), }); -export const loader = async ({ request }: LoaderFunctionArgs) => { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); - - const searchParams = createSearchParams(request.url, SearchParams); - if (!searchParams.success) throw new Error(searchParams.error); - const { page: rawPage, hideInactive } = searchParams.params.getAll(); - const page = rawPage ?? 1; - - const data = await getAdminNotificationsList({ - page, - pageSize: PAGE_SIZE, - hideInactive: hideInactive ?? false, - }); +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ user, request }) => { + const searchParams = createSearchParams(request.url, SearchParams); + if (!searchParams.success) throw new Error(searchParams.error); + const { page: rawPage, hideInactive } = searchParams.params.getAll(); + const page = rawPage ?? 1; + + const data = await getAdminNotificationsList({ + page, + pageSize: PAGE_SIZE, + hideInactive: hideInactive ?? false, + }); - return typedjson({ ...data, userId }); -}; + return typedjson({ ...data, userId: user.id }); + } +); -export async function action({ request }: ActionFunctionArgs) { - const userId = await requireUserId(request); - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user?.admin) throw redirect("/"); +export const action = dashboardAction( + { authorization: { requireSuper: true } }, + async ({ user, request }) => { + const userId = user.id; + const formData = await request.formData(); + const _action = formData.get("_action"); - const formData = await request.formData(); - const _action = formData.get("_action"); + if (_action === "create" || _action === "create-preview") { + return handleCreateAction(formData, userId, _action === "create-preview"); + } - if (_action === "create" || _action === "create-preview") { - return handleCreateAction(formData, userId, _action === "create-preview"); - } + if (_action === "archive") { + return handleArchiveAction(formData); + } - if (_action === "archive") { - return handleArchiveAction(formData); - } + if (_action === "delete") { + return handleDeleteAction(formData); + } - if (_action === "delete") { - return handleDeleteAction(formData); - } + if (_action === "publish-now") { + return handlePublishNowAction(formData); + } - if (_action === "publish-now") { - return handlePublishNowAction(formData); - } + if (_action === "edit") { + return handleEditAction(formData); + } - if (_action === "edit") { - return handleEditAction(formData); + return typedjson({ error: "Unknown action" }, { status: 400 }); } - - return typedjson({ error: "Unknown action" }, { status: 400 }); -} +); function parseNotificationFormData(formData: FormData) { const surface = formData.get("surface") as string; diff --git a/apps/webapp/app/routes/admin.orgs.tsx b/apps/webapp/app/routes/admin.orgs.tsx index 6d16ab99c9d..8441d4d19da 100644 --- a/apps/webapp/app/routes/admin.orgs.tsx +++ b/apps/webapp/app/routes/admin.orgs.tsx @@ -1,7 +1,6 @@ import { MagnifyingGlassIcon } from "@heroicons/react/20/solid"; import { Form } from "@remix-run/react"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; import { useState } from "react"; import { z } from "zod"; import { FeatureFlagsDialog } from "~/components/admin/FeatureFlagsDialog"; @@ -20,7 +19,7 @@ import { TableRow, } from "~/components/primitives/Table"; import { adminGetOrganizations } from "~/models/admin.server"; -import { requireUser, requireUserId } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; import { createSearchParams } from "~/utils/searchParams"; export const SearchParams = z.object({ @@ -30,20 +29,18 @@ export const SearchParams = z.object({ export type SearchParams = z.infer; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); - } +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ user, request }) => { + const searchParams = createSearchParams(request.url, SearchParams); + if (!searchParams.success) { + throw new Error(searchParams.error); + } + const result = await adminGetOrganizations(user.id, searchParams.params.getAll()); - const searchParams = createSearchParams(request.url, SearchParams); - if (!searchParams.success) { - throw new Error(searchParams.error); + return typedjson(result); } - const result = await adminGetOrganizations(user.id, searchParams.params.getAll()); - - return typedjson(result); -}; +); export default function AdminDashboardRoute() { const { organizations, filters, page, pageCount } = useTypedLoaderData(); diff --git a/apps/webapp/app/routes/admin.tsx b/apps/webapp/app/routes/admin.tsx index 61431398220..236c7f0580c 100644 --- a/apps/webapp/app/routes/admin.tsx +++ b/apps/webapp/app/routes/admin.tsx @@ -1,18 +1,13 @@ import { Outlet } from "@remix-run/react"; -import type { LoaderFunctionArgs } from "@remix-run/server-runtime"; -import { redirect, typedjson } from "remix-typedjson"; +import { typedjson } from "remix-typedjson"; import { LinkButton } from "~/components/primitives/Buttons"; import { Tabs } from "~/components/primitives/Tabs"; -import { requireUser } from "~/services/session.server"; +import { dashboardLoader } from "~/services/routeBuilders/dashboardBuilder"; -export async function loader({ request }: LoaderFunctionArgs) { - const user = await requireUser(request); - if (!user.admin) { - return redirect("/"); - } - - return typedjson({ user }); -} +export const loader = dashboardLoader( + { authorization: { requireSuper: true } }, + async ({ user }) => typedjson({ user }) +); export default function Page() { return ( diff --git a/apps/webapp/app/routes/api.v1.batches.$batchId.ts b/apps/webapp/app/routes/api.v1.batches.$batchId.ts index d852385b4b6..5c0c65baf57 100644 --- a/apps/webapp/app/routes/api.v1.batches.$batchId.ts +++ b/apps/webapp/app/routes/api.v1.batches.$batchId.ts @@ -1,7 +1,7 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { $replica } from "~/db.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ batchId: z.string(), @@ -25,8 +25,19 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (batch) => ({ batch: batch.friendlyId }), - superScopes: ["read:runs", "read:all", "admin"], + // Pre-RBAC, this route's `superScopes` included `read:runs`, so a + // JWT minted with `read:runs` could read batches. The new strict + // scope-type match means `read:runs` no longer trivially matches + // `{type: "batch"}`. Include `{type: "runs"}` (alongside the + // batch-id-scoped element) to preserve that semantic for any + // SDK-issued tokens in the wild — a `read:runs` JWT still passes + // batch retrieval. Per-id `read:batch:` and type-level + // `read:batch` still grant via the first element. + resource: (batch) => + anyResource([ + { type: "batch", id: batch.friendlyId }, + { type: "runs" }, + ]), }, }, async ({ resource: batch }) => { diff --git a/apps/webapp/app/routes/api.v1.deployments.ts b/apps/webapp/app/routes/api.v1.deployments.ts index 8fa5b432950..39988e1d13d 100644 --- a/apps/webapp/app/routes/api.v1.deployments.ts +++ b/apps/webapp/app/routes/api.v1.deployments.ts @@ -69,8 +69,7 @@ export const loader = createLoaderApiRoute( corsStrategy: "none", authorization: { action: "read", - resource: () => ({ deployments: "list" }), - superScopes: ["read:deployments", "read:all", "admin"], + resource: () => ({ type: "deployments", id: "list" }), }, findResource: async () => 1, // This is a dummy function, we don't need to find a resource }, diff --git a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts index 557a67409de..f9c5ac0b68c 100644 --- a/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts +++ b/apps/webapp/app/routes/api.v1.idempotencyKeys.$key.reset.ts @@ -21,8 +21,7 @@ export const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "write", - resource: () => ({}), - superScopes: ["write:runs", "admin"], + resource: () => ({ type: "runs" }), }, }, async ({ params, body, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.projects.$projectRef.runs.ts b/apps/webapp/app/routes/api.v1.projects.$projectRef.runs.ts index 295bcb5caee..5e952aa7ce0 100644 --- a/apps/webapp/app/routes/api.v1.projects.$projectRef.runs.ts +++ b/apps/webapp/app/routes/api.v1.projects.$projectRef.runs.ts @@ -1,5 +1,6 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; +import { $replica } from "~/db.server"; import { findProjectByRef } from "~/models/project.server"; import { ApiRunListPresenter, @@ -16,6 +17,20 @@ export const loader = createLoaderPATApiRoute( params: ParamsSchema, searchParams: ApiRunListSearchParams, corsStrategy: "all", + // Resolve projectRef → org so the PAT plugin can ground its + // role-floor calculation. We deliberately don't filter by user + // membership here — that's the plugin's job (`authenticatePat` + // checks OrgMember in the target org and rejects if the user + // isn't a member). Keeps the contract clean: context is "what + // org does this URL target?" and auth is "is this user allowed?" + context: async (params) => { + const project = await $replica.project.findFirst({ + where: { externalRef: params.projectRef }, + select: { organizationId: true }, + }); + return project ? { organizationId: project.organizationId } : {}; + }, + authorization: { action: "read", resource: () => ({ type: "runs" }) }, }, async ({ searchParams, params, authentication, apiVersion }) => { const project = await findProjectByRef(params.projectRef, authentication.userId); diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts index 1203682793a..99601b5d668 100644 --- a/apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts +++ b/apps/webapp/app/routes/api.v1.prompts.$slug.override.reactivate.ts @@ -22,8 +22,7 @@ const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "update", - resource: (params) => ({ prompts: params.slug }), - superScopes: ["admin"], + resource: (params) => ({ type: "prompts", id: params.slug }), }, }, async ({ body, params, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts index 3ddf7b78416..2a00ceac15c 100644 --- a/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts +++ b/apps/webapp/app/routes/api.v1.prompts.$slug.override.ts @@ -40,8 +40,7 @@ const { action, loader } = createMultiMethodApiRoute({ corsStrategy: "all", authorization: { action: "update", - resource: (params) => ({ prompts: params.slug }), - superScopes: ["admin"], + resource: (params) => ({ type: "prompts", id: params.slug }), }, methods: { POST: { diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts index 6040fdb46e6..795e4a6c68f 100644 --- a/apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts +++ b/apps/webapp/app/routes/api.v1.prompts.$slug.promote.ts @@ -22,8 +22,7 @@ const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "update", - resource: (params) => ({ prompts: params.slug }), - superScopes: ["admin"], + resource: (params) => ({ type: "prompts", id: params.slug }), }, }, async ({ body, params, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.ts index 32ea1525c14..0d101ae6122 100644 --- a/apps/webapp/app/routes/api.v1.prompts.$slug.ts +++ b/apps/webapp/app/routes/api.v1.prompts.$slug.ts @@ -37,8 +37,7 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (_resource, params) => ({ prompts: params.slug }), - superScopes: ["read:prompts", "admin"], + resource: (_resource, params) => ({ type: "prompts", id: params.slug }), }, }, async ({ searchParams, resource: prompt }) => { @@ -98,8 +97,7 @@ const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "read", - resource: (params) => ({ prompts: params.slug }), - superScopes: ["read:prompts", "admin"], + resource: (params) => ({ type: "prompts", id: params.slug }), }, }, async ({ body, params, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts b/apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts index c40b3e62dbf..49f90a98c84 100644 --- a/apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts +++ b/apps/webapp/app/routes/api.v1.prompts.$slug.versions.ts @@ -27,8 +27,7 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (_resource, params) => ({ prompts: params.slug }), - superScopes: ["read:prompts", "admin"], + resource: (_resource, params) => ({ type: "prompts", id: params.slug }), }, }, async ({ resource: prompt }) => { diff --git a/apps/webapp/app/routes/api.v1.prompts._index.ts b/apps/webapp/app/routes/api.v1.prompts._index.ts index ccbc0ec38d0..e4ef5f9702e 100644 --- a/apps/webapp/app/routes/api.v1.prompts._index.ts +++ b/apps/webapp/app/routes/api.v1.prompts._index.ts @@ -10,8 +10,7 @@ export const loader = createLoaderApiRoute( findResource: async () => 1, authorization: { action: "read", - resource: () => ({ prompts: "all" }), - superScopes: ["read:prompts", "admin"], + resource: () => ({ type: "prompts", id: "all" }), }, }, async ({ authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.query.dashboards._index.ts b/apps/webapp/app/routes/api.v1.query.dashboards._index.ts index fdc4dbc3852..2bc9e3b3016 100644 --- a/apps/webapp/app/routes/api.v1.query.dashboards._index.ts +++ b/apps/webapp/app/routes/api.v1.query.dashboards._index.ts @@ -37,8 +37,7 @@ export const loader = createLoaderApiRoute( findResource: async () => 1, authorization: { action: "read", - resource: () => ({ query: "dashboards" }), - superScopes: ["read:query", "read:all", "admin"], + resource: () => ({ type: "query", id: "dashboards" }), }, }, async () => { diff --git a/apps/webapp/app/routes/api.v1.query.schema.ts b/apps/webapp/app/routes/api.v1.query.schema.ts index aa4762af6f8..3e95d16818d 100644 --- a/apps/webapp/app/routes/api.v1.query.schema.ts +++ b/apps/webapp/app/routes/api.v1.query.schema.ts @@ -47,8 +47,7 @@ export const loader = createLoaderApiRoute( findResource: async () => 1, authorization: { action: "read", - resource: () => ({ query: "schema" }), - superScopes: ["read:query", "read:all", "admin"], + resource: () => ({ type: "query", id: "schema" }), }, }, async () => { diff --git a/apps/webapp/app/routes/api.v1.query.ts b/apps/webapp/app/routes/api.v1.query.ts index 05d92e9726a..3fb6b04ec78 100644 --- a/apps/webapp/app/routes/api.v1.query.ts +++ b/apps/webapp/app/routes/api.v1.query.ts @@ -1,7 +1,10 @@ import { json } from "@remix-run/server-runtime"; import { QueryError } from "@internal/clickhouse"; import { z } from "zod"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + createActionApiRoute, + everyResource, +} from "~/services/routeBuilders/apiBuilder.server"; import { executeQuery, type QueryScope } from "~/services/queryService.server"; import { logger } from "~/services/logger.server"; import { rowsToCSV } from "~/utils/dataExport"; @@ -34,11 +37,16 @@ const { action, loader } = createActionApiRoute( findResource: async () => 1, authorization: { action: "read", + // A multi-table query reads from every detected table. Wrap with + // everyResource so a JWT scoped to one table can't pass auth for + // a query that also reads tables it isn't scoped to (would be the + // same OR-loophole the batch trigger route had pre-fix). resource: (_, __, ___, body) => { const tables = detectTables(body.query); - return { query: tables.length > 0 ? tables : "all" }; + return tables.length > 0 + ? everyResource(tables.map((id) => ({ type: "query", id }))) + : { type: "query", id: "all" }; }, - superScopes: ["read:query", "read:all", "admin"], }, }, async ({ body, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.events.ts b/apps/webapp/app/routes/api.v1.runs.$runId.events.ts index ac96c9ddb81..6e48288e958 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.events.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.events.ts @@ -1,7 +1,10 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server"; import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server"; @@ -21,13 +24,17 @@ export const loader = createLoaderApiRoute( shouldRetryNotFound: true, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batch?.friendlyId, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batch?.friendlyId) { + resources.push({ type: "batch", id: run.batch.friendlyId }); + } + return anyResource(resources); + }, }, }, async ({ resource: run, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts b/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts index 7c093efd960..a123b1522b7 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.spans.$spanId.ts @@ -3,7 +3,10 @@ import { BatchId } from "@trigger.dev/core/v3/isomorphic"; import { z } from "zod"; import { $replica } from "~/db.server"; import { extractAISpanData } from "~/components/runs/v3/ai"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server"; import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server"; @@ -28,13 +31,17 @@ export const loader = createLoaderApiRoute( shouldRetryNotFound: true, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batchId ? BatchId.toFriendlyId(run.batchId) : undefined, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batchId) { + resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) }); + } + return anyResource(resources); + }, }, }, async ({ params, resource: run, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts b/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts index cc35836bfe6..aba85259fbc 100644 --- a/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts +++ b/apps/webapp/app/routes/api.v1.runs.$runId.trace.ts @@ -2,7 +2,10 @@ import { json } from "@remix-run/server-runtime"; import { BatchId } from "@trigger.dev/core/v3/isomorphic"; import { z } from "zod"; import { $replica } from "~/db.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { resolveEventRepositoryForStore } from "~/v3/eventRepository/index.server"; import { getTaskEventStoreTableForRun } from "~/v3/taskEventStore.server"; @@ -26,13 +29,17 @@ export const loader = createLoaderApiRoute( shouldRetryNotFound: true, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batchId ? BatchId.toFriendlyId(run.batchId) : undefined, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batchId) { + resources.push({ type: "batch", id: BatchId.toFriendlyId(run.batchId) }); + } + return anyResource(resources); + }, }, }, async ({ resource: run, authentication }) => { diff --git a/apps/webapp/app/routes/api.v1.runs.ts b/apps/webapp/app/routes/api.v1.runs.ts index b5191ee2591..4cbd689f627 100644 --- a/apps/webapp/app/routes/api.v1.runs.ts +++ b/apps/webapp/app/routes/api.v1.runs.ts @@ -4,7 +4,10 @@ import { ApiRunListSearchParams, } from "~/presenters/v3/ApiRunListPresenter.server"; import { logger } from "~/services/logger.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; export const loader = createLoaderApiRoute( { @@ -13,8 +16,24 @@ export const loader = createLoaderApiRoute( corsStrategy: "all", authorization: { action: "read", - resource: (_, __, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (_, __, searchParams) => { + const taskFilter = searchParams["filter[taskIdentifier]"] ?? []; + // Pre-RBAC, the resource was `{ tasks: searchParams["filter[taskIdentifier]"] }` + // and the legacy `checkAuthorization` iterated `Object.keys` — so a + // JWT with type-level `read:tasks` (no id) granted access to the + // unfiltered runs list. The new ability model only matches against + // resources we list, so the type-level `{ type: "tasks" }` element + // (alongside `{ type: "runs" }` and the per-id task elements) + // preserves that semantic — `read:tasks` JWTs in the wild still + // list unfiltered runs without needing a separate `read:runs` + // scope. Per-id `read:tasks:foo` still grants only when the + // filter includes `foo`. + return anyResource([ + { type: "runs" }, + { type: "tasks" }, + ...taskFilter.map((id) => ({ type: "tasks", id })), + ]); + }, }, findResource: async () => 1, // This is a dummy function, we don't need to find a resource }, diff --git a/apps/webapp/app/routes/api.v1.sessions.$session.close.ts b/apps/webapp/app/routes/api.v1.sessions.$session.close.ts index 16d8a6d93d1..15c2e8dc6bd 100644 --- a/apps/webapp/app/routes/api.v1.sessions.$session.close.ts +++ b/apps/webapp/app/routes/api.v1.sessions.$session.close.ts @@ -25,8 +25,7 @@ const { action, loader } = createActionApiRoute( corsStrategy: "all", authorization: { action: "admin", - resource: (params) => ({ sessions: params.session }), - superScopes: ["admin:sessions", "admin:all", "admin"], + resource: (params) => ({ type: "sessions", id: params.session }), }, }, async ({ authentication, params, body }) => { diff --git a/apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts b/apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts index cdc9c9e8dc7..7c5718aeae3 100644 --- a/apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts +++ b/apps/webapp/app/routes/api.v1.sessions.$session.end-and-continue.ts @@ -8,7 +8,10 @@ import { $replica, prisma } from "~/db.server"; import { logger } from "~/services/logger.server"; import { swapSessionRun } from "~/services/realtime/sessionRunManager.server"; import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createActionApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ session: z.string(), @@ -42,15 +45,18 @@ const { action, loader } = createActionApiRoute( resolveSessionByIdOrExternalId($replica, auth.environment.id, params.session), authorization: { action: "write", + // Multi-key: the session is addressable by URL param, friendlyId, + // and externalId — a JWT scoped to any of them grants access. + // Type-level `write:sessions` (no id) also matches; `write:all` / + // `admin` bypass via the JWT ability's wildcard branches. resource: (params, _, __, ___, session) => { const ids = new Set([params.session]); if (session) { ids.add(session.friendlyId); if (session.externalId) ids.add(session.externalId); } - return { sessions: [...ids] }; + return anyResource([...ids].map((id) => ({ type: "sessions", id }))); }, - superScopes: ["write:sessions", "write:all", "admin"], }, }, async ({ authentication, params, body, resource: session }) => { diff --git a/apps/webapp/app/routes/api.v1.sessions.$session.ts b/apps/webapp/app/routes/api.v1.sessions.$session.ts index 800ee32b99b..9b6fb339989 100644 --- a/apps/webapp/app/routes/api.v1.sessions.$session.ts +++ b/apps/webapp/app/routes/api.v1.sessions.$session.ts @@ -11,6 +11,7 @@ import { serializeSessionWithFriendlyRunId, } from "~/services/realtime/sessions.server"; import { + anyResource, createActionApiRoute, createLoaderApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; @@ -29,8 +30,17 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (session) => ({ sessions: [session.friendlyId, session.externalId ?? ""] }), - superScopes: ["read:sessions", "read:all", "admin"], + // Multi-key: a session is addressable by both friendlyId and (when + // set) externalId. A JWT scoped to either id grants access; type- + // level `read:sessions` (no id) matches both elements; `read:all` + // / `admin` bypass via the JWT ability's wildcard branches. + resource: (session) => + session.externalId + ? anyResource([ + { type: "sessions", id: session.friendlyId }, + { type: "sessions", id: session.externalId }, + ]) + : { type: "sessions", id: session.friendlyId }, }, }, async ({ resource: session }) => { @@ -50,8 +60,7 @@ const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "admin", - resource: (params) => ({ sessions: params.session }), - superScopes: ["admin:sessions", "admin:all", "admin"], + resource: (params) => ({ type: "sessions", id: params.session }), }, }, async ({ authentication, params, body }) => { diff --git a/apps/webapp/app/routes/api.v1.sessions.ts b/apps/webapp/app/routes/api.v1.sessions.ts index eafb0f7a20c..591a9fe5319 100644 --- a/apps/webapp/app/routes/api.v1.sessions.ts +++ b/apps/webapp/app/routes/api.v1.sessions.ts @@ -20,6 +20,7 @@ import { import { serializeSession } from "~/services/realtime/sessions.server"; import { SessionsRepository } from "~/services/sessionsRepository/sessionsRepository.server"; import { + anyResource, createActionApiRoute, createLoaderApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; @@ -37,8 +38,21 @@ export const loader = createLoaderApiRoute( corsStrategy: "all", authorization: { action: "read", - resource: (_, __, searchParams) => ({ tasks: searchParams["filter[taskIdentifier]"] }), - superScopes: ["read:sessions", "read:all", "admin"], + // Multi-key resource preserves the pre-RBAC superScope semantics: + // - Per-task scoping via `read:tasks:` matches a task element + // - Type-level `read:sessions` (the old superScope) matches the + // sessions element (collection-level — no id) + // - `read:all` / `admin` bypass via the JWT ability's wildcard branches + // The taskIdentifier filter accepts a string or an array; expand to + // one resource per task id so any per-task-scoped JWT among them + // grants access (the array gets OR semantics). + resource: (_, __, searchParams) => { + const taskFilter = asArray(searchParams["filter[taskIdentifier]"]) ?? []; + return anyResource([ + ...taskFilter.map((id) => ({ type: "tasks" as const, id })), + { type: "sessions" as const }, + ]); + }, }, findResource: async () => 1, }, @@ -113,21 +127,20 @@ const { action } = createActionApiRoute( // Per-task scoping via `body.taskIdentifier` (action-route resource // callbacks receive the parsed body as the 4th arg — see // `apiBuilder.server.ts:710`). A JWT scoped only to `write:tasks:foo` - // can only create sessions whose `taskIdentifier` is `"foo"`. Broad - // callers (cli-v3 MCP, customer servers wrapping their own auth) - // hold the `write:sessions` super-scope and bypass the per-task - // check entirely. + // can only create sessions whose `taskIdentifier` is `"foo"`. // - // Note: the auth check is OR across resource types, so listing both - // `sessions` and `tasks` here would let a `write:sessions`-scoped - // JWT pass for *any* task — defeating the per-task narrowing. Keep - // it task-only and let the super-scope path handle session-level - // wildcard access. + // Multi-key resource: pre-RBAC this route had a `superScopes: + // ["write:sessions", "admin"]` whitelist; post-RBAC the equivalent + // is the `{ type: "sessions" }` element below — a `write:sessions` + // JWT (no id) matches it directly, deliberately bypassing the + // per-task check exactly as before. `admin` / `write:all` bypass + // via the JWT ability's wildcard branches. action: "write", - resource: (_params, _searchParams, _headers, body) => ({ - tasks: body.taskIdentifier, - }), - superScopes: ["write:sessions", "admin"], + resource: (_params, _searchParams, _headers, body) => + anyResource([ + { type: "tasks", id: body.taskIdentifier }, + { type: "sessions" }, + ]), }, corsStrategy: "all", }, diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts index e39f4b3cc8f..ee1ed0393ca 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts @@ -51,8 +51,7 @@ const { action, loader } = createActionApiRoute( maxContentLength: env.TASK_PAYLOAD_MAXIMUM_SIZE, authorization: { action: "trigger", - resource: (params) => ({ tasks: params.taskId }), - superScopes: ["write:tasks", "admin"], + resource: (params) => ({ type: "tasks", id: params.taskId }), }, corsStrategy: "all", }, diff --git a/apps/webapp/app/routes/api.v1.tasks.batch.ts b/apps/webapp/app/routes/api.v1.tasks.batch.ts index 50760b79a6d..16b3ef16062 100644 --- a/apps/webapp/app/routes/api.v1.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v1.tasks.batch.ts @@ -7,7 +7,10 @@ import { import { env } from "~/env.server"; import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + createActionApiRoute, + everyResource, +} from "~/services/routeBuilders/apiBuilder.server"; import { resolveIdempotencyKeyTTL } from "~/utils/idempotencyKeys.server"; import { ServiceValidationError } from "~/v3/services/baseService.server"; import { @@ -30,10 +33,17 @@ const { action, loader } = createActionApiRoute( maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE, authorization: { action: "batchTrigger", - resource: (_, __, ___, body) => ({ - tasks: Array.from(new Set(body.items.map((i) => i.task))), - }), - superScopes: ["write:tasks", "admin"], + // Each item in the batch is a distinct task — every one must be + // authorized, not just any one of them. `everyResource` flips + // the auth check to AND semantics so a JWT scoped to taskA can't + // submit a batch that also includes taskB / taskC. + resource: (_, __, ___, body) => + everyResource( + Array.from(new Set(body.items.map((i) => i.task))).map((id) => ({ + type: "tasks", + id, + })) + ), }, corsStrategy: "all", }, diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.callback.$hash.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.callback.$hash.ts index e8a7f046edb..d86feff7b91 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.callback.$hash.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.callback.$hash.ts @@ -45,6 +45,7 @@ export async function action({ request, params }: ActionFunctionArgs) { orgMember: true, parentEnvironment: { select: { + id: true, apiKey: true, }, }, diff --git a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts index 133b6bc55fb..4a3e5f960c6 100644 --- a/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts +++ b/apps/webapp/app/routes/api.v1.waitpoints.tokens.$waitpointFriendlyId.complete.ts @@ -23,8 +23,7 @@ const { action, loader } = createActionApiRoute( allowJWT: true, authorization: { action: "write", - resource: (params) => ({ waitpoints: params.waitpointFriendlyId }), - superScopes: ["write:waitpoints", "admin"], + resource: (params) => ({ type: "waitpoints", id: params.waitpointFriendlyId }), }, corsStrategy: "all", }, diff --git a/apps/webapp/app/routes/api.v2.batches.$batchId.ts b/apps/webapp/app/routes/api.v2.batches.$batchId.ts index c89dbbaf312..a4a3027cfb7 100644 --- a/apps/webapp/app/routes/api.v2.batches.$batchId.ts +++ b/apps/webapp/app/routes/api.v2.batches.$batchId.ts @@ -1,7 +1,7 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { $replica } from "~/db.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ batchId: z.string(), @@ -25,8 +25,13 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (batch) => ({ batch: batch.friendlyId }), - superScopes: ["read:runs", "read:all", "admin"], + // See sibling note in api.v1.batches.$batchId.ts — `{type: "runs"}` + // preserves pre-RBAC `read:runs` superScope access for batch reads. + resource: (batch) => + anyResource([ + { type: "batch", id: batch.friendlyId }, + { type: "runs" }, + ]), }, }, async ({ resource: batch }) => { diff --git a/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts b/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts index a05af273d8d..a636ca0cc1d 100644 --- a/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts +++ b/apps/webapp/app/routes/api.v2.runs.$runParam.cancel.ts @@ -15,8 +15,7 @@ const { action } = createActionApiRoute( corsStrategy: "none", authorization: { action: "write", - resource: (params) => ({ runs: params.runParam }), - superScopes: ["write:runs", "admin"], + resource: (params) => ({ type: "runs", id: params.runParam }), }, findResource: async (params, auth) => { return $replica.taskRun.findFirst({ diff --git a/apps/webapp/app/routes/api.v2.tasks.batch.ts b/apps/webapp/app/routes/api.v2.tasks.batch.ts index e45f7508b90..8b2be6e3ca5 100644 --- a/apps/webapp/app/routes/api.v2.tasks.batch.ts +++ b/apps/webapp/app/routes/api.v2.tasks.batch.ts @@ -9,7 +9,10 @@ import { env } from "~/env.server"; import { RunEngineBatchTriggerService } from "~/runEngine/services/batchTrigger.server"; import { AuthenticatedEnvironment, getOneTimeUseToken } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + createActionApiRoute, + everyResource, +} from "~/services/routeBuilders/apiBuilder.server"; import { handleRequestIdempotency, saveRequestIdempotency, @@ -32,10 +35,17 @@ const { action, loader } = createActionApiRoute( maxContentLength: env.BATCH_TASK_PAYLOAD_MAXIMUM_SIZE, authorization: { action: "batchTrigger", - resource: (_, __, ___, body) => ({ - tasks: Array.from(new Set(body.items.map((i) => i.task))), - }), - superScopes: ["write:tasks", "admin"], + // Each item in the batch is a distinct task — every one must be + // authorized, not just any one of them. `everyResource` flips + // the auth check to AND semantics so a JWT scoped to taskA can't + // submit a batch that also includes taskB / taskC. + resource: (_, __, ___, body) => + everyResource( + Array.from(new Set(body.items.map((i) => i.task))).map((id) => ({ + type: "tasks", + id, + })) + ), }, corsStrategy: "all", }, diff --git a/apps/webapp/app/routes/api.v3.batches.ts b/apps/webapp/app/routes/api.v3.batches.ts index b671a8efbd6..f4227106765 100644 --- a/apps/webapp/app/routes/api.v3.batches.ts +++ b/apps/webapp/app/routes/api.v3.batches.ts @@ -35,12 +35,9 @@ const { action, loader } = createActionApiRoute( maxContentLength: 131_072, // 128KB is plenty for the batch metadata authorization: { action: "batchTrigger", - resource: () => ({ - // No specific tasks to authorize at batch creation time - // Tasks are validated when items are streamed - tasks: [], - }), - superScopes: ["write:tasks", "admin"], + // No specific tasks to authorize at batch creation time — tasks are + // validated when items are streamed. Collection-level check. + resource: () => ({ type: "tasks" }), }, corsStrategy: "all", }, diff --git a/apps/webapp/app/routes/api.v3.runs.$runId.ts b/apps/webapp/app/routes/api.v3.runs.$runId.ts index de40a9a9120..00ea7102580 100644 --- a/apps/webapp/app/routes/api.v3.runs.$runId.ts +++ b/apps/webapp/app/routes/api.v3.runs.$runId.ts @@ -1,7 +1,10 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ runId: z.string(), @@ -18,13 +21,17 @@ export const loader = createLoaderApiRoute( shouldRetryNotFound: true, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batch?.friendlyId, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batch?.friendlyId) { + resources.push({ type: "batch", id: run.batch.friendlyId }); + } + return anyResource(resources); + }, }, }, async ({ authentication, resource, apiVersion }) => { diff --git a/apps/webapp/app/routes/engine.v1.dev.runs.$runFriendlyId.snapshots.$snapshotFriendlyId.attempts.start.ts b/apps/webapp/app/routes/engine.v1.dev.runs.$runFriendlyId.snapshots.$snapshotFriendlyId.attempts.start.ts index 0c88cc45f61..a3f35013b78 100644 --- a/apps/webapp/app/routes/engine.v1.dev.runs.$runFriendlyId.snapshots.$snapshotFriendlyId.attempts.start.ts +++ b/apps/webapp/app/routes/engine.v1.dev.runs.$runFriendlyId.snapshots.$snapshotFriendlyId.attempts.start.ts @@ -5,7 +5,7 @@ import { WorkerApiRunAttemptStartRequestBody, WorkerApiRunAttemptStartResponseBody, } from "@trigger.dev/core/v3/workers"; -import { RuntimeEnvironment } from "@trigger.dev/database"; +import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; import { defaultMachine } from "~/services/platform.v3.server"; import { z } from "zod"; import { prisma } from "~/db.server"; @@ -76,7 +76,7 @@ const { action } = createActionApiRoute( ); async function getEnvVars( - environment: RuntimeEnvironment, + environment: AuthenticatedEnvironment, runId: string, machinePreset: MachinePreset, taskEventStore?: string diff --git a/apps/webapp/app/routes/invite-resend.tsx b/apps/webapp/app/routes/invite-resend.tsx index dc66e898517..5dc285b944f 100644 --- a/apps/webapp/app/routes/invite-resend.tsx +++ b/apps/webapp/app/routes/invite-resend.tsx @@ -4,7 +4,7 @@ import { env } from "process"; import { z } from "zod"; import { resendInvite } from "~/models/member.server"; import { redirectWithSuccessMessage } from "~/models/message.server"; -import { scheduleEmail } from "~/services/email.server"; +import { scheduleEmail } from "~/services/scheduleEmail.server"; import { requireUserId } from "~/services/session.server"; import { acceptInvitePath, organizationTeamPath } from "~/utils/pathBuilder"; diff --git a/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts b/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts index 33449deebca..2b8fb106681 100644 --- a/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts +++ b/apps/webapp/app/routes/realtime.v1.batches.$batchId.ts @@ -2,7 +2,7 @@ import { z } from "zod"; import { $replica } from "~/db.server"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { realtimeClient } from "~/services/realtimeClientGlobal.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ batchId: z.string(), @@ -23,8 +23,13 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (batch) => ({ batch: batch.friendlyId }), - superScopes: ["read:runs", "read:all", "admin"], + // See sibling note in api.v1.batches.$batchId.ts — `{type: "runs"}` + // preserves pre-RBAC `read:runs` superScope access for batch reads. + resource: (batch) => + anyResource([ + { type: "batch", id: batch.friendlyId }, + { type: "runs" }, + ]), }, }, async ({ authentication, request, resource: batchRun, apiVersion }) => { diff --git a/apps/webapp/app/routes/realtime.v1.runs.$runId.ts b/apps/webapp/app/routes/realtime.v1.runs.$runId.ts index 060f937b0eb..e03787c6200 100644 --- a/apps/webapp/app/routes/realtime.v1.runs.$runId.ts +++ b/apps/webapp/app/routes/realtime.v1.runs.$runId.ts @@ -3,7 +3,10 @@ import { z } from "zod"; import { $replica } from "~/db.server"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { realtimeClient } from "~/services/realtimeClientGlobal.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; const ParamsSchema = z.object({ runId: z.string(), @@ -31,13 +34,17 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batch?.friendlyId, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batch?.friendlyId) { + resources.push({ type: "batch", id: run.batch.friendlyId }); + } + return anyResource(resources); + }, }, }, async ({ authentication, request, resource: run, apiVersion }) => { diff --git a/apps/webapp/app/routes/realtime.v1.runs.ts b/apps/webapp/app/routes/realtime.v1.runs.ts index 18eeeb0a075..b04c2d55bbc 100644 --- a/apps/webapp/app/routes/realtime.v1.runs.ts +++ b/apps/webapp/app/routes/realtime.v1.runs.ts @@ -1,7 +1,10 @@ import { z } from "zod"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { realtimeClient } from "~/services/realtimeClientGlobal.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; const SearchParamsSchema = z.object({ tags: z @@ -21,8 +24,18 @@ export const loader = createLoaderApiRoute( findResource: async () => 1, // This is a dummy value, it's not used authorization: { action: "read", - resource: (_, __, searchParams) => searchParams, - superScopes: ["read:runs", "read:all", "admin"], + resource: (_, __, searchParams) => + // Pre-RBAC, the resource was the searchParams object itself and + // the legacy `checkAuthorization` iterated `Object.keys`, so a + // JWT with type-level `read:tags` (no id) granted access to the + // unfiltered runs stream. Including `{ type: "tags" }` here + // preserves that — per-id `read:tags:` still grants only + // when the filter includes that tag. + anyResource([ + { type: "runs" }, + { type: "tags" }, + ...(searchParams.tags ?? []).map((tag) => ({ type: "tags", id: tag })), + ]), }, }, async ({ searchParams, authentication, request, apiVersion }) => { diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts index a21b5202317..792b6480d8d 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts @@ -12,7 +12,10 @@ import { } from "~/services/realtime/sessions.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; import { drainSessionStreamWaitpoints } from "~/services/sessionStreamWaitpointCache.server"; -import { createActionApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createActionApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { engine } from "~/v3/runEngine.server"; import { ServiceValidationError } from "~/v3/services/common.server"; @@ -49,15 +52,16 @@ const { action, loader } = createActionApiRoute( action: "write", // Authorize against the union of the URL form, friendlyId, and // externalId so a JWT scoped to any form authorizes any URL. + // Type-level `write:sessions` (no id) also matches; `write:all` / + // `admin` bypass via the JWT ability's wildcard branches. resource: (params, _, __, ___, session) => { const ids = new Set([params.session]); if (session) { ids.add(session.friendlyId); if (session.externalId) ids.add(session.externalId); } - return { sessions: [...ids] }; + return anyResource([...ids].map((id) => ({ type: "sessions", id }))); }, - superScopes: ["write:sessions", "write:all", "admin"], }, }, async ({ request, params, authentication, resource: session }) => { diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts index 37ec58c51ae..562b19fad99 100644 --- a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts @@ -10,6 +10,7 @@ import { } from "~/services/realtime/sessions.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; import { + anyResource, createActionApiRoute, createLoaderApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; @@ -30,8 +31,7 @@ const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "write", - resource: (params) => ({ sessions: params.session }), - superScopes: ["write:sessions", "write:all", "admin"], + resource: (params) => ({ type: "sessions", id: params.session }), }, }, async ({ params, authentication }) => { @@ -116,15 +116,18 @@ const loader = createLoaderApiRoute( }, authorization: { action: "read", + // Multi-key: the channel is addressable by the URL key, the row's + // friendlyId, and (if set) externalId. Type-level `read:sessions` + // matches any of them; `read:all` / `admin` bypass via the JWT + // ability's wildcard branches. resource: ({ row, addressingKey }) => { const ids = new Set([addressingKey]); if (row) { ids.add(row.friendlyId); if (row.externalId) ids.add(row.externalId); } - return { sessions: [...ids] }; + return anyResource([...ids].map((id) => ({ type: "sessions", id }))); }, - superScopes: ["read:sessions", "read:all", "admin"], }, }, async ({ params, request, authentication, resource }) => { diff --git a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts index 477ce781a20..39935b9de1f 100644 --- a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts +++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts @@ -3,7 +3,10 @@ import { z } from "zod"; import { $replica } from "~/db.server"; import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; -import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; +import { + anyResource, + createLoaderApiRoute, +} from "~/services/routeBuilders/apiBuilder.server"; import { AuthenticatedEnvironment } from "~/services/apiAuth.server"; const ParamsSchema = z.object({ @@ -89,7 +92,13 @@ export const loader = createLoaderApiRoute( friendlyId: params.runId, runtimeEnvironmentId: auth.environment.id, }, - include: { + select: { + id: true, + friendlyId: true, + taskIdentifier: true, + runTags: true, + realtimeStreamsVersion: true, + streamBasinName: true, batch: { select: { friendlyId: true, @@ -100,13 +109,17 @@ export const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batch?.friendlyId, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batch?.friendlyId) { + resources.push({ type: "batch", id: run.batch.friendlyId }); + } + return anyResource(resources); + }, }, }, async ({ params, request, resource: run, authentication }) => { diff --git a/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts b/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts index 089f2dc55e3..335116043d1 100644 --- a/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts +++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.input.$streamId.ts @@ -7,6 +7,7 @@ import { deleteInputStreamWaitpoint, } from "~/services/inputStreamWaitpointCache.server"; import { + anyResource, createActionApiRoute, createLoaderApiRoute, } from "~/services/routeBuilders/apiBuilder.server"; @@ -31,8 +32,7 @@ const { action } = createActionApiRoute( corsStrategy: "all", authorization: { action: "write", - resource: (params) => ({ inputStreams: params.runId }), - superScopes: ["write:inputStreams", "write:all", "admin"], + resource: (params) => ({ type: "inputStreams", id: params.runId }), }, }, async ({ request, params, authentication }) => { @@ -127,13 +127,17 @@ const loader = createLoaderApiRoute( }, authorization: { action: "read", - resource: (run) => ({ - runs: run.friendlyId, - tags: run.runTags, - batch: run.batch?.friendlyId, - tasks: run.taskIdentifier, - }), - superScopes: ["read:runs", "read:all", "admin"], + resource: (run) => { + const resources = [ + { type: "runs", id: run.friendlyId }, + { type: "tasks", id: run.taskIdentifier }, + ...run.runTags.map((tag) => ({ type: "tags", id: tag })), + ]; + if (run.batch?.friendlyId) { + resources.push({ type: "batch", id: run.batch.friendlyId }); + } + return anyResource(resources); + }, }, }, async ({ params, request, resource: run, authentication }) => { diff --git a/apps/webapp/app/runEngine/concerns/batchLimits.server.ts b/apps/webapp/app/runEngine/concerns/batchLimits.server.ts index f40088039eb..8cd7bf72c6a 100644 --- a/apps/webapp/app/runEngine/concerns/batchLimits.server.ts +++ b/apps/webapp/app/runEngine/concerns/batchLimits.server.ts @@ -32,7 +32,16 @@ function createBatchLimitsRedisClient() { return redisClient; } -function createOrganizationRateLimiter(organization: Organization): RateLimiter { +// Just the org fields this module reads. Compatible with both the full +// Prisma `Organization` payload and the slim `AuthenticatedEnvironment` +// `["organization"]` shape (when passed `batchRateLimitConfig` / +// `batchQueueConcurrencyConfig` as `unknown`). +type OrganizationForBatchLimits = { + batchRateLimitConfig?: unknown; + batchQueueConcurrencyConfig?: unknown; +}; + +function createOrganizationRateLimiter(organization: OrganizationForBatchLimits): RateLimiter { const limiterConfig = resolveBatchRateLimitConfig(organization.batchRateLimitConfig); const limiter = createLimiterFromConfig(limiterConfig); @@ -72,7 +81,7 @@ function resolveBatchRateLimitConfig(batchRateLimitConfig?: unknown): RateLimite * Internally looks up the plan type, but doesn't expose it to callers. */ export async function getBatchLimits( - organization: Organization + organization: OrganizationForBatchLimits ): Promise<{ rateLimiter: RateLimiter; config: BatchLimitsConfig }> { const rateLimiter = createOrganizationRateLimiter(organization); const config = resolveBatchLimitsConfig(organization.batchQueueConcurrencyConfig); diff --git a/apps/webapp/app/services/apiAuth.server.ts b/apps/webapp/app/services/apiAuth.server.ts index 611953efc4f..915311c07c1 100644 --- a/apps/webapp/app/services/apiAuth.server.ts +++ b/apps/webapp/app/services/apiAuth.server.ts @@ -1,5 +1,4 @@ import { json } from "@remix-run/server-runtime"; -import { type Prettify } from "@trigger.dev/core"; import { SignJWT, errors, jwtVerify } from "jose"; import { z } from "zod"; @@ -7,8 +6,11 @@ import { $replica } from "~/db.server"; import { env } from "~/env.server"; import { findProjectByRef } from "~/models/project.server"; import { + authIncludeBase, + authIncludeWithParent, findEnvironmentByApiKey, findEnvironmentByPublicApiKey, + toAuthenticated, } from "~/models/runtimeEnvironment.server"; import { type RuntimeEnvironmentForEnvRepo } from "~/v3/environmentVariables/environmentVariablesRepository.server"; import { logger } from "./logger.server"; @@ -23,7 +25,7 @@ import { isOrganizationAccessToken, } from "./organizationAccessToken.server"; import { isPublicJWT, validatePublicJwtKey } from "./realtime/jwtAuth.server"; -import { sanitizeBranchName } from "~/v3/gitBranch"; +import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; const ClaimsSchema = z.object({ scopes: z.array(z.string()).optional(), @@ -36,12 +38,10 @@ const ClaimsSchema = z.object({ .optional(), }); -type Optional = Prettify & Partial>>; - -export type AuthenticatedEnvironment = Optional< - NonNullable>>, - "orgMember" ->; +// Re-export the slim shape defined in @trigger.dev/core. Single source of +// truth across the auth boundary (RBAC plugin contract → webapp handlers). +export type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; +import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; export type ApiAuthenticationResult = | ApiAuthenticationResultSuccess @@ -52,7 +52,6 @@ export type ApiAuthenticationResultSuccess = { apiKey: string; type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT"; environment: AuthenticatedEnvironment; - scopes?: string[]; oneTimeUse?: boolean; realtime?: { skipColumns?: string[]; @@ -163,7 +162,6 @@ export async function authenticateApiKey( ok: true, ...result, environment: validationResults.environment, - scopes: parsedClaims.success ? parsedClaims.data.scopes : [], oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false, realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined, }; @@ -246,7 +244,6 @@ async function authenticateApiKeyWithFailure( ok: true, ...result, environment: validationResults.environment, - scopes: parsedClaims.success ? parsedClaims.data.scopes : [], oneTimeUse: parsedClaims.success ? parsedClaims.data.otu : false, realtime: parsedClaims.success ? parsedClaims.data.realtime : undefined, }; @@ -510,17 +507,14 @@ export async function authenticatedEnvironmentForAuthentication( } : {}), }, - include: { - project: true, - organization: true, - }, + include: authIncludeBase, }); if (!environment) { throw json({ error: "Environment not found" }, { status: 404 }); } - return environment; + return toAuthenticated(environment); } const environment = await $replica.runtimeEnvironment.findFirst({ @@ -530,11 +524,7 @@ export async function authenticatedEnvironmentForAuthentication( branchName: sanitizedBranch, archivedAt: null, }, - include: { - project: true, - organization: true, - parentEnvironment: true, - }, + include: authIncludeWithParent, }); if (!environment) { @@ -545,12 +535,13 @@ export async function authenticatedEnvironmentForAuthentication( throw json({ error: "Branch not associated with a preview environment" }, { status: 400 }); } - return { + // PREVIEW envs reuse the parent's apiKey for downstream auth flows + // (signed JWTs, internal-fetch helpers). Override before mapping so + // the slim shape carries the parent's key. + return toAuthenticated({ ...environment, apiKey: environment.parentEnvironment.apiKey, - organization: environment.organization, - project: environment.project, - }; + }); } case "organizationAccessToken": { const organization = await $replica.organization.findUnique({ @@ -582,17 +573,14 @@ export async function authenticatedEnvironmentForAuthentication( projectId: project.id, slug: slug, }, - include: { - project: true, - organization: true, - }, + include: authIncludeBase, }); if (!environment) { throw json({ error: "Environment not found" }, { status: 404 }); } - return environment; + return toAuthenticated(environment); } const environment = await $replica.runtimeEnvironment.findFirst({ @@ -602,11 +590,7 @@ export async function authenticatedEnvironmentForAuthentication( branchName: sanitizedBranch, archivedAt: null, }, - include: { - project: true, - organization: true, - parentEnvironment: true, - }, + include: authIncludeWithParent, }); if (!environment) { @@ -617,12 +601,10 @@ export async function authenticatedEnvironmentForAuthentication( throw json({ error: "Branch not associated with a preview environment" }, { status: 400 }); } - return { + return toAuthenticated({ ...environment, apiKey: environment.parentEnvironment.apiKey, - organization: environment.organization, - project: environment.project, - }; + }); } default: { auth satisfies never; diff --git a/apps/webapp/app/services/authorization.server.ts b/apps/webapp/app/services/authorization.server.ts deleted file mode 100644 index 786cc161ed9..00000000000 --- a/apps/webapp/app/services/authorization.server.ts +++ /dev/null @@ -1,113 +0,0 @@ -export type AuthorizationAction = "read" | "write" | string; // Add more actions as needed - -const ResourceTypes = ["tasks", "tags", "runs", "batch", "waitpoints", "deployments", "inputStreams", "query", "prompts", "sessions"] as const; - -export type AuthorizationResources = { - [key in (typeof ResourceTypes)[number]]?: string | string[]; -}; - -export type AuthorizationEntity = { - type: "PUBLIC" | "PRIVATE" | "PUBLIC_JWT"; - scopes?: string[]; -}; - -/** - * Checks if the given entity is authorized to perform a specific action on a resource. - * - * @param entity - The entity requesting authorization. - * @param action - The action the entity wants to perform. - * @param resource - The resource on which the action is to be performed. - * @param superScopes - An array of super scopes that can bypass the normal authorization checks. - * - * @example - * - * ```typescript - * import { checkAuthorization } from "./authorization.server"; - * - * const entity = { - * type: "PUBLIC", - * scope: ["read:runs:run_1234", "read:tasks"] - * }; - * - * checkAuthorization(entity, "read", { runs: "run_1234" }); // Returns true - * checkAuthorization(entity, "read", { runs: "run_5678" }); // Returns false - * checkAuthorization(entity, "read", { tasks: "task_1234" }); // Returns true - * checkAuthorization(entity, "read", { tasks: ["task_5678"] }); // Returns true - * ``` - */ -export type AuthorizationResult = { authorized: true } | { authorized: false; reason: string }; - -/** - * Checks if the given entity is authorized to perform a specific action on a resource. - */ -export function checkAuthorization( - entity: AuthorizationEntity, - action: AuthorizationAction, - resource: AuthorizationResources, - superScopes?: string[] -): AuthorizationResult { - // "PRIVATE" is a secret key and has access to everything - if (entity.type === "PRIVATE") { - return { authorized: true }; - } - - // "PUBLIC" is a deprecated key and has no access - if (entity.type === "PUBLIC") { - return { authorized: false, reason: "PUBLIC type is deprecated and has no access" }; - } - - // If the entity has no permissions, deny access - if (!entity.scopes || entity.scopes.length === 0) { - return { - authorized: false, - reason: - "Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information.", - }; - } - - // If the resource object is empty, deny access - if (Object.keys(resource).length === 0) { - return { authorized: false, reason: "Resource object is empty" }; - } - - // Check for any of the super scopes - if (superScopes && superScopes.length > 0) { - if (superScopes.some((permission) => entity.scopes?.includes(permission))) { - return { authorized: true }; - } - } - - const filteredResource = Object.keys(resource).reduce((acc, key) => { - if (ResourceTypes.includes(key)) { - acc[key as keyof AuthorizationResources] = resource[key as keyof AuthorizationResources]; - } - return acc; - }, {} as AuthorizationResources); - - // Check each resource type - for (const [resourceType, resourceValue] of Object.entries(filteredResource)) { - const resourceValues = Array.isArray(resourceValue) ? resourceValue : [resourceValue]; - - for (const value of resourceValues) { - // Check for specific resource permission - const specificPermission = `${action}:${resourceType}:${value}`; - // Check for general resource type permission - const generalPermission = `${action}:${resourceType}`; - - // If any permission matches, return authorized - if (entity.scopes.includes(specificPermission) || entity.scopes.includes(generalPermission)) { - return { authorized: true }; - } - } - } - - // No matching permissions found - return { - authorized: false, - reason: `Public Access Token is missing required permissions. Token has the following permissions: ${entity.scopes - .map((s) => `'${s}'`) - .join( - ", " - )}. See https://trigger.dev/docs/frontend/overview#authentication for more information.`, - }; -} diff --git a/apps/webapp/app/services/email.server.ts b/apps/webapp/app/services/email.server.ts index 290addcdc97..17fba0c52e1 100644 --- a/apps/webapp/app/services/email.server.ts +++ b/apps/webapp/app/services/email.server.ts @@ -4,7 +4,6 @@ import type { SendEmailOptions } from "remix-auth-email-link"; import { redirect } from "remix-typedjson"; import { env } from "~/env.server"; import type { AuthUser } from "./authUser"; -import { commonWorker } from "~/v3/commonWorker.server"; import { logger } from "./logger.server"; import { singleton } from "~/utils/singleton"; import { assertEmailAllowed } from "~/utils/email"; @@ -92,15 +91,6 @@ export async function sendPlainTextEmail(options: SendPlainTextOptions) { return client.sendPlainText(options); } -export async function scheduleEmail(data: DeliverEmail, delay?: { seconds: number }) { - const availableAt = delay ? new Date(Date.now() + delay.seconds * 1000) : undefined; - await commonWorker.enqueue({ - job: "scheduleEmail", - payload: data, - availableAt, - }); -} - export async function sendEmail(data: DeliverEmail) { return client.send(data); } diff --git a/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts b/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts index 3aa28bc01db..3774a84ef32 100644 --- a/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts +++ b/apps/webapp/app/services/mfa/multiFactorAuthentication.server.ts @@ -7,7 +7,7 @@ import { createHash } from "@better-auth/utils/hash"; import { createOTP } from "@better-auth/utils/otp"; import { base32 } from "@better-auth/utils/base32"; import { z } from "zod"; -import { scheduleEmail } from "../email.server"; +import { scheduleEmail } from "../scheduleEmail.server"; const generateRandomString = createRandomStringGenerator("A-Z", "0-9"); diff --git a/apps/webapp/app/services/personalAccessToken.server.ts b/apps/webapp/app/services/personalAccessToken.server.ts index e781cdfeb7a..ad3e7be8f16 100644 --- a/apps/webapp/app/services/personalAccessToken.server.ts +++ b/apps/webapp/app/services/personalAccessToken.server.ts @@ -3,6 +3,7 @@ import { customAlphabet, nanoid } from "nanoid"; import { z } from "zod"; import { prisma } from "~/db.server"; import { logger } from "./logger.server"; +import { rbac } from "./rbac.server"; import { decryptToken, encryptToken, hashToken } from "~/utils/tokens.server"; import { env } from "~/env.server"; @@ -19,6 +20,12 @@ export const PAT_LAST_ACCESSED_THROTTLE_MS = 5 * 60 * 1000; type CreatePersonalAccessTokenOptions = { name: string; userId: string; + // Optional: when provided, persist a TokenRole row alongside the PAT + // so PAT-authenticated requests pick up that role's permissions + // (TRI-8749). The dashboard tokens page passes a chosen system role; + // the CLI auth-code path doesn't pass one (legacy behaviour + // preserved — those PATs run with no explicit role). + roleId?: string; }; /** Returns obfuscated access tokens that aren't revoked */ @@ -105,6 +112,43 @@ export type PersonalAccessTokenAuthenticationResult = { userId: string; }; +/** + * Smart-skip the `lastAccessedAt` write when the cached value is already + * within the throttle window. Saves one DB roundtrip per "fresh" auth. + * + * Two layers of throttling: JS-side (`Date.now() - lastAccessedAt.getTime()`) + * elides the SQL entirely when the caller already has a recent timestamp; + * SQL-side `WHERE` clause inside the `updateMany` guards against concurrent + * auths racing to a double-write when the JS check decides to fire. + * + * Called from both the legacy PAT flow (`authenticatePersonalAccessToken`) + * and the apiBuilder's RBAC-routed PAT flow (which gets `lastAccessedAt` + * from `rbac.authenticatePat`'s result). Keeps the throttle policy in one + * place rather than expecting every plugin implementer to re-implement it. + */ +export async function updateLastAccessedAtIfStale( + tokenId: string, + lastAccessedAt: Date | null +): Promise { + if ( + lastAccessedAt && + Date.now() - lastAccessedAt.getTime() <= PAT_LAST_ACCESSED_THROTTLE_MS + ) { + return; // fresh — no roundtrip + } + await prisma.personalAccessToken.updateMany({ + where: { + id: tokenId, + revokedAt: null, + OR: [ + { lastAccessedAt: null }, + { lastAccessedAt: { lt: new Date(Date.now() - PAT_LAST_ACCESSED_THROTTLE_MS) } }, + ], + }, + data: { lastAccessedAt: new Date() }, + }); +} + const EncryptedSecretValueSchema = z.object({ nonce: z.string(), ciphertext: z.string(), @@ -211,24 +255,7 @@ export async function authenticatePersonalAccessToken( return; } - // Conditional updateMany — only writes if the existing lastAccessedAt is - // null or older than the throttle window. The WHERE runs inside the UPDATE - // so concurrent auths don't race into a double-write. `revokedAt: null` - // matches the findFirst guard above so a token revoked between the read - // and write doesn't get a stale lastAccessedAt update. - await prisma.personalAccessToken.updateMany({ - where: { - id: personalAccessToken.id, - revokedAt: null, - OR: [ - { lastAccessedAt: null }, - { lastAccessedAt: { lt: new Date(Date.now() - PAT_LAST_ACCESSED_THROTTLE_MS) } }, - ], - }, - data: { - lastAccessedAt: new Date(), - }, - }); + await updateLastAccessedAtIfStale(personalAccessToken.id, personalAccessToken.lastAccessedAt); const decryptedToken = decryptPersonalAccessToken(personalAccessToken); @@ -338,6 +365,7 @@ export async function createPersonalAccessTokenFromAuthorizationCode( export async function createPersonalAccessToken({ name, userId, + roleId, }: CreatePersonalAccessTokenOptions) { const token = createToken(); const encryptedToken = encryptToken(token, env.ENCRYPTION_KEY); @@ -352,6 +380,45 @@ export async function createPersonalAccessToken({ }, }); + // Persist the role choice via the RBAC plugin's setTokenRole. The + // plugin may store this in a separate datastore from Prisma (e.g. + // Drizzle on a different schema), so co-transactional inserts are + // awkward — we use a compensating-delete pattern instead: if + // setTokenRole fails, roll back the PAT row by deleting it. The auth + // path treats "no role" as permissive (matches the default fallback) + // so a brief orphan window between the two writes is harmless. The + // compensating delete narrows that window from "until manual cleanup" + // to "until the request returns". + // + // Skip the call entirely when no RBAC plugin is loaded — the OSS + // fallback has no TokenRole table to write to. Gating on + // `rbac.isUsingPlugin()` (rather than parsing the fallback's error + // string) keeps the OSS-vs-cloud branch explicit and decoupled from + // any specific error message. + if (roleId && (await rbac.isUsingPlugin())) { + const roleResult = await rbac.setTokenRole({ + tokenId: personalAccessToken.id, + roleId, + }); + if (!roleResult.ok) { + await prisma.personalAccessToken + .delete({ where: { id: personalAccessToken.id } }) + .catch((err) => { + logger.error("Failed to compensating-delete PAT after TokenRole insert failed", { + patId: personalAccessToken.id, + roleResultError: roleResult.error, + deleteError: err instanceof Error ? err.message : String(err), + }); + }); + throw new Error(`Failed to assign role to access token: ${roleResult.error}`); + } + } else if (roleId) { + logger.debug("createPersonalAccessToken: no RBAC plugin, skipping role assignment", { + patId: personalAccessToken.id, + userId, + }); + } + return { id: personalAccessToken.id, name, diff --git a/apps/webapp/app/services/platform.v3.server.ts b/apps/webapp/app/services/platform.v3.server.ts index 6df93c9c0e9..bd189b4fb5a 100644 --- a/apps/webapp/app/services/platform.v3.server.ts +++ b/apps/webapp/app/services/platform.v3.server.ts @@ -1,5 +1,5 @@ import { MachinePresetName, tryCatch } from "@trigger.dev/core/v3"; -import type { Organization, Project, RuntimeEnvironmentType } from "@trigger.dev/database"; +import type { RuntimeEnvironmentType } from "@trigger.dev/database"; import { BillingClient, defaultMachine as defaultMachineFromPlatform, @@ -25,7 +25,6 @@ import { redirect } from "remix-typedjson"; import { z } from "zod"; import { env } from "~/env.server"; import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; -import { createEnvironment } from "~/models/organization.server"; import { logger } from "~/services/logger.server"; import { newProjectPath, organizationBillingPath } from "~/utils/pathBuilder"; import { singleton } from "~/utils/singleton"; @@ -598,33 +597,6 @@ export async function getEntitlement( return result.val; } -export async function projectCreated( - organization: Pick, - project: Project -) { - if (!isCloud()) { - await createEnvironment({ organization, project, type: "STAGING" }); - await createEnvironment({ - organization, - project, - type: "PREVIEW", - isBranchableEnvironment: true, - }); - } else { - //staging is only available on certain plans - const plan = await getCurrentPlan(organization.id); - if (plan?.v3Subscription.plan?.limits.hasStagingEnvironment) { - await createEnvironment({ organization, project, type: "STAGING" }); - await createEnvironment({ - organization, - project, - type: "PREVIEW", - isBranchableEnvironment: true, - }); - } - } -} - export async function getBillingAlerts( organizationId: string ): Promise { @@ -789,7 +761,7 @@ export async function triggerInitialDeployment( } } -function isCloud(): boolean { +export function isCloud(): boolean { const acceptableHosts = [ "https://cloud.trigger.dev", "https://test-cloud.trigger.dev", diff --git a/apps/webapp/app/services/projectCreated.server.ts b/apps/webapp/app/services/projectCreated.server.ts new file mode 100644 index 00000000000..f845af52033 --- /dev/null +++ b/apps/webapp/app/services/projectCreated.server.ts @@ -0,0 +1,35 @@ +import type { Organization, Project } from "@trigger.dev/database"; +import { createEnvironment } from "~/models/organization.server"; +import { getCurrentPlan, isCloud } from "~/services/platform.v3.server"; + +// Extracted from platform.v3.server.ts to break a circular import: +// platform.v3.server ↔ models/organization.server (via createEnvironment). +// The cycle caused the bundled __esm wrappers to re-enter and short-circuit +// the platform.v3.server init, leaving `defaultMachine` and `machines` +// undefined in `singleton("machinePresets", ...)` — the boot crash at +// `allMachines()` traced to TRI-8731. +export async function projectCreated( + organization: Pick, + project: Project +) { + if (!isCloud()) { + await createEnvironment({ organization, project, type: "STAGING" }); + await createEnvironment({ + organization, + project, + type: "PREVIEW", + isBranchableEnvironment: true, + }); + } else { + const plan = await getCurrentPlan(organization.id); + if (plan?.v3Subscription?.plan?.limits?.hasStagingEnvironment) { + await createEnvironment({ organization, project, type: "STAGING" }); + await createEnvironment({ + organization, + project, + type: "PREVIEW", + isBranchableEnvironment: true, + }); + } + } +} diff --git a/apps/webapp/app/services/rbac.server.ts b/apps/webapp/app/services/rbac.server.ts new file mode 100644 index 00000000000..49b6f88ecf4 --- /dev/null +++ b/apps/webapp/app/services/rbac.server.ts @@ -0,0 +1,29 @@ +import { $replica, prisma } from "~/db.server"; +import type { PrismaClient } from "@trigger.dev/database"; +import plugin from "@trigger.dev/rbac"; +import { env } from "~/env.server"; + +// plugin.create() is synchronous — returns a lazy controller that resolves +// any installed RBAC plugin on first call. Top-level await is not used +// because CJS output format does not support it. +// +// Auth-path reads run on every request — pass the replica explicitly so +// they don't pile up on the primary. Writes (role mutations) still go +// through the primary. Same separation findEnvironmentByApiKey used +// before this PR moved bearer auth into the RBAC plugin. +// +// Session-cookie userId resolution lives at the call site (see +// dashboardBuilder.server.ts), not here. Statically importing +// `~/services/session.server` from this module dragged the entire +// remix-auth pipeline (auth.server → emailAuth/gitHubAuth/googleAuth, +// each validating their secret at module load) into anything that +// transitively imported `rbac` — including PAT auth callers that have +// no session-cookie path at all. Passing userId through the +// `authenticateSession` context decouples the plugin host from the +// host's session implementation. +export const rbac = plugin.create( + // $replica is structurally a PrismaClient minus `$transaction` — the + // RBAC fallback only uses `findFirst` on it, so the cast is safe. + { primary: prisma, replica: $replica as PrismaClient }, + { forceFallback: env.RBAC_FORCE_FALLBACK } +); diff --git a/apps/webapp/app/services/realtime/jwtAuth.server.ts b/apps/webapp/app/services/realtime/jwtAuth.server.ts index 00266075d0f..66b7ccf2258 100644 --- a/apps/webapp/app/services/realtime/jwtAuth.server.ts +++ b/apps/webapp/app/services/realtime/jwtAuth.server.ts @@ -126,9 +126,7 @@ export function isPublicJWT(token: string): boolean { } } -export function extractJwtSigningSecretKey( - environment: AuthenticatedEnvironment & { parentEnvironment?: { apiKey: string } } -) { +export function extractJwtSigningSecretKey(environment: AuthenticatedEnvironment) { return environment.parentEnvironment?.apiKey ?? environment.apiKey; } diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts index 9e439938d0d..3bf3564431a 100644 --- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts +++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts @@ -1,20 +1,14 @@ import { z } from "zod"; -import { - ApiAuthenticationResultSuccess, - authenticateApiRequestWithFailure, -} from "../apiAuth.server"; +import { ApiAuthenticationResultSuccess } from "../apiAuth.server"; import { ActionFunctionArgs, json, LoaderFunctionArgs } from "@remix-run/server-runtime"; import { fromZodError } from "zod-validation-error"; import { apiCors } from "~/utils/apiCors"; -import { - AuthorizationAction, - AuthorizationResources, - checkAuthorization, -} from "../authorization.server"; import { logger } from "../logger.server"; +import { rbac } from "../rbac.server"; +import type { RbacAbility, RbacResource } from "@trigger.dev/rbac"; import { - authenticateApiRequestWithPersonalAccessToken, PersonalAccessTokenAuthenticationResult, + updateLastAccessedAtIfStale, } from "../personalAccessToken.server"; import { safeJsonParse } from "~/utils/json"; import { @@ -50,8 +44,126 @@ function logBoundaryError( } } +// Bridges the RBAC plugin (source of truth for auth + abilities) to the legacy +// ApiAuthenticationResultSuccess shape route handlers still expect. All three +// apiBuilder call sites funnel through this helper — no handler-level changes +// needed. +async function authenticateRequestForApiBuilder( + request: Request, + { allowJWT }: { allowJWT: boolean } +): Promise< + | { ok: false; status: 401 | 403; error: string } + | { ok: true; authentication: ApiAuthenticationResultSuccess; ability: RbacAbility } +> { + const result = await rbac.authenticateBearer(request, { allowJWT }); + if (!result.ok) { + // Plugin auth distinguishes 401 (who are you?) from 403 (you're not + // allowed) — e.g. a suspended account or IP block returns 403. + // Forwarding the status preserves that semantic for client retry logic. + return { ok: false, status: result.status, error: result.error }; + } + + // Plugins return the full AuthenticatedEnvironment shape directly — no + // follow-up DB lookup. The fallback fetches via Prisma, the cloud plugin + // via Drizzle; both produce the same slim contract type. + const authentication: ApiAuthenticationResultSuccess = { + ok: true, + apiKey: result.environment.apiKey, + type: result.subject.type === "publicJWT" ? "PUBLIC_JWT" : "PRIVATE", + environment: result.environment, + realtime: result.jwt?.realtime, + oneTimeUse: result.jwt?.oneTimeUse, + }; + + return { ok: true, authentication, ability: result.ability }; +} + type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion; +// A multi-resource auth check has two possible directions, and route authors +// have to pick one explicitly: +// +// - `anyResource(...)` — succeed if *any* element passes. Used when a single +// record carries multiple identifiers (a run is addressable by friendlyId / +// batch / tags / task) so a JWT scoped to *any* of them grants access. +// +// - `everyResource(...)` — succeed only if *every* element passes. Used for +// batch operations where each element is a *distinct* resource and a JWT +// scoped to one element must not authorize the others. +// +// Bare `RbacResource[]` is intentionally *not* part of `AuthResource` — the +// type system forces every multi-resource site to disambiguate. The original +// pre-RBAC apiBuilder had a separate `superScopes: [...]` whitelist for +// "broader-than-this-resource" access; post-RBAC that's expressed via the JWT +// ability's wildcard branches (`*:all` and `admin*` — see +// `internal-packages/rbac/src/ability.ts`) plus a collection-level shape +// `{ type: "" }` (no id) in the `anyResource` array so a +// `:` JWT matches it. No code knob needed. +// +// Markers are Symbols so they can't collide with arbitrary RbacResource fields. +const ANY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.anyResource"); +const EVERY_RESOURCE_MARKER = Symbol.for("@trigger.dev/rbac.everyResource"); + +type AnyResourceAuth = { + readonly [ANY_RESOURCE_MARKER]: true; + readonly resources: readonly RbacResource[]; +}; + +type EveryResourceAuth = { + readonly [EVERY_RESOURCE_MARKER]: true; + readonly resources: readonly RbacResource[]; +}; + +export function anyResource(resources: RbacResource[]): AnyResourceAuth { + return { [ANY_RESOURCE_MARKER]: true, resources }; +} + +export function everyResource(resources: RbacResource[]): EveryResourceAuth { + return { [EVERY_RESOURCE_MARKER]: true, resources }; +} + +function isAnyResource(value: unknown): value is AnyResourceAuth { + return ( + typeof value === "object" && + value !== null && + (value as Record)[ANY_RESOURCE_MARKER] === true + ); +} + +function isEveryResource(value: unknown): value is EveryResourceAuth { + return ( + typeof value === "object" && + value !== null && + (value as Record)[EVERY_RESOURCE_MARKER] === true + ); +} + +type AuthResource = RbacResource | AnyResourceAuth | EveryResourceAuth; + +function checkAuth( + ability: RbacAbility, + action: string, + resource: AuthResource +): boolean { + if (isEveryResource(resource)) { + // Empty array via [].every() is vacuously true — would let any token + // pass auth. Routes building everyResource() from request bodies + // (e.g. batch trigger items) should never produce zero elements + // because body validation rejects empty arrays first, but defending + // here anyway since the auth layer should never grant on no input. + if (resource.resources.length === 0) return false; + return resource.resources.every((r) => ability.can(action, r)); + } + if (isAnyResource(resource)) { + // Symmetric guard: anyResource([]) is benign for most abilities + // (.some() is false on empty), but the permissive ability would + // still grant. Treat empty as "no resource declared" → deny. + if (resource.resources.length === 0) return false; + return ability.can(action, [...resource.resources]); + } + return ability.can(action, resource); +} + type ApiKeyRouteBuilderOptions< TParamsSchema extends AnyZodSchema | undefined = undefined, TSearchParamsSchema extends AnyZodSchema | undefined = undefined, @@ -76,7 +188,7 @@ type ApiKeyRouteBuilderOptions< ) => Promise; shouldRetryNotFound?: boolean; authorization?: { - action: AuthorizationAction; + action: string; resource: ( resource: NonNullable, params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion @@ -90,8 +202,7 @@ type ApiKeyRouteBuilderOptions< headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion ? z.infer : undefined - ) => AuthorizationResources; - superScopes?: string[]; + ) => AuthResource; }; }; @@ -144,23 +255,15 @@ export function createLoaderApiRoute< } try { - const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT }); - - if (!authenticationResult) { + const authResult = await authenticateRequestForApiBuilder(request, { allowJWT }); + if (!authResult.ok) { return await wrapResponse( request, - json({ error: "Invalid or Missing API key" }, { status: 401 }), - corsStrategy !== "none" - ); - } - - if (!authenticationResult.ok) { - return await wrapResponse( - request, - json({ error: authenticationResult.error }, { status: 401 }), + json({ error: authResult.error }, { status: authResult.status }), corsStrategy !== "none" ); } + const { authentication: authenticationResult, ability } = authResult; let parsedParams: any = undefined; if (paramsSchema) { @@ -227,7 +330,7 @@ export function createLoaderApiRoute< } if (authorization) { - const { action, resource: authResource, superScopes } = authorization; + const { action, resource: authResource } = authorization; const $authResource = authResource( resource, parsedParams, @@ -235,26 +338,12 @@ export function createLoaderApiRoute< parsedHeaders ); - logger.debug("Checking authorization", { - action, - resource: $authResource, - superScopes, - scopes: authenticationResult.scopes, - }); - - const authorizationResult = checkAuthorization( - authenticationResult, - action, - $authResource, - superScopes - ); - - if (!authorizationResult.authorized) { + if (!checkAuth(ability, action, $authResource)) { return await wrapResponse( request, json( { - error: `Unauthorized: ${authorizationResult.reason}`, + error: "Unauthorized", code: "unauthorized", param: "access_token", type: "authorization", @@ -309,6 +398,37 @@ type PATRouteBuilderOptions< searchParams?: TSearchParamsSchema; headers?: THeadersSchema; corsStrategy?: "all" | "none"; + // Resolves the target org/project for the request. Fed to + // `rbac.authenticatePat` so the plugin can compute the user's role + // floor (their authority in that org) for the cap intersection. + // When omitted, the PAT runs in identity-only mode — no role floor, + // no per-route ability gating beyond what authorization (if any) + // declares against a permissive baseline. Routes added before TRI-9087 + // run in this mode by default. + context?: ( + params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + request: Request + ) => + | { organizationId?: string; projectId?: string } + | Promise<{ organizationId?: string; projectId?: string }>; + authorization?: { + action: string; + resource: ( + params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + searchParams: TSearchParamsSchema extends + | z.ZodFirstPartySchemaTypes + | z.ZodDiscriminatedUnion + ? z.infer + : undefined, + headers: THeadersSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion + ? z.infer + : undefined + ) => AuthResource; + }; }; type PATHandlerFunction< @@ -328,6 +448,7 @@ type PATHandlerFunction< ? z.infer : undefined; authentication: PersonalAccessTokenAuthenticationResult; + ability: RbacAbility; request: Request; apiVersion: API_VERSIONS; }) => Promise; @@ -346,6 +467,8 @@ export function createLoaderPATApiRoute< searchParams: searchParamsSchema, headers: headersSchema, corsStrategy = "none", + context: contextFn, + authorization, } = options; if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") { @@ -353,16 +476,6 @@ export function createLoaderPATApiRoute< } try { - const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request); - - if (!authenticationResult) { - return await wrapResponse( - request, - json({ error: "Invalid or Missing API key" }, { status: 401 }), - corsStrategy !== "none" - ); - } - let parsedParams: any = undefined; if (paramsSchema) { const parsed = paramsSchema.safeParse(params); @@ -415,11 +528,70 @@ export function createLoaderPATApiRoute< const apiVersion = getApiVersion(request); + // Single PAT auth roundtrip. `rbac.authenticatePat` validates the + // token AND computes the cap-and-floor ability in one DB query + // (the OSS fallback does the validation only and returns a + // permissive ability; the cloud plugin returns the joined + // cap/floor result). We previously called + // `authenticateApiRequestWithPersonalAccessToken` here first as + // belt-and-braces, but that meant two PAT lookups per request + // for routes with `context`/`authorization` declared. Routes + // without those still get a working `authentication` object — + // we pass an empty ctx and the fallback validates fine. + // + // `lastAccessedAt` is plumbed through the plugin result so the + // host can decide whether to fire the update (smart-skip in + // `updateLastAccessedAtIfStale` — no DB roundtrip when the + // cached timestamp is fresher than the throttle window). + const ctx = contextFn ? await contextFn(parsedParams, request) : {}; + const patAuth = await rbac.authenticatePat(request, ctx); + if (!patAuth.ok) { + return await wrapResponse( + request, + json({ error: patAuth.error }, { status: patAuth.status }), + corsStrategy !== "none" + ); + } + + const authenticationResult: PersonalAccessTokenAuthenticationResult = { + userId: patAuth.userId, + }; + const ability: RbacAbility = patAuth.ability; + + // Fire the `lastAccessedAt` write conditionally. Two-layer throttle: + // JS skips the SQL when the value is fresh (most requests); the + // SQL `WHERE` clause inside the helper is race-safe for concurrent + // auths that both decide to fire. Don't `await` it from the + // critical path? — it's a one-row update on a small hot table and + // we want to surface failures, so it's awaited (same shape as the + // legacy `authenticatePersonalAccessToken`). + await updateLastAccessedAtIfStale(patAuth.tokenId, patAuth.lastAccessedAt); + + if (authorization) { + const $resource = authorization.resource(parsedParams, parsedSearchParams, parsedHeaders); + if (!checkAuth(ability, authorization.action, $resource)) { + return await wrapResponse( + request, + json( + { + error: "Unauthorized", + code: "unauthorized", + param: "access_token", + type: "authorization", + }, + { status: 403 } + ), + corsStrategy !== "none" + ); + } + } + const result = await handler({ params: parsedParams, searchParams: parsedSearchParams, headers: parsedHeaders, authentication: authenticationResult, + ability, request, apiVersion, }); @@ -468,7 +640,7 @@ type ApiKeyActionRouteBuilderOptions< : undefined ) => Promise; authorization?: { - action: AuthorizationAction; + action: string; resource: ( params: TParamsSchema extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion ? z.infer @@ -490,8 +662,7 @@ type ApiKeyActionRouteBuilderOptions< // externalId for sessions) read it here so a JWT minted for either form // authorizes both URL forms. resource: TResource | undefined - ) => AuthorizationResources; - superScopes?: string[]; + ) => AuthResource; }; maxContentLength?: number; body?: TBodySchema; @@ -579,23 +750,15 @@ export function createActionApiRoute< } try { - const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT }); - - if (!authenticationResult) { - return await wrapResponse( - request, - json({ error: "Invalid or Missing API key" }, { status: 401 }), - corsStrategy !== "none" - ); - } - - if (!authenticationResult.ok) { + const authResult = await authenticateRequestForApiBuilder(request, { allowJWT }); + if (!authResult.ok) { return await wrapResponse( request, - json({ error: authenticationResult.error }, { status: 401 }), + json({ error: authResult.error }, { status: authResult.status }), corsStrategy !== "none" ); } + const { authentication: authenticationResult, ability } = authResult; if (maxContentLength) { const contentLength = request.headers.get("content-length"); @@ -706,7 +869,7 @@ export function createActionApiRoute< // - PRIVATE key + missing resource → auth passes → 404 (correct) // - PRIVATE key + existing resource → auth passes → handler runs if (authorization) { - const { action, resource: authResource, superScopes } = authorization; + const { action, resource: authResource } = authorization; const $resource = authResource( parsedParams, parsedSearchParams, @@ -715,26 +878,12 @@ export function createActionApiRoute< resource ); - logger.debug("Checking authorization", { - action, - resource: $resource, - superScopes, - scopes: authenticationResult.scopes, - }); - - const authorizationResult = checkAuthorization( - authenticationResult, - action, - $resource, - superScopes - ); - - if (!authorizationResult.authorized) { + if (!checkAuth(ability, action, $resource)) { return await wrapResponse( request, json( { - error: `Unauthorized: ${authorizationResult.reason}`, + error: "Unauthorized", code: "unauthorized", param: "access_token", type: "authorization", @@ -825,9 +974,8 @@ type MultiMethodApiRouteOptions< allowJWT?: boolean; corsStrategy?: "all" | "none"; authorization?: { - action: AuthorizationAction; - resource: (params: InferZod) => AuthorizationResources; - superScopes?: string[]; + action: string; + resource: (params: InferZod) => AuthResource; }; maxContentLength?: number; methods: Partial< @@ -872,33 +1020,22 @@ export function createMultiMethodApiRoute< if (!methodConfig) { return await wrapResponse( request, - json( - { error: "Method not allowed" }, - { status: 405, headers: { Allow: allowedMethods } } - ), + json({ error: "Method not allowed" }, { status: 405, headers: { Allow: allowedMethods } }), corsStrategy !== "none" ); } try { // Authenticate - const authenticationResult = await authenticateApiRequestWithFailure(request, { allowJWT }); - - if (!authenticationResult) { + const authResult = await authenticateRequestForApiBuilder(request, { allowJWT }); + if (!authResult.ok) { return await wrapResponse( request, - json({ error: "Invalid or Missing API key" }, { status: 401 }), - corsStrategy !== "none" - ); - } - - if (!authenticationResult.ok) { - return await wrapResponse( - request, - json({ error: authenticationResult.error }, { status: 401 }), + json({ error: authResult.error }, { status: authResult.status }), corsStrategy !== "none" ); } + const { authentication: authenticationResult, ability } = authResult; if (maxContentLength) { const contentLength = request.headers.get("content-length"); @@ -966,29 +1103,15 @@ export function createMultiMethodApiRoute< // Authorize if (authorization) { - const { action, resource, superScopes } = authorization; + const { action, resource } = authorization; const $resource = resource(parsedParams); - logger.debug("Checking authorization", { - action, - resource: $resource, - superScopes, - scopes: authenticationResult.scopes, - }); - - const authorizationResult = checkAuthorization( - authenticationResult, - action, - $resource, - superScopes - ); - - if (!authorizationResult.authorized) { + if (!checkAuth(ability, action, $resource)) { return await wrapResponse( request, json( { - error: `Unauthorized: ${authorizationResult.reason}`, + error: "Unauthorized", code: "unauthorized", param: "access_token", type: "authorization", diff --git a/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts b/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts new file mode 100644 index 00000000000..656761d852d --- /dev/null +++ b/apps/webapp/app/services/routeBuilders/dashboardBuilder.server.ts @@ -0,0 +1,117 @@ +// Server-only impl backing dashboardBuilder.ts. Imports rbac.server and +// runs the actual auth/authorization. The wrappers in dashboardBuilder.ts +// dynamic-import this module from inside the loader/action body, so it +// never reaches the client bundle. + +import { json, redirect } from "@remix-run/server-runtime"; +import type { RbacAbility } from "@trigger.dev/rbac"; +import { rbac } from "~/services/rbac.server"; +import { getUserId } from "~/services/session.server"; +import type { + AuthorizationOption, + DashboardLoaderOptions, + SessionUser, +} from "./dashboardBuilder"; +import { fromZodError } from "zod-validation-error"; +import type { z } from "zod"; + +type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion; + +function loginRedirectFor(request: Request, override?: string): Response { + if (override) return redirect(override); + const url = new URL(request.url); + const redirectTo = encodeURIComponent(`${url.pathname}${url.search}`); + return redirect(`/login?redirectTo=${redirectTo}`); +} + +function isAuthorized(ability: RbacAbility, authorization: AuthorizationOption): boolean { + if ("requireSuper" in authorization) { + return ability.canSuper(); + } + return ability.can(authorization.action, authorization.resource); +} + +type AuthScope = { organizationId?: string; projectId?: string }; + +export async function authenticateAndAuthorize< + TParams, + TSearchParams, + TContext extends AuthScope +>( + request: Request, + rawParams: unknown, + options: DashboardLoaderOptions +): Promise< + | { ok: false; response: Response } + | { + ok: true; + user: SessionUser; + ability: RbacAbility; + params: unknown; + searchParams: unknown; + context: TContext; + } +> { + let parsedParams: any = undefined; + if (options.params) { + const parsed = (options.params as unknown as AnyZodSchema).safeParse(rawParams); + if (!parsed.success) { + return { + ok: false, + response: json( + { error: "Params Error", details: fromZodError(parsed.error).details }, + { status: 400 } + ), + }; + } + parsedParams = parsed.data; + } + + let parsedSearchParams: any = undefined; + if (options.searchParams) { + const fromUrl = Object.fromEntries(new URL(request.url).searchParams); + const parsed = (options.searchParams as unknown as AnyZodSchema).safeParse(fromUrl); + if (!parsed.success) { + return { + ok: false, + response: json( + { error: "Query Error", details: fromZodError(parsed.error).details }, + { status: 400 } + ), + }; + } + parsedSearchParams = parsed.data; + } + + const ctx = (options.context + ? await options.context(parsedParams, request) + : ({} as TContext)) as TContext; + // Resolve userId from the session cookie *here* (the dashboard + // request boundary) and feed it into the rbac plugin context. The + // plugin no longer takes a `helpers.getSessionUserId` callback — + // statically importing session.server from rbac.server dragged the + // entire remix-auth strategy chain (each strategy validates its + // secret at module load) into anything that pulled `rbac` in, + // including PAT-only callers. + const userId = (await getUserId(request)) ?? null; + const auth = await rbac.authenticateSession(request, { ...ctx, userId }); + if (!auth.ok) { + if (auth.reason === "unauthenticated") { + return { ok: false, response: loginRedirectFor(request, options.loginRedirect) }; + } + return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") }; + } + + if (options.authorization && !isAuthorized(auth.ability, options.authorization)) { + return { ok: false, response: redirect(options.unauthorizedRedirect ?? "/") }; + } + + return { + ok: true, + user: auth.user, + ability: auth.ability, + params: parsedParams, + searchParams: parsedSearchParams, + context: ctx, + }; +} diff --git a/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts b/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts new file mode 100644 index 00000000000..673f4f78deb --- /dev/null +++ b/apps/webapp/app/services/routeBuilders/dashboardBuilder.ts @@ -0,0 +1,141 @@ +// Client-safe shim for the dashboard route builder. The actual server +// implementation lives in dashboardBuilder.server.ts; the wrappers here +// just return closures that lazily import that impl on first invocation. +// +// Why split: routes use `export const loader = dashboardLoader(...)` at +// module top-level. Remix's dev build preserves the top-level call when +// resolving the loader export, so the import target needs to exist on +// the client even though the closure body never executes there. A +// `.server.ts` file is excluded from the client bundle, which would +// resolve `dashboardLoader` to undefined and crash with +// "dashboardLoader is not a function" on first navigation. Keeping this +// file non-`.server` puts the wrappers in the client bundle as +// effectively no-op closures (they're never called there), and the +// closure body's dynamic import only resolves at server runtime. + +import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime"; +import type { RbacAbility, RbacResource } from "@trigger.dev/rbac"; +import type { z } from "zod"; + +type AnyZodSchema = z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion; + +type InferZod = T extends z.ZodFirstPartySchemaTypes | z.ZodDiscriminatedUnion + ? z.infer + : undefined; + +export type SessionUser = { + id: string; + email: string; + name: string | null; + displayName: string | null; + avatarUrl: string | null; + admin: boolean; + confirmedBasicDetails: boolean; + isImpersonating: boolean; +}; + +// `requireSuper: true` enforces ability.canSuper(). Otherwise an explicit +// action + resource pair is checked via ability.can(...). +export type AuthorizationOption = + | { requireSuper: true } + | { + action: string; + resource: RbacResource | RbacResource[]; + }; + +// Plugin-side scope: whatever the route's `context` returns must include +// these (or just be `{}` when the route doesn't scope by org/project). +// rbac.authenticateSession reads them off the value to filter UserRole. +type AuthScope = { organizationId?: string; projectId?: string }; + +export type DashboardLoaderOptions = { + params?: TParams; + searchParams?: TSearchParams; + // Resolves any per-request data the handler + auth check both need + // (typically org/project lookups from URL params). The returned object + // is fed to `rbac.authenticateSession` as the auth scope AND passed + // through to the handler in `args.context`, so the route does each + // lookup once. + context?: ( + params: InferZod, + request: Request + ) => TContext | Promise; + authorization?: AuthorizationOption; + // Where to send unauthenticated requests. Defaults to /login with a + // redirectTo back to the original path. + loginRedirect?: string; + // Where to send users who pass auth but fail the ability check. Defaults + // to "/" (the home page). + unauthorizedRedirect?: string; +}; + +export type DashboardLoaderHandlerArgs = { + params: InferZod; + searchParams: InferZod; + user: SessionUser; + ability: RbacAbility; + context: TContext; + request: Request; +}; + +export function dashboardLoader< + TParams extends AnyZodSchema | undefined = undefined, + TSearchParams extends AnyZodSchema | undefined = undefined, + TContext extends AuthScope = AuthScope, + TReturn extends Response = Response +>( + options: DashboardLoaderOptions, + handler: ( + args: DashboardLoaderHandlerArgs + ) => Promise +) { + return async function loader({ request, params }: LoaderFunctionArgs): Promise { + // Server-only — see comment at top. Node caches the module after the + // first call, so the dynamic import is effectively free past warmup. + const { authenticateAndAuthorize } = await import("./dashboardBuilder.server"); + const result = await authenticateAndAuthorize(request, params, options); + if (!result.ok) throw result.response; + + return handler({ + params: result.params as InferZod, + searchParams: result.searchParams as InferZod, + user: result.user, + ability: result.ability, + context: result.context as TContext, + request, + }); + }; +} + +export type DashboardActionOptions = + DashboardLoaderOptions; + +export type DashboardActionHandlerArgs = + DashboardLoaderHandlerArgs; + +export function dashboardAction< + TParams extends AnyZodSchema | undefined = undefined, + TSearchParams extends AnyZodSchema | undefined = undefined, + TContext extends AuthScope = AuthScope, + TReturn extends Response = Response +>( + options: DashboardActionOptions, + handler: ( + args: DashboardActionHandlerArgs + ) => Promise +) { + return async function action({ request, params }: ActionFunctionArgs): Promise { + const { authenticateAndAuthorize } = await import("./dashboardBuilder.server"); + const result = await authenticateAndAuthorize(request, params, options); + if (!result.ok) throw result.response; + + return handler({ + params: result.params as InferZod, + searchParams: result.searchParams as InferZod, + user: result.user, + ability: result.ability, + context: result.context as TContext, + request, + }); + }; +} diff --git a/apps/webapp/app/services/scheduleEmail.server.ts b/apps/webapp/app/services/scheduleEmail.server.ts new file mode 100644 index 00000000000..0da19c86cb3 --- /dev/null +++ b/apps/webapp/app/services/scheduleEmail.server.ts @@ -0,0 +1,16 @@ +import type { DeliverEmail } from "emails"; +import { commonWorker } from "~/v3/commonWorker.server"; + +// Lives outside email.server.ts so that the SMTP/Resend client module +// stays a leaf dependency. Pulling commonWorker from email.server poisoned +// every consumer of the auth chain (auth → emailAuth → email) with the +// V1+V2 worker tree, which transitively loads marqs and trips Redis-env +// guards in any vitest file whose import graph reaches it. +export async function scheduleEmail(data: DeliverEmail, delay?: { seconds: number }) { + const availableAt = delay ? new Date(Date.now() + delay.seconds * 1000) : undefined; + await commonWorker.enqueue({ + job: "scheduleEmail", + payload: data, + availableAt, + }); +} diff --git a/apps/webapp/app/services/upsertBranch.server.ts b/apps/webapp/app/services/upsertBranch.server.ts index 3b4e18a58ea..e13f5d244c8 100644 --- a/apps/webapp/app/services/upsertBranch.server.ts +++ b/apps/webapp/app/services/upsertBranch.server.ts @@ -3,7 +3,7 @@ import slug from "slug"; import { prisma } from "~/db.server"; import { createApiKeyForEnv, createPkApiKeyForEnv } from "~/models/api-key.server"; import { type CreateBranchOptions } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.branches/route"; -import { isValidGitBranchName, sanitizeBranchName } from "~/v3/gitBranch"; +import { isValidGitBranchName, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; import { logger } from "./logger.server"; import { getCurrentPlan, getLimit } from "./platform.v3.server"; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 7a151053f5a..8f94b302ef7 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -114,6 +114,10 @@ export function organizationTeamPath(organization: OrgForPath) { return `${organizationPath(organization)}/settings/team`; } +export function organizationRolesPath(organization: OrgForPath) { + return `${organizationPath(organization)}/settings/roles`; +} + export function inviteTeamMemberPath(organization: OrgForPath) { return `${organizationPath(organization)}/invite`; } diff --git a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts index c6c72d79567..f2ca46d4d3a 100644 --- a/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts +++ b/apps/webapp/app/v3/environmentVariables/environmentVariablesRepository.server.ts @@ -1,4 +1,5 @@ import { Prisma, type PrismaClient, type RuntimeEnvironmentType } from "@trigger.dev/database"; +import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; import { z } from "zod"; import { environmentFullTitle } from "~/components/environments/EnvironmentLabel"; import { $transaction, prisma } from "~/db.server"; @@ -876,8 +877,21 @@ export const RuntimeEnvironmentForEnvRepoPayload = { }, } as const; -export type RuntimeEnvironmentForEnvRepo = Prisma.RuntimeEnvironmentGetPayload< - typeof RuntimeEnvironmentForEnvRepoPayload +// Derived from the slim AuthenticatedEnvironment so a full AE satisfies +// this type — the legacy Prisma payload had `builtInEnvironmentVariableOverrides` +// as Prisma's JsonValue, which is a subtype of `unknown` in the slim +// shape, causing assignability errors in the JWT/queue paths that pass +// AE values straight through. Using Pick aligns them. +export type RuntimeEnvironmentForEnvRepo = Pick< + AuthenticatedEnvironment, + | "id" + | "slug" + | "type" + | "projectId" + | "apiKey" + | "organizationId" + | "branchName" + | "builtInEnvironmentVariableOverrides" >; export const environmentVariablesRepository = new EnvironmentVariablesRepository(); @@ -1333,10 +1347,13 @@ function resolveBuiltInEnvironmentVariableOverrides( if ( !Array.isArray(overrides) && typeof overrides === "object" && - key in overrides && - typeof overrides[key] === "string" + overrides !== null && + key in overrides ) { - return overrides[key]; + const value = (overrides as Record)[key]; + if (typeof value === "string") { + return value; + } } return defaultValue; diff --git a/apps/webapp/app/v3/remoteImageBuilder.server.ts b/apps/webapp/app/v3/remoteImageBuilder.server.ts index a6f113022af..763ef2e95ff 100644 --- a/apps/webapp/app/v3/remoteImageBuilder.server.ts +++ b/apps/webapp/app/v3/remoteImageBuilder.server.ts @@ -1,13 +1,21 @@ import { depot } from "@depot/sdk-node"; import { type ExternalBuildData } from "@trigger.dev/core/v3"; -import { type Project } from "@trigger.dev/database"; import { prisma } from "~/db.server"; import { env } from "~/env.server"; import pRetry from "p-retry"; import { logger } from "~/services/logger.server"; +// Just the project columns this module reads — keeps the signature +// compatible with both the full Prisma `Project` payload and the slim +// `AuthenticatedEnvironment["project"]` shape. +type ProjectForBuilder = { + id: string; + externalRef: string; + builderProjectId: string | null; +}; + export async function createRemoteImageBuild( - project: Project + project: ProjectForBuilder ): Promise { if (!remoteBuildsEnabled()) { return; @@ -42,7 +50,7 @@ export async function createRemoteImageBuild( }; } -async function createBuilderProjectIfNotExists(project: Project) { +async function createBuilderProjectIfNotExists(project: ProjectForBuilder) { if (project.builderProjectId) { return project.builderProjectId; } diff --git a/apps/webapp/package.json b/apps/webapp/package.json index 0afb011cce0..b536d35d443 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -124,6 +124,7 @@ "@trigger.dev/companyicons": "^1.5.35", "@trigger.dev/core": "workspace:*", "@trigger.dev/database": "workspace:*", + "@trigger.dev/rbac": "workspace:*", "@trigger.dev/otlp-importer": "workspace:*", "@trigger.dev/platform": "1.0.27", "@trigger.dev/redis-worker": "workspace:*", diff --git a/apps/webapp/test/README.md b/apps/webapp/test/README.md new file mode 100644 index 00000000000..d1c2a418b39 --- /dev/null +++ b/apps/webapp/test/README.md @@ -0,0 +1,65 @@ +# Webapp tests + +Three suites live in this directory. + +## Unit tests — `*.test.ts` + +Run with `pnpm test` from `apps/webapp`. Default vitest pickup. No +container setup. Run on every PR via `unit-tests-webapp.yml`. + +## Smoke e2e — `*.e2e.test.ts` + +End-to-end auth baseline that proves the route auth plumbing is wired up. +Each file spins up its own webapp + Postgres + Redis container in +`beforeAll` (~30s startup). Vitest config: `vitest.e2e.config.ts`. Run on +every PR via `e2e-webapp.yml`. + +```bash +cd apps/webapp +pnpm exec vitest --config vitest.e2e.config.ts +``` + +## Comprehensive auth e2e — `*.e2e.full.test.ts` + +The full RBAC auth matrix — every route family with explicit pass/fail +scenarios. See TRI-8731 for the parent ticket and TRI-8732 onwards for +each family's coverage spec. + +**Architecture**: one container reused across the whole suite via +`vitest.e2e.full.config.ts`'s `globalSetup`. Test files share the server +through `getTestServer()` from `helpers/sharedTestServer.ts`. Each test +seeds its own resources so order doesn't matter. + +**Layout**: + +| File | Top-level describe | Family subtasks | +|---|---|---| +| `auth-api.e2e.full.test.ts` | `API` | TRI-8733 trigger, TRI-8734 run resource, TRI-8735 run mutations, TRI-8736 run lists, TRI-8737 batches, TRI-8738 prompts, TRI-8739 deployments + query, TRI-8740 waitpoints + input streams, TRI-8741 PAT | +| `auth-dashboard.e2e.full.test.ts` | `Dashboard` | TRI-8742 admin pages | +| `auth-cross-cutting.e2e.full.test.ts` | `Cross-cutting` | TRI-8743 deleted projects / revoked keys / expired JWTs / env mismatch / force-fallback toggle | + +**Adding a new family**: pick the relevant file, add a nested `describe` +block. Inside, seed your own fixtures via the helpers and hit the shared +server. + +```ts +describe("Trigger task", () => { + const server = getTestServer(); + + it("missing Authorization → 401", async () => { + const res = await server.webapp.fetch("/api/v1/tasks/x/trigger", { method: "POST", body: "{}" }); + expect(res.status).toBe(401); + }); +}); +``` + +**CI**: `e2e-webapp-auth-full.yml`. Triggers on `workflow_dispatch`, +nightly schedule, and PRs touching auth-relevant paths (route builders, +rbac.server.ts, apiAuth.server.ts, apiroutes, the suite itself). + +**Run locally**: + +```bash +cd apps/webapp +pnpm exec vitest --config vitest.e2e.full.config.ts +``` diff --git a/apps/webapp/test/api-auth.e2e.test.ts b/apps/webapp/test/api-auth.e2e.test.ts index c425ca7449c..31e365d6d40 100644 --- a/apps/webapp/test/api-auth.e2e.test.ts +++ b/apps/webapp/test/api-auth.e2e.test.ts @@ -11,6 +11,9 @@ import type { TestServer } from "@internal/testcontainers/webapp"; import { startTestServer } from "@internal/testcontainers/webapp"; import { generateJWT } from "@trigger.dev/core/v3/jwt"; import { seedTestEnvironment } from "./helpers/seedTestEnvironment"; +import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT"; +import { seedTestRun } from "./helpers/seedTestRun"; +import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint"; vi.setConfig({ testTimeout: 180_000 }); @@ -119,3 +122,306 @@ describe("JWT bearer auth — baseline behavior", () => { expect(res.status).toBe(401); }); }); + +// Exercises the RBAC plugin loader end-to-end. The test server boots +// with RBAC_FORCE_FALLBACK=1 (see internal-packages/testcontainers/src/webapp.ts), +// which makes rbac.server.ts use the default fallback regardless of +// whether a plugin is installed in node_modules. /admin/concurrency +// uses rbac.authenticateSession internally; an unauthenticated request +// must flow through LazyController → RoleBaseAccessFallback → +// redirect("/login"). +describe("RBAC plugin — fallback wiring", () => { + it("unauthenticated dashboard route redirects to /login via the fallback", async () => { + const res = await server.webapp.fetch("/admin/concurrency", { redirect: "manual" }); + expect(res.status).toBe(302); + const location = res.headers.get("location") ?? ""; + expect(new URL(location, "http://placeholder").pathname).toBe("/login"); + }); +}); + +// Covers createActionApiRoute's bearer auth path. The target route is +// POST /api/v1/idempotencyKeys/:key/reset — allowJWT: true, superScopes: ["write:runs", "admin"]. +// Tests assert HTTP-observable behavior so they remain valid after TRI-8719 swaps +// authenticateApiRequestWithFailure for rbac.authenticateBearer. +describe("API bearer auth — action requests", () => { + const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset"; + + it("valid API key: auth passes (body validation fails, not 401/403)", async () => { + const { apiKey } = await seedTestEnvironment(server.prisma); + const res = await server.webapp.fetch(targetPath, { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json" }, + body: JSON.stringify({}), // missing taskIdentifier → zod validation error + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("missing Authorization header: 401", async () => { + const res = await server.webapp.fetch(targetPath, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ taskIdentifier: "noop" }), + }); + expect(res.status).toBe(401); + }); + + it("invalid API key: 401", async () => { + const res = await server.webapp.fetch(targetPath, { + method: "POST", + headers: { + Authorization: "Bearer tr_dev_completely_invalid_key_xyz_not_real", + "content-type": "application/json", + }, + body: JSON.stringify({ taskIdentifier: "noop" }), + }); + expect(res.status).toBe(401); + }); + +}); + +describe("JWT bearer auth — action requests", () => { + const targetPath = "/api/v1/idempotencyKeys/does-not-exist/reset"; + + it("JWT with matching scope: auth passes", async () => { + const { environment } = await seedTestEnvironment(server.prisma); + const jwt = await generateTestJWT(environment, { scopes: ["write:runs"] }); + const res = await server.webapp.fetch(targetPath, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with wrong scope (read-only) on write route: 403", async () => { + const { environment } = await seedTestEnvironment(server.prisma); + const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] }); + const res = await server.webapp.fetch(targetPath, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" }, + body: JSON.stringify({ taskIdentifier: "noop" }), + }); + expect(res.status).toBe(403); + }); +}); + +// Covers createLoaderPATApiRoute via GET /api/v1/projects/:projectRef/runs. +// authenticateApiRequestWithPersonalAccessToken rejects anything that isn't tr_pat_-prefixed +// or doesn't match a non-revoked PersonalAccessToken row. +describe("Personal access token auth", () => { + const pathFor = (ref: string) => `/api/v1/projects/${ref}/runs`; + + it("missing Authorization header: 401", async () => { + const res = await server.webapp.fetch(pathFor("nonexistent")); + expect(res.status).toBe(401); + }); + + it("API key (tr_dev_*) on PAT-only route: 401", async () => { + const { apiKey } = await seedTestEnvironment(server.prisma); + const res = await server.webapp.fetch(pathFor("nonexistent"), { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + expect(res.status).toBe(401); + }); + + it("malformed PAT (wrong prefix): 401", async () => { + const res = await server.webapp.fetch(pathFor("nonexistent"), { + headers: { Authorization: "Bearer not_a_pat_at_all_random_string" }, + }); + expect(res.status).toBe(401); + }); + + it("well-formed but unknown PAT: 401", async () => { + const res = await server.webapp.fetch(pathFor("nonexistent"), { + headers: { + Authorization: "Bearer tr_pat_0000000000000000000000000000000000000000", + }, + }); + expect(res.status).toBe(401); + }); + + it("revoked PAT: 401", async () => { + const user = await seedTestUser(server.prisma); + const { token } = await seedTestPAT(server.prisma, user.id, { revoked: true }); + const res = await server.webapp.fetch(pathFor("nonexistent"), { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(401); + }); + + it("valid PAT on nonexistent project: 404 (auth passes)", async () => { + const user = await seedTestUser(server.prisma); + const { token } = await seedTestPAT(server.prisma, user.id); + const res = await server.webapp.fetch(pathFor("nonexistent"), { + headers: { Authorization: `Bearer ${token}` }, + }); + expect(res.status).toBe(404); + }); +}); + +// Verifies resource-scoped JWT behaviour end-to-end against a real seeded resource. +// Target: POST /api/v1/waitpoints/tokens/:waitpointFriendlyId/complete — allowJWT: true, +// authorization: { action: "write", resource: (params) => ({ waitpoints: params.waitpointFriendlyId }), +// superScopes: ["write:waitpoints", "admin"] }. +// +// The Waitpoint is seeded with status COMPLETED so the handler short-circuits with +// { success: true } once auth passes — no run-engine worker needed. "Auth passes" is +// observable as a 200 response; "auth fails" is observable as a 403. +describe("JWT bearer auth — resource-scoped scopes", () => { + const pathFor = (friendlyId: string) => `/api/v1/waitpoints/tokens/${friendlyId}/complete`; + + async function seedEnvAndWaitpoint() { + const seed = await seedTestEnvironment(server.prisma); + const waitpoint = await seedTestWaitpoint(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + return { ...seed, waitpoint }; + } + + async function completeRequest(friendlyId: string, jwt: string) { + return server.webapp.fetch(pathFor(friendlyId), { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" }, + body: JSON.stringify({}), + }); + } + + it("scope matches exact resource id: 200", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { + scopes: [`write:waitpoints:${waitpoint.friendlyId}`], + }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("scope targets a different resource id: 403", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { + scopes: ["write:waitpoints:waitpoint_someoneelse000000000000000"], + }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(403); + }); + + it("type-level scope (no id) grants all resources of that type: 200", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { scopes: ["write:waitpoints"] }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("scope action mismatch (read-only on write route) with matching resource id: 403", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { + scopes: [`read:waitpoints:${waitpoint.friendlyId}`], + }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(403); + }); + + it("scope targets a different resource type: 403", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { + scopes: ["write:runs:run_abc000000000000000000000"], + }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(403); + }); + + it("admin super-scope grants access (legacy behaviour): 200", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { scopes: ["admin"] }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("unrelated type scope with no super-scope match: 403", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateTestJWT(environment, { scopes: ["read:runs"] }); + const res = await completeRequest(waitpoint.friendlyId, jwt); + expect(res.status).toBe(403); + }); +}); + +// Pre-migration coverage for the three behavioural constraints captured in TRI-8719. +// Each test locks in an observable current behaviour that the migration must preserve: +// - custom actions (trigger/batchTrigger/update) satisfied by write:* scopes +// - multi-key resource callbacks (runs/tags/batch/tasks) — any key match grants access +// - empty resource callbacks relying on superScopes +describe("JWT bearer auth — behaviours to preserve through TRI-8719", () => { + it("custom action: type-level write:tasks scope satisfies action=\"trigger\" (auth passes)", async () => { + const { environment } = await seedTestEnvironment(server.prisma); + // Current SDK + MCP JWTs for task-trigger use type-level scope, e.g. write:tasks. + // Legacy checkAuthorization passes via exact superScope match ["write:tasks", "admin"]. + // After TRI-8719, the ACTION_ALIASES map must keep this working: trigger action is + // satisfied by a scope whose action is write. + const jwt = await generateTestJWT(environment, { scopes: ["write:tasks"] }); + const res = await server.webapp.fetch("/api/v1/tasks/nonexistent-task/trigger", { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "content-type": "application/json" }, + body: JSON.stringify({}), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("multi-key resource: read:tags: scope grants access to a run carrying that tag (auth passes)", async () => { + const { environment, project } = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: environment.id, + projectId: project.id, + runTags: ["my-resource-scoped-tag"], + }); + const jwt = await generateTestJWT(environment, { + scopes: ["read:tags:my-resource-scoped-tag"], + }); + const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("multi-key resource: read:batch: scope grants access to a run in that batch (auth passes)", async () => { + const { environment, project } = await seedTestEnvironment(server.prisma); + const { runFriendlyId, batchFriendlyId } = await seedTestRun(server.prisma, { + environmentId: environment.id, + projectId: project.id, + withBatch: true, + }); + const jwt = await generateTestJWT(environment, { + scopes: [`read:batch:${batchFriendlyId}`], + }); + const res = await server.webapp.fetch(`/api/v1/runs/${runFriendlyId}/trace`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + // Empty-resource routes (api.v1.batches.ts, api.v1.idempotencyKeys.$key.reset.ts) + // currently DENY all JWTs because legacy checkAuthorization's empty-resource check + // fires before the superScope check. TRI-8719's plan to add explicit { type: "runs" } + // changes this to "JWTs with read:runs or write:runs now work on these routes" — an + // intentional improvement, not a preserved behaviour. See TRI-8719 description for + // the note; there's nothing to lock in with a test here. +}); + +// Edge cases where auth-path DB state should cause 401 even with a valid-looking token. +describe("API bearer auth — environment/project edge cases", () => { + it("valid API key whose project is soft-deleted: 401", async () => { + const { apiKey, project } = await seedTestEnvironment(server.prisma); + await server.prisma.project.update({ + where: { id: project.id }, + data: { deletedAt: new Date() }, + }); + const res = await server.webapp.fetch("/api/v1/runs/run_doesnotexist/result", { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + expect(res.status).toBe(401); + }); +}); diff --git a/apps/webapp/test/auth-api.e2e.full.test.ts b/apps/webapp/test/auth-api.e2e.full.test.ts new file mode 100644 index 00000000000..e66cb1e8072 --- /dev/null +++ b/apps/webapp/test/auth-api.e2e.full.test.ts @@ -0,0 +1,2977 @@ +// Comprehensive API auth tests — uses the shared TestServer started by +// vitest.e2e.full.config.ts's globalSetup. Family subtasks under TRI-8731 +// add nested describe blocks here: +// +// describe("API", () => { +// describe("Trigger task", () => { ... }) // TRI-8733 +// describe("Runs — resource routes", () => { ... }) // TRI-8734 +// ... +// }) +// +// See test/helpers/sharedTestServer.ts for `getTestServer()`. + +import { generateJWT } from "@trigger.dev/core/v3/jwt"; +import { describe, expect, it } from "vitest"; +import { getTestServer } from "./helpers/sharedTestServer"; +import { seedTestEnvironment } from "./helpers/seedTestEnvironment"; +import { seedTestPAT, seedTestUser } from "./helpers/seedTestPAT"; +import { seedTestRun } from "./helpers/seedTestRun"; +import { seedTestApiSession } from "./helpers/seedTestApiSession"; +import { seedTestUserProject } from "./helpers/seedTestUserProject"; +import { seedTestWaitpoint } from "./helpers/seedTestWaitpoint"; + +describe("API", () => { + // Placeholder until family subtasks add their describes (TRI-8733+). + // Verifies the shared container is reachable from this worker. + it("shared webapp container responds to /healthcheck", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch("/healthcheck"); + expect(res.ok).toBe(true); + }); + + // PAT-authenticated routes (TRI-8741). The smoke matrix in + // test/api-auth.e2e.test.ts covers basic 401 cases (missing auth, + // wrong-prefix, unknown PAT, revoked PAT, valid-PAT-on-nonexistent- + // project). This describe extends the matrix to the cases that + // require seeding the full user → org → project → env graph: + // valid-PAT-on-real-project, cross-org isolation, soft-deleted + // project, and the global-admin-flag-doesn't-grant-cross-org carve- + // out. + // + // Target route: GET /api/v1/projects/:projectRef/runs (the only + // createLoaderPATApiRoute consumer at time of writing — re-grep + // before extending if more PAT-only routes appear). + describe("PAT-authenticated routes — comprehensive", () => { + const pathFor = (ref: string) => `/api/v1/projects/${ref}/runs`; + + it("JWT on PAT-only route: 401", async () => { + const server = getTestServer(); + const { environment } = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(pathFor("nonexistent"), { + headers: { Authorization: `Bearer ${jwt}` }, + }); + // PAT route doesn't accept JWTs — auth rejects before resource lookup. + expect(res.status).toBe(401); + }); + + it("valid PAT, project exists in user's org: auth passes", async () => { + const server = getTestServer(); + const { project, pat } = await seedTestUserProject(server.prisma); + const res = await server.webapp.fetch(pathFor(project.externalRef), { + headers: { Authorization: `Bearer ${pat.token}` }, + }); + // Auth + scoping pass. The route's run-list presenter hits + // ClickHouse which isn't reachable in tests — accept any status + // that isn't an auth failure. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("valid PAT, project belongs to a different user's org: 404", async () => { + const server = getTestServer(); + // Two completely isolated graphs. Both projects exist; the PAT + // belongs to userA, the project to userB's org. findProjectByRef + // scopes by `members: { some: { userId } }`, so userA's PAT + // sees userB's project as nonexistent → 404 (not 403). + const a = await seedTestUserProject(server.prisma); + const b = await seedTestUserProject(server.prisma); + const res = await server.webapp.fetch(pathFor(b.project.externalRef), { + headers: { Authorization: `Bearer ${a.pat.token}` }, + }); + // Lock in the 404 — the access check inside findProjectByRef + // returns null for cross-org and the route maps null to 404. + expect(res.status).toBe(404); + }); + + it("valid PAT, project soft-deleted (deletedAt != null): 200 (route does not filter)", async () => { + const server = getTestServer(); + // findProjectByRef (apps/webapp/app/models/project.server.ts) + // does NOT filter on deletedAt — it scopes only by externalRef + // and the user's org membership. So a soft-deleted project is + // still findable here; the run-list presenter just returns + // data:[] (or whatever survived). The ticket lists this as a + // 404 case but that's not the route's actual contract; lock in + // observed behaviour and call out the gap so a future change + // (either tightening findProjectByRef or filtering at the route) + // is conscious. + const { project, pat } = await seedTestUserProject(server.prisma, { + projectDeleted: true, + }); + const res = await server.webapp.fetch(pathFor(project.externalRef), { + headers: { Authorization: `Bearer ${pat.token}` }, + }); + // ClickHouse-dependent run-list — auth-passed assertion. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("valid PAT for a global-admin user: still per-user (no cross-org access)", async () => { + const server = getTestServer(); + // user.admin = true is the legacy super-admin flag. The PAT + // route's access check is per-user (members: { some: { userId } }), + // not admin-aware — so admin doesn't unlock cross-org visibility. + // Lock in that behaviour: an admin's PAT can't read another + // org's project either. + const admin = await seedTestUser(server.prisma, { admin: true }); + const adminPat = await seedTestPAT(server.prisma, admin.id); + const otherOrg = await seedTestUserProject(server.prisma); + + const res = await server.webapp.fetch(pathFor(otherOrg.project.externalRef), { + headers: { Authorization: `Bearer ${adminPat.token}` }, + }); + expect(res.status).toBe(404); + }); + + it("valid PAT, admin user accessing their OWN project: auth passes", async () => { + const server = getTestServer(); + // Companion to the above — confirm admin=true users can still + // access their own org's projects (the admin flag isn't + // accidentally subtracting permission). + const { project, pat } = await seedTestUserProject(server.prisma, { + userAdmin: true, + }); + const res = await server.webapp.fetch(pathFor(project.externalRef), { + headers: { Authorization: `Bearer ${pat.token}` }, + }); + // ClickHouse-dependent run-list — auth-passed assertion. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // Resource-scoped writes (TRI-8740). Two routes: + // - POST /api/v1/waitpoints/tokens/:friendlyId/complete + // resource: { type: "waitpoints", id: friendlyId } + // - POST /realtime/v1/streams/:runId/input/:streamId + // resource: { type: "inputStreams", id: runId } + // + // The smoke matrix (api-auth.e2e.test.ts "JWT bearer auth — resource- + // scoped scopes") already covers waitpoints comprehensively for JWT + // resource-id matching, type-level scopes, action mismatches, admin + // super-scope, etc. This block fills the gaps: + // - Private API key (not JWT) on the route. + // - JWT with `write:all` super-scope. + // - Cross-env (env A's JWT trying env B's resource). + // Plus the equivalent full matrix for input-streams which the smoke + // matrix doesn't touch. + describe("Resource-scoped writes — waitpoints (gap-fill)", () => { + const pathFor = (friendlyId: string) => + `/api/v1/waitpoints/tokens/${friendlyId}/complete`; + const completeRequest = (path: string, headers: Record) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({}), + }); + + async function seedEnvAndWaitpoint() { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const waitpoint = await seedTestWaitpoint(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + return { ...seed, waitpoint }; + } + + it("private API key (tr_dev_*): auth passes (200)", async () => { + const { apiKey, waitpoint } = await seedEnvAndWaitpoint(); + const res = await completeRequest(pathFor(waitpoint.friendlyId), { + Authorization: `Bearer ${apiKey}`, + }); + // Waitpoint is COMPLETED, so the handler short-circuits with 200 + // once auth passes. Auth-passed assertion: NOT 401 / 403. + expect(res.status).toBe(200); + }); + + it("JWT with write:all super-scope: auth passes (200)", async () => { + const { environment, waitpoint } = await seedEnvAndWaitpoint(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["write:all"] }, + expirationTime: "15m", + }); + const res = await completeRequest(pathFor(waitpoint.friendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).toBe(200); + }); + + it("cross-env: env A's JWT cannot complete env B's waitpoint: not 200", async () => { + const server = getTestServer(); + const a = await seedTestEnvironment(server.prisma); + const b = await seedEnvAndWaitpoint(); + const jwt = await generateJWT({ + secretKey: a.apiKey, + payload: { + pub: true, + sub: a.environment.id, + scopes: [`write:waitpoints:${b.waitpoint.friendlyId}`], + }, + expirationTime: "15m", + }); + // The JWT is signed by env A and its sub claim says env A. The + // route resolves env from the sub claim and the waitpoint is + // env B's, so the lookup misses. The exact code depends on + // whether auth or the resource lookup fires first — both + // outcomes are correct, just NOT 200. + const res = await completeRequest(pathFor(b.waitpoint.friendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(200); + }); + }); + + describe("Resource-scoped writes — input streams (full matrix)", () => { + const pathFor = (runId: string, streamId: string) => + `/realtime/v1/streams/${runId}/input/${streamId}`; + const postRequest = (path: string, headers: Record) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ data: { hello: "world" } }), + }); + + async function seedEnvAndRun() { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + return { ...seed, runFriendlyId, streamId: "test-stream" }; + } + + it("missing auth: 401", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(pathFor("run_doesnotexist", "stream-x"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ data: {} }), + }); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes (not 401/403)", async () => { + const { apiKey, runFriendlyId, streamId } = await seedEnvAndRun(); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${apiKey}`, + }); + // Route may return any 2xx/4xx based on stream state — we only + // care that auth passed (NOT 401/403). + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with exact-id scope: auth passes", async () => { + const { environment, runFriendlyId, streamId } = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: [`write:inputStreams:${runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with type-level scope: auth passes", async () => { + const { environment, runFriendlyId, streamId } = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["write:inputStreams"] }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with wrong resource id: 403", async () => { + const { environment, runFriendlyId, streamId } = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: ["write:inputStreams:run_someoneelse00000000000000"], + }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).toBe(403); + }); + + it("JWT with read action on write route: 403", async () => { + const { environment, runFriendlyId, streamId } = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: [`read:inputStreams:${runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).toBe(403); + }); + + it("JWT with write:all super-scope: auth passes", async () => { + const { environment, runFriendlyId, streamId } = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["write:all"] }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with admin super-scope: auth passes", async () => { + const { environment, runFriendlyId, streamId } = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, sub: environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(runFriendlyId, streamId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("cross-env: env A's JWT cannot write to env B's run: not 200", async () => { + const server = getTestServer(); + const a = await seedTestEnvironment(server.prisma); + const b = await seedEnvAndRun(); + const jwt = await generateJWT({ + secretKey: a.apiKey, + payload: { + pub: true, + sub: a.environment.id, + scopes: [`write:inputStreams:${b.runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await postRequest(pathFor(b.runFriendlyId, b.streamId), { + Authorization: `Bearer ${jwt}`, + }); + // Either auth fails outright or the run lookup misses (env A's + // view of the run doesn't include env B's data). Critical + // security property: NOT 200. + expect(res.status).not.toBe(200); + }); + }); + + // Trigger task routes (TRI-8733). The single-task route uses + // action: "trigger" with a single resource { type: "tasks", id }; + // batch v1/v2 use action: "batchTrigger" with a body-derived array + // [{type:"tasks", id}, ...] under AND semantics — every task in the + // batch must be authorized, not just any one (otherwise a JWT scoped + // to one task could submit a batch with arbitrary other tasks). + // v3 batches use a collection-level resource { type: "tasks" } + // (no id — items are validated per-row when streamed). + // + // ACTION_ALIASES (from packages/core/src/v3/jwt.ts) maps write→trigger + // and write→batchTrigger so write:tasks scopes also satisfy these + // routes. The smoke matrix already verifies write:tasks → trigger + // alias works; we re-test it here per-route so scope misconfig in + // one route doesn't slip past. + describe("Trigger task — single (api.v1.tasks.$taskId.trigger)", () => { + const TASK_ID = "test-task"; + const path = `/api/v1/tasks/${TASK_ID}/trigger`; + + async function seedAndRequest( + headers: Record, + body: unknown = { payload: {} } + ) { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }); + return { res, seed }; + } + + it("missing auth: 401", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes (handler may 4xx — not 401/403)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { + Authorization: `Bearer ${seed.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ payload: {} }), + }); + // Auth passed; the handler may 404 because the task doesn't + // actually exist in the BackgroundWorker. Anything not 401/403 + // is "auth passed" for this test. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:tasks (type-level, ACTION_ALIASES write→trigger): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with trigger:tasks:: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`trigger:tasks:${TASK_ID}`], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with trigger:tasks:: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["trigger:tasks:some-other-task"], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).toBe(403); + }); + + it("JWT with read:tasks: 403 (read NOT aliased to trigger)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).toBe(403); + }); + + it("JWT with empty scopes: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: [] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).toBe(403); + }); + + it("JWT signed with wrong key: 401", async () => { + const server = getTestServer(); + const a = await seedTestEnvironment(server.prisma); + const b = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: b.apiKey, // wrong key for env A's sub + payload: { + pub: true, + sub: a.environment.id, + scopes: [`trigger:tasks:${TASK_ID}`], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).toBe(401); + }); + + it("JWT with admin super-scope: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ payload: {} }), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + describe("Trigger task — batch v1 (api.v1.tasks.batch)", () => { + const path = "/api/v1/tasks/batch"; + const buildBody = (taskIds: string[]) => ({ + items: taskIds.map((task) => ({ task, payload: {} })), + }); + + it("missing auth: 401", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA"])), + }); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { + Authorization: `Bearer ${seed.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(buildBody(["taskA"])), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:tasks (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA", "taskB"])), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with batchTrigger:tasks:taskA + body has [taskA, taskB]: 403 (every-task semantics)", async () => { + // Batch trigger uses AND semantics — every task in the body must + // be authorized, not just any one of them. A JWT scoped to only + // taskA cannot submit a batch that also includes taskB, otherwise + // the caller would be triggering tasks they have no scope for. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["batchTrigger:tasks:taskA"], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA", "taskB"])), + }); + expect(res.status).toBe(403); + }); + + it("JWT with batchTrigger:tasks:taskA + body has [taskA] only: auth passes", async () => { + // Per-task scope grants per-task access — a batch containing + // only the authorized task is allowed. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["batchTrigger:tasks:taskA"], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA"])), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with batchTrigger:tasks: + body has only taskA: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["batchTrigger:tasks:not-in-body"], + }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA"])), + }); + expect(res.status).toBe(403); + }); + + it("JWT with read:tasks: 403 (action mismatch)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA"])), + }); + expect(res.status).toBe(403); + }); + + it("JWT with admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody(["taskA"])), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // v2 batch shares the exact same authorization config as v1 — same + // body-derived array resource, same batchTrigger action. We don't + // duplicate the full matrix here; the v1 tests cover the wrapper + // behaviour. If v2's authorization config ever diverges from v1's, + // add a targeted test here. For now just sanity-check that the v2 + // route's wiring is alive. + describe("Trigger task — batch v2 (api.v2.tasks.batch) sanity", () => { + const path = "/api/v2/tasks/batch"; + + it("missing auth: 401", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ items: [{ task: "t", payload: {} }] }), + }); + expect(res.status).toBe(401); + }); + + it("JWT with write:tasks: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify({ items: [{ task: "t", payload: {} }] }), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // v3 batches use a collection-level resource { type: "tasks" } with + // no id — items are validated per-row when streamed. So id-specific + // scopes (write:tasks:foo) shouldn't grant blanket access; only + // type-level write:tasks (or admin/write:all) should. + describe("Trigger task — batch v3 (api.v3.batches) collection-level", () => { + const path = "/api/v3/batches"; + const buildBody = () => ({ runCount: 1 }); + + it("missing auth: 401", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(buildBody()), + }); + expect(res.status).toBe(401); + }); + + it("JWT with write:tasks (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody()), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with read:tasks: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:tasks"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody()), + }); + expect(res.status).toBe(403); + }); + + it("JWT with admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + method: "POST", + headers: { Authorization: `Bearer ${jwt}`, "Content-Type": "application/json" }, + body: JSON.stringify(buildBody()), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // Run lists (TRI-8736). Two routes share the same multi-key + // resource pattern — collection-level `{ type: "runs" }` always + // present, plus an array of secondary keys derived from search + // params: + // - GET /api/v1/runs: filter[taskIdentifier]=A,B → +{ type: "tasks", id: A }, { type: "tasks", id: B } + // - GET /realtime/v1/runs: ?tags=foo,bar → +{ type: "tags", id: "foo" }, { type: "tags", id: "bar" } + // + // Multi-key any-match contract from TRI-8719: a JWT with a scope + // matching ANY element of the resource array grants access. So: + // - read:runs → matches the collection key → passes + // - read:tasks:A (with A in filter) → matches an array element → passes + // - read:tasks:Z (with A in filter) → no match → 403 + describe("Run list — api.v1.runs (multi-key tasks)", () => { + const path = "/api/v1/runs"; + + async function get(query: string, headers: Record) { + return getTestServer().webapp.fetch(`${path}${query}`, { headers }); + } + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + // Pass cases on api.v1.runs assert "auth passed" (not 401/403) + // rather than strict 200. The handler hits ClickHouse which isn't + // reachable from the test container — the endpoint can 500 in + // tests even when auth is fine. The auth layer is what we're + // verifying here. + it("private API key: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await get("", { Authorization: `Bearer ${seed.apiKey}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with read:runs (collection-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with read:all super-scope: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:all"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with empty scopes: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: [] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT with write:runs (action mismatch — read route): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("filter[taskIdentifier]=task_a,task_b + JWT read:tasks:task_a → passes (array match)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:tasks:task_a"], + }, + expirationTime: "15m", + }); + const res = await get( + "?filter%5BtaskIdentifier%5D=task_a%2Ctask_b", + { Authorization: `Bearer ${jwt}` } + ); + // Resource array is [{type:"runs"}, {type:"tasks",id:"task_a"}, {type:"tasks",id:"task_b"}]. + // The scope read:tasks:task_a matches the second element → access granted. + // Handler may 500 (ClickHouse unreachable in tests) but auth passed. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("filter[taskIdentifier]=task_a + JWT read:tasks:task_z → 403 (no array match)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:tasks:task_z"], + }, + expirationTime: "15m", + }); + const res = await get( + "?filter%5BtaskIdentifier%5D=task_a", + { Authorization: `Bearer ${jwt}` } + ); + // Resource is [{runs}, {tasks:task_a}]. JWT scope says + // read:tasks:task_z which doesn't match the runs collection + // (wrong type) or the task_a element (wrong id). 403. + expect(res.status).toBe(403); + }); + }); + + describe("Run list — realtime.v1.runs (multi-key tags)", () => { + const path = "/realtime/v1/runs"; + + async function get(query: string, headers: Record) { + return getTestServer().webapp.fetch(`${path}${query}`, { headers }); + } + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + it("JWT with read:runs (collection-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + // Realtime endpoints stream — the route may return 200 (streaming + // OK) or other status codes depending on streams setup. We only + // care that auth passed: NOT 401/403. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with read:tags:foo + ?tags=foo,bar → passes (array match)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:tags:foo"], + }, + expirationTime: "15m", + }); + const res = await get("?tags=foo,bar", { Authorization: `Bearer ${jwt}` }); + // Resource array is [{type:"runs"}, {type:"tags",id:"foo"}, {type:"tags",id:"bar"}]. + // Scope matches the foo element → access granted. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with read:tags:baz + ?tags=foo → 403 (no array match)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:tags:baz"], + }, + expirationTime: "15m", + }); + const res = await get("?tags=foo", { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT with admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:runs (action mismatch): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] }, + expirationTime: "15m", + }); + const res = await get("", { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + }); + + // Run mutations (TRI-8735). Two routes: + // - POST /api/v2/runs/:runParam/cancel + // action: write, resource: { type: "runs", id: params.runParam } + // — single id-keyed resource, supports id-specific scopes. + // - POST /api/v1/idempotencyKeys/:key/reset + // action: write, resource: { type: "runs" } (collection-level) + // — id-specific scopes don't grant blanket access; only + // type-level write:runs (or super-scopes) work. + // + // The legacy idempotencyKeys/:key/reset rejected ALL JWTs due to an + // empty-resource bug. Post TRI-8719 the empty-resource resolution + // lets write:runs JWTs through. Tests here lock in the new behaviour. + describe("Run mutations — cancel (api.v2.runs.$runParam.cancel)", () => { + const pathFor = (runId: string) => `/api/v2/runs/${runId}/cancel`; + const post = (path: string, headers: Record) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({}), + }); + + it("missing auth: 401", async () => { + const res = await post(pathFor("run_anything"), {}); + expect(res.status).toBe(401); + }); + + it("invalid API key: 401", async () => { + const res = await post(pathFor("run_anything"), { + Authorization: "Bearer tr_dev_definitely_not_real_key", + }); + expect(res.status).toBe(401); + }); + + it("private API key on real run: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${seed.apiKey}`, + }); + // Auth + findResource passed; handler may return any 2xx/4xx + // depending on run state. We only care: not 401/403. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:runs (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] }, + expirationTime: "15m", + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:runs:: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`write:runs:${runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:runs:: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["write:runs:run_someoneelse00000000000"], + }, + expirationTime: "15m", + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).toBe(403); + }); + + it("JWT with read:runs (action mismatch): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`read:runs:${runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).toBe(403); + }); + + it("JWT with write:all super-scope: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:all"] }, + expirationTime: "15m", + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await post(pathFor(runFriendlyId), { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + describe("Run mutations — idempotencyKeys.reset (api.v1.idempotencyKeys.$key.reset)", () => { + // Collection-level resource { type: "runs" } — id-specific + // write:runs: scopes don't help here (no id to match). + // The legacy version of this route rejected ALL JWTs due to an + // empty-resource bug; the post-TRI-8719 path lets write:runs + // through. Tests below pin that down. + const path = "/api/v1/idempotencyKeys/some-key/reset"; + const validBody = JSON.stringify({ taskIdentifier: "test-task" }); + + const post = (headers: Record, body = validBody) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body, + }); + + it("missing auth: 401", async () => { + const res = await post({}); + expect(res.status).toBe(401); + }); + + it("invalid API key: 401", async () => { + const res = await post({ Authorization: "Bearer tr_dev_invalid" }); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await post({ Authorization: `Bearer ${seed.apiKey}` }); + // Handler may 404/204 depending on whether the idempotency key + // exists. Auth-passed assertion only. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with write:runs (type-level): auth passes — locks in TRI-8719 fix", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:runs"] }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + // PRE-TRI-8719: this returned 403 (legacy empty-resource bug + // rejected all JWTs). POST-TRI-8719: write:runs grants access. + // Locking in the new behaviour. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with read:runs (action mismatch): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT with write:all: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:all"] }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT with admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // Run resource routes (TRI-8734). Every read-side `$runId` route + // computes its authorization resource from the loaded TaskRun: + // [ + // { type: "runs", id: run.friendlyId }, + // { type: "tasks", id: run.taskIdentifier }, + // ...run.runTags.map(tag => ({ type: "tags", id: tag })), + // run.batch?.friendlyId && { type: "batch", id: run.batch.friendlyId }, + // ] + // + // A JWT scope matching ANY array element grants access. We test the + // full matrix against the canonical route (api.v3.runs.$runId), and + // a sanity check on one of the others to confirm the wiring isn't + // route-local. If a future route's resource shape diverges, add a + // targeted describe. + describe("Run resource — GET /api/v3/runs/:runId (multi-key array)", () => { + const pathFor = (runId: string) => `/api/v3/runs/${runId}`; + + async function seedRunWithBatchAndTags() { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const seeded = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + runTags: ["alpha", "beta"], + withBatch: true, + }); + return { ...seed, ...seeded }; + } + + const get = (path: string, headers: Record) => + getTestServer().webapp.fetch(path, { headers }); + + it("missing auth: 401", async () => { + const res = await get(pathFor("run_anything"), {}); + expect(res.status).toBe(401); + }); + + it("invalid API key: 401", async () => { + const res = await get(pathFor("run_anything"), { + Authorization: "Bearer tr_dev_invalid", + }); + expect(res.status).toBe(401); + }); + + it("private API key on real run: auth passes", async () => { + const { runFriendlyId, apiKey } = await seedRunWithBatchAndTags(); + const res = await get(pathFor(runFriendlyId), { + Authorization: `Bearer ${apiKey}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:runs (type-level): auth passes", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:runs:: auth passes (id match)", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: [`read:runs:${runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:runs:: 403", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: ["read:runs:run_someoneelse00000000000"], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT read:tags:: auth passes (array element match)", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + // run was seeded with runTags=["alpha","beta"]; scope matches "alpha". + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:tags:alpha"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:tags:: 403", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:tags:gamma"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT read:batch:: auth passes", async () => { + const { runFriendlyId, batchFriendlyId, apiKey, environment } = + await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: [`read:batch:${batchFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:batch:: 403", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: ["read:batch:batch_someoneelse00000000"], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT read:tasks:: auth passes", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + // seedTestRun uses taskIdentifier "test-task" by default. + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:tasks:test-task"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:all: auth passes", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:all"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT admin: auth passes", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT write:runs:: 403 (action mismatch — read route)", async () => { + const { runFriendlyId, apiKey, environment } = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: [`write:runs:${runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(runFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("cross-env: env A's JWT cannot read env B's run: not 200", async () => { + const server = getTestServer(); + const a = await seedTestEnvironment(server.prisma); + const b = await seedRunWithBatchAndTags(); + const jwt = await generateJWT({ + secretKey: a.apiKey, + payload: { + pub: true, + sub: a.environment.id, + scopes: [`read:runs:${b.runFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(b.runFriendlyId), { Authorization: `Bearer ${jwt}` }); + // Either auth fails or the run lookup misses (env A's view of + // the run doesn't include env B's data). Critical: NOT 200. + expect(res.status).not.toBe(200); + }); + }); + + // Sanity check: same multi-key pattern wired the same way on the + // events sub-route. If this drifts in the future the divergence + // gets a dedicated describe. + describe("Run resource — GET /api/v1/runs/:runId/events (sanity)", () => { + const pathFor = (runId: string) => `/api/v1/runs/${runId}/events`; + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(pathFor("run_anything")); + expect(res.status).toBe(401); + }); + + it("JWT read:runs (type-level): auth passes on a real run", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const { runFriendlyId } = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await getTestServer().webapp.fetch(pathFor(runFriendlyId), { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // Batch resources (TRI-8737). Per-batch retrieve + realtime + // endpoints — single-id resource `{ type: "batch", id: batch.friendlyId }`. + // The list endpoint (`GET /api/v1/batches`) is currently absent + // from this branch (deleted in s3-switchover), so the list- + // section of the matrix is N/A here. If/when the list endpoint + // returns, add a list-side describe. + // + // Notable behaviour: the route's resource is `{ type: "batch" }`, + // NOT `{ type: "runs" }`. The legacy literal-match escape that + // let `read:runs` JWTs hit batch endpoints no longer applies. + // Tests pin this down (a `read:runs` scope on a `{ type: "batch" }` + // resource is a type mismatch → 403). + describe("Batch retrieve — GET /api/v1/batches/:batchId", () => { + const pathFor = (batchId: string) => `/api/v1/batches/${batchId}`; + + async function seedRunWithBatch() { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const seeded = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + withBatch: true, + }); + // batchFriendlyId is guaranteed when withBatch is set. + if (!seeded.batchFriendlyId) { + throw new Error("seedTestRun({ withBatch: true }) didn't return a batchFriendlyId"); + } + return { ...seed, batchFriendlyId: seeded.batchFriendlyId }; + } + + const get = (path: string, headers: Record) => + getTestServer().webapp.fetch(path, { headers }); + + it("missing auth: 401", async () => { + const res = await get(pathFor("batch_anything"), {}); + expect(res.status).toBe(401); + }); + + it("invalid API key: 401", async () => { + const res = await get(pathFor("batch_anything"), { + Authorization: "Bearer tr_dev_invalid", + }); + expect(res.status).toBe(401); + }); + + it("private API key on real batch: auth passes", async () => { + const { batchFriendlyId, apiKey } = await seedRunWithBatch(); + const res = await get(pathFor(batchFriendlyId), { + Authorization: `Bearer ${apiKey}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:batch: matching: auth passes", async () => { + const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: [`read:batch:${batchFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:batch:: 403", async () => { + const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { + pub: true, + sub: environment.id, + scopes: ["read:batch:batch_someoneelse00000000"], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT read:batch (type-level): auth passes", async () => { + const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:batch"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:runs: auth passes (backwards-compat for legacy SDKs)", async () => { + const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + // Pre-RBAC the batch-retrieve route's superScopes included + // `read:runs`, so JWTs minted with read:runs could read batches. + // The post-migration backwards-compat fix adds a `{type: "runs"}` + // element to the route's anyResource(...) list so those + // SDK-issued JWTs in the wild keep working — avoids a silent + // 401/403 regression for callers we never told to switch scopes. + // Per-id `read:batch:` and type-level `read:batch` still + // grant via the route's first resource element. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:all super-scope: auth passes", async () => { + const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["read:all"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT admin: auth passes", async () => { + const { batchFriendlyId, apiKey, environment } = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get(pathFor(batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("cross-env: env A's JWT cannot read env B's batch: not 200", async () => { + const server = getTestServer(); + const a = await seedTestEnvironment(server.prisma); + const b = await seedRunWithBatch(); + const jwt = await generateJWT({ + secretKey: a.apiKey, + payload: { + pub: true, + sub: a.environment.id, + scopes: [`read:batch:${b.batchFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await get(pathFor(b.batchFriendlyId), { Authorization: `Bearer ${jwt}` }); + // Critical: env A's JWT can't see env B's batch (env-scoped + // findResource returns null). NOT 200. + expect(res.status).not.toBe(200); + }); + }); + + // Sanity: api.v2 and realtime.v1 share the exact same authorization + // config as v1. Don't duplicate the full matrix; just verify the + // wiring is alive on each. + describe("Batch retrieve — GET /api/v2/batches/:batchId (sanity)", () => { + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch("/api/v2/batches/batch_anything"); + expect(res.status).toBe(401); + }); + + it("JWT read:batch (type-level): auth passes on real batch", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const seeded = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + withBatch: true, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:batch"] }, + expirationTime: "15m", + }); + const res = await getTestServer().webapp.fetch( + `/api/v2/batches/${seeded.batchFriendlyId}`, + { headers: { Authorization: `Bearer ${jwt}` } } + ); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // Prompts routes (TRI-8738). Resource shapes: + // - List resource: { type: "prompts", id: "all" } action: read + // - Retrieve resource: { type: "prompts", id: params.slug } action: read + // - Override resource: { type: "prompts", id: params.slug } action: update + // (multi-method: POST/PUT/PATCH/DELETE) + // - Promote resource: { type: "prompts", id: params.slug } action: update + // - Reactivate resource: { type: "prompts", id: params.slug } action: update + // + // ACTION_ALIASES: update ← write, so write:prompts also satisfies + // the update-action routes. + // + // Auth happens before any DB lookup, so we test against + // non-existent slugs — handler will 404 but we assert "not 401/403" + // for pass cases. + describe("Prompts list — GET /api/v1/prompts (collection-level)", () => { + const path = "/api/v1/prompts"; + const get = (headers: Record) => + getTestServer().webapp.fetch(path, { headers }); + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await get({ Authorization: `Bearer ${seed.apiKey}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:prompts (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:prompts"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:runs: 403 (type mismatch)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + describe("Prompts retrieve — GET /api/v1/prompts/:slug (id-keyed read)", () => { + const SLUG = "test-prompt"; + const path = `/api/v1/prompts/${SLUG}`; + const get = (headers: Record) => + getTestServer().webapp.fetch(path, { headers }); + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await get({ Authorization: `Bearer ${seed.apiKey}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:prompts (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:prompts"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:prompts:: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`read:prompts:${SLUG}`], + }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:prompts:: not 200 (no access)", async () => { + // Note: the prompts retrieve route has a findResource callback + // that runs BEFORE authorization. Since we don't seed a Prompt + // fixture, the route 404s before reaching the auth check — + // assert "not 200" to capture the no-access semantic without + // depending on whether the guard that fires first is auth (403) + // or findResource (404). Both block the user. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:prompts:some-other-slug"], + }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(200); + }); + + it("JWT read:runs: not 200 (type mismatch — no access)", async () => { + // Same caveat as above re: findResource ordering. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(200); + }); + + it("JWT admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + describe("Prompts override — POST /api/v1/prompts/:slug/override (update action)", () => { + const SLUG = "test-prompt"; + const path = `/api/v1/prompts/${SLUG}/override`; + const post = (headers: Record) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ content: "test" }), + }); + + it("missing auth: 401", async () => { + const res = await post({}); + expect(res.status).toBe(401); + }); + + it("JWT write:prompts: matching (ACTION_ALIASES write→update): passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`write:prompts:${SLUG}`], + }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT write:prompts (type-level): passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:prompts"] }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:prompts: 403 (action mismatch — read NOT aliased to update)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`read:prompts:${SLUG}`], + }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT write:prompts:: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["write:prompts:some-other-slug"], + }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT admin: passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await post({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + describe("Prompts promote/reactivate (sanity, update action)", () => { + it("promote: JWT write:prompts (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:prompts"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch("/api/v1/prompts/some-slug/promote", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` }, + body: JSON.stringify({}), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("reactivate: JWT read:prompts: 403 (action mismatch)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:prompts"] }, + expirationTime: "15m", + }); + // Body must satisfy the route's schema ({ version: positive int }) + // — otherwise body validation 400s before authorization runs. + const res = await server.webapp.fetch( + "/api/v1/prompts/some-slug/override/reactivate", + { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${jwt}` }, + body: JSON.stringify({ version: 1 }), + } + ); + expect(res.status).toBe(403); + }); + }); + + // Deployments + query routes (TRI-8739). Read-only family with + // distinct resource types per route: + // - GET /api/v1/deployments { type: "deployments", id: "list" } + // - GET /api/v1/query/schema { type: "query", id: "schema" } + // - GET /api/v1/query/dashboards { type: "query", id: "dashboards" } + // - POST /api/v1/query body-derived: detectTables(query) → + // [{ type: "query", id }] or + // { type: "query", id: "all" } if none + describe("Deployments list — GET /api/v1/deployments", () => { + const path = "/api/v1/deployments"; + const get = (headers: Record) => + getTestServer().webapp.fetch(path, { headers }); + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + it("private API key: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const res = await get({ Authorization: `Bearer ${seed.apiKey}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:deployments: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:deployments"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:all: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:all"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:runs (type mismatch): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + + it("JWT write:deployments (action mismatch — read route): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:deployments"] }, + expirationTime: "15m", + }); + const res = await get({ Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + }); + + describe("Query schema — GET /api/v1/query/schema (sanity)", () => { + const path = "/api/v1/query/schema"; + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + it("JWT read:query (type-level): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:query"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT read:deployments (type mismatch): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:deployments"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).toBe(403); + }); + }); + + describe("Query dashboards — GET /api/v1/query/dashboards (sanity)", () => { + const path = "/api/v1/query/dashboards"; + + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch(path); + expect(res.status).toBe(401); + }); + + it("JWT read:query: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:query"] }, + expirationTime: "15m", + }); + const res = await server.webapp.fetch(path, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + describe("Query ad-hoc — POST /api/v1/query (body-derived resource)", () => { + const path = "/api/v1/query"; + const post = (body: object, headers: Record) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(body), + }); + + it("missing auth: 401", async () => { + const res = await post({ query: "SELECT * FROM runs" }, {}); + expect(res.status).toBe(401); + }); + + it("body with table 'runs' + JWT read:query:runs: auth passes (any-match)", async () => { + // detectTables pulls 'runs' from FROM-clause. Resource becomes + // [{ type: "query", id: "runs" }]. Scope read:query:runs matches. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:query:runs"] }, + expirationTime: "15m", + }); + const res = await post({ query: "SELECT * FROM runs" }, { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("body with no detectable tables (defaults id='all') + JWT read:query: auth passes", async () => { + // A query with no FROM clause → detectTables returns [] → + // resource is { type: "query", id: "all" }. Type-level read:query + // matches. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:query"] }, + expirationTime: "15m", + }); + const res = await post({ query: "SELECT 1" }, { Authorization: `Bearer ${jwt}` }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("multi-table body + JWT scoped to only one of them: 403 (every-table semantics)", async () => { + // detectTables matches `\bFROM\s+\b` per query-schema, so + // a query with two FROM clauses (e.g. UNION) yields a multi- + // entry resource list. The route wraps it in everyResource so + // AND semantics apply: a JWT scoped to one detected table + // cannot submit a query that also reads the other. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["read:query:runs"] }, + expirationTime: "15m", + }); + const res = await post( + { + query: + "SELECT count() FROM runs UNION ALL SELECT count() FROM metrics", + }, + { Authorization: `Bearer ${jwt}` } + ); + expect(res.status).toBe(403); + }); + + it("multi-table body + JWT scoped to all detected tables: auth passes", async () => { + // Companion to the every-table 403 above — when the JWT covers + // every detected table the AND-check passes. + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:query:runs", "read:query:metrics"], + }, + expirationTime: "15m", + }); + const res = await post( + { + query: + "SELECT count() FROM runs UNION ALL SELECT count() FROM metrics", + }, + { Authorization: `Bearer ${jwt}` } + ); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("body with table 'runs' + JWT read:query:other_table: 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: ["read:query:other_table"], + }, + expirationTime: "15m", + }); + const res = await post({ query: "SELECT * FROM runs" }, { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).toBe(403); + }); + + it("JWT admin: auth passes regardless of body", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["admin"] }, + expirationTime: "15m", + }); + const res = await post({ query: "SELECT * FROM runs" }, { + Authorization: `Bearer ${jwt}`, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("JWT write:query (action mismatch): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { pub: true, sub: seed.environment.id, scopes: ["write:query"] }, + expirationTime: "15m", + }); + const res = await post({ query: "SELECT 1" }, { Authorization: `Bearer ${jwt}` }); + expect(res.status).toBe(403); + }); + }); + + describe("Batch retrieve — GET /realtime/v1/batches/:batchId (sanity)", () => { + it("missing auth: 401", async () => { + const res = await getTestServer().webapp.fetch("/realtime/v1/batches/batch_anything"); + expect(res.status).toBe(401); + }); + + it("JWT read:batch:: auth passes on real batch", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const seeded = await seedTestRun(server.prisma, { + environmentId: seed.environment.id, + projectId: seed.project.id, + withBatch: true, + }); + const jwt = await generateJWT({ + secretKey: seed.apiKey, + payload: { + pub: true, + sub: seed.environment.id, + scopes: [`read:batch:${seeded.batchFriendlyId}`], + }, + expirationTime: "15m", + }); + const res = await getTestServer().webapp.fetch( + `/realtime/v1/batches/${seeded.batchFriendlyId}`, + { headers: { Authorization: `Bearer ${jwt}` } } + ); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // Sessions — JWT scope matrix. + // + // The session routes were authored against the pre-RBAC apiBuilder + // and used the legacy `superScopes: [...]` field to whitelist broad + // access. After TRI-8719 superScopes is dead code; the equivalent + // bypass is expressed via: + // - multi-key resource arrays (one element per addressable key, + // plus a collection-level `{ type: "sessions" }` for type-only + // scopes) + // - the JWT ability's `*:all` and `admin*` wildcard branches + // + // These tests lock in that the migration's "no JWT regresses" + // promise holds for sessions. Each historical superScope becomes a + // positive test, and per-task narrowing gets negative coverage. + describe("Sessions — JWT scope matrix", () => { + // ---- List sessions: GET /api/v1/sessions + // + // Resource: [{ type: "tasks", id: } per filter id, { type: "sessions" }] + // Old superScopes: ["read:sessions", "read:all", "admin"] + describe("List sessions — GET /api/v1/sessions", () => { + const path = (taskFilter?: string) => + taskFilter + ? `/api/v1/sessions?filter[taskIdentifier]=${taskFilter}` + : "/api/v1/sessions"; + + const fetchWithJwt = async (jwt: string, taskFilter?: string) => + getTestServer().webapp.fetch(path(taskFilter), { + headers: { Authorization: `Bearer ${jwt}` }, + }); + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + // The handler reads from ClickHouse via SessionsRepository, which + // isn't wired up in the e2e webapp container — so successful auth + // surfaces as 5xx after the handler errors. Assert "not 401, not + // 403" rather than 200 for the auth-passes paths. + + it("read:tasks:foo on filter=foo: auth passes", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:tasks:foo", + ]); + const res = await fetchWithJwt(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("read:tasks:bar on filter=foo: 403 (per-task narrowing)", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:tasks:bar", + ]); + const res = await fetchWithJwt(jwt, "foo"); + expect(res.status).toBe(403); + }); + + it("read:sessions on filter=foo: auth passes (was a superScope)", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:sessions", + ]); + const res = await fetchWithJwt(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("read:sessions on no-filter list: auth passes", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:sessions", + ]); + const res = await fetchWithJwt(jwt); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("read:all: auth passes (was a superScope)", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:all"]); + const res = await fetchWithJwt(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("admin: auth passes (was a superScope)", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]); + const res = await fetchWithJwt(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("read:tasks (type-only) on no-filter list: 403 (filter is sessions, not tasks)", async () => { + // No filter → resource is `{ type: "sessions" }` only. read:tasks + // doesn't match the sessions type, so 403 — explicit narrowing. + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:tasks", + ]); + const res = await fetchWithJwt(jwt); + expect(res.status).toBe(403); + }); + + it("write:tasks:foo (wrong action) on filter=foo: 403", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:tasks:foo", + ]); + const res = await fetchWithJwt(jwt, "foo"); + expect(res.status).toBe(403); + }); + }); + + // ---- Create session: POST /api/v1/sessions + // + // Resource: [{ type: "tasks", id: body.taskIdentifier }, { type: "sessions" }] + // Old superScopes: ["write:sessions", "admin"] + describe("Create session — POST /api/v1/sessions", () => { + const path = "/api/v1/sessions"; + + const post = async (jwt: string, taskIdentifier: string) => + getTestServer().webapp.fetch(path, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + type: "chat.agent", + taskIdentifier, + triggerConfig: { basePayload: {} }, + }), + }); + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("write:tasks:foo matching body: auth passes", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:tasks:foo", + ]); + const res = await post(jwt, "foo"); + // Body validation / handler can fail later (404 if task is + // missing, 400 for invalid body) — we only care that auth + // didn't reject. + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("write:tasks:bar mismatching body: 403", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:tasks:bar", + ]); + const res = await post(jwt, "foo"); + expect(res.status).toBe(403); + }); + + it("write:sessions: auth passes (was a superScope)", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:sessions", + ]); + const res = await post(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("write:all: auth passes", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:all"]); + const res = await post(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("admin: auth passes", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]); + const res = await post(jwt, "foo"); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("read:tasks:foo (wrong action): 403", async () => { + const seed = await seedTestEnvironment(getTestServer().prisma); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:tasks:foo", + ]); + const res = await post(jwt, "foo"); + expect(res.status).toBe(403); + }); + }); + + // ---- Retrieve session: GET /api/v1/sessions/:session + // + // Resource: multi-key array of `{ type: "sessions", id }` entries + // for friendlyId and externalId (when set). + // Old superScopes: ["read:sessions", "read:all", "admin"] + describe("Retrieve session — GET /api/v1/sessions/:session", () => { + const get = async (sessionParam: string, jwt: string) => + getTestServer().webapp.fetch(`/api/v1/sessions/${sessionParam}`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("read:sessions:: 200", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + `read:sessions:${session.friendlyId}`, + ]); + const res = await get(session.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("read:sessions: on externalId URL form: 200 (multi-key)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + `read:sessions:${session.externalId}`, + ]); + const res = await get(session.externalId!, jwt); + expect(res.status).toBe(200); + }); + + it("read:sessions (type-only): 200", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:sessions", + ]); + const res = await get(session.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("read:sessions:other (non-matching id): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:sessions:not-this-session", + ]); + const res = await get(session.friendlyId, jwt); + expect(res.status).toBe(403); + }); + }); + + // ---- Update session: PATCH /api/v1/sessions/:session + // + // action: "admin" — only admin-tier scopes (or wildcards) satisfy. + // Old superScopes: ["admin:sessions", "admin:all", "admin"] + describe("Update session — PATCH /api/v1/sessions/:session", () => { + const patch = async (sessionParam: string, jwt: string) => + getTestServer().webapp.fetch(`/api/v1/sessions/${sessionParam}`, { + method: "PATCH", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ tags: ["updated"] }), + }); + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("admin:sessions:: 200", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + `admin:sessions:${session.friendlyId}`, + ]); + const res = await patch(session.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("admin:sessions (type-only): 200", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "admin:sessions", + ]); + const res = await patch(session.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("admin:all: 200", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin:all"]); + const res = await patch(session.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("admin (bare): 200", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]); + const res = await patch(session.friendlyId, jwt); + expect(res.status).toBe(200); + }); + + it("write:sessions (wrong action — admin not aliased from write): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:sessions", + ]); + const res = await patch(session.friendlyId, jwt); + expect(res.status).toBe(403); + }); + }); + + // ---- Close session: POST /api/v1/sessions/:session/close + // + // action: "admin" — same matrix as PATCH. + describe("Close session — POST /api/v1/sessions/:session/close", () => { + const close = async (sessionParam: string, jwt: string) => + getTestServer().webapp.fetch( + `/api/v1/sessions/${sessionParam}/close`, + { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ reason: "test" }), + } + ); + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("admin:sessions: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "admin:sessions", + ]); + const res = await close(session.friendlyId, jwt); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("admin: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["admin"]); + const res = await close(session.friendlyId, jwt); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("write:sessions: 403 (admin action)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:sessions", + ]); + const res = await close(session.friendlyId, jwt); + expect(res.status).toBe(403); + }); + }); + + // ---- End-and-continue: POST /api/v1/sessions/:session/end-and-continue + // + // action: "write" — multi-key sessions resource. + describe("End-and-continue — POST /api/v1/sessions/:session/end-and-continue", () => { + const endAndContinue = async (sessionParam: string, jwt: string) => + getTestServer().webapp.fetch( + `/api/v1/sessions/${sessionParam}/end-and-continue`, + { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + // Body shape doesn't matter for auth — handler runs after + // the auth check so any 4xx here means auth passed. + body: JSON.stringify({ + reason: "test", + callingRunId: "run_does_not_exist", + }), + } + ); + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("write:sessions: auth passes (was a superScope)", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:sessions", + ]); + const res = await endAndContinue(session.friendlyId, jwt); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("write:all: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:all"]); + const res = await endAndContinue(session.friendlyId, jwt); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("read:sessions (wrong action): 403", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:sessions", + ]); + const res = await endAndContinue(session.friendlyId, jwt); + expect(res.status).toBe(403); + }); + }); + + // ---- Realtime IO: GET (subscribe) and PUT (initialize) + // + // Both go through createLoaderApiRoute / createActionApiRoute — same + // multi-key sessions resource. No deep matrix here; one positive + // test per old superScope per method is enough. + describe("Realtime IO — /realtime/v1/sessions/:session/:io", () => { + const ioPath = (sessionParam: string) => + `/realtime/v1/sessions/${sessionParam}/in`; + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("GET with read:sessions (was a superScope): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "read:sessions", + ]); + const res = await server.webapp.fetch(ioPath(session.friendlyId), { + method: "HEAD", + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("GET with read:all: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["read:all"]); + const res = await server.webapp.fetch(ioPath(session.friendlyId), { + method: "HEAD", + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("PUT with write:sessions (was a superScope): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:sessions", + ]); + const res = await server.webapp.fetch(ioPath(session.friendlyId), { + method: "PUT", + headers: { Authorization: `Bearer ${jwt}` }, + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + + // ---- Realtime append: POST /realtime/v1/sessions/:session/:io/append + // + // action: "write" — multi-key sessions resource. + describe("Realtime append — POST /realtime/v1/sessions/:session/:io/append", () => { + const appendPath = (sessionParam: string) => + `/realtime/v1/sessions/${sessionParam}/in/append`; + + const mintJwt = async (apiKey: string, envId: string, scopes: string[]) => + generateJWT({ + secretKey: apiKey, + payload: { pub: true, sub: envId, scopes }, + expirationTime: "15m", + }); + + it("write:sessions (was a superScope): auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, [ + "write:sessions", + ]); + const res = await server.webapp.fetch(appendPath(session.friendlyId), { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/octet-stream", + }, + body: new Uint8Array([1, 2, 3]), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("write:all: auth passes", async () => { + const server = getTestServer(); + const seed = await seedTestEnvironment(server.prisma); + const session = await seedTestApiSession(server.prisma, seed.environment); + const jwt = await mintJwt(seed.apiKey, seed.environment.id, ["write:all"]); + const res = await server.webapp.fetch(appendPath(session.friendlyId), { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/octet-stream", + }, + body: new Uint8Array([1, 2, 3]), + }); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + }); + }); +}); diff --git a/apps/webapp/test/auth-cross-cutting.e2e.full.test.ts b/apps/webapp/test/auth-cross-cutting.e2e.full.test.ts new file mode 100644 index 00000000000..d5d462f6c32 --- /dev/null +++ b/apps/webapp/test/auth-cross-cutting.e2e.full.test.ts @@ -0,0 +1,216 @@ +// Cross-cutting auth-layer behaviours that aren't tied to a specific route +// family — see TRI-8743. Soft-deleted projects, revoked keys, expired JWTs, +// cross-env mismatch, force-fallback toggle. +// +// Strategy: pick one representative API-key route +// (GET /api/v1/runs/run_doesnotexist/result) and one representative JWT +// route (POST /api/v1/waitpoints/tokens//complete) and exercise the +// edge cases against those. The route choice doesn't matter — the +// auth layer is shared across every API route via apiBuilder.server.ts. +// Smoke matrix (api-auth.e2e.test.ts) already covers the trivial +// cases (missing/invalid key, basic JWT pass, soft-deleted project); +// this file adds cases that need explicit fixture setup. + +import { generateJWT } from "@trigger.dev/core/v3/jwt"; +import { SignJWT } from "jose"; +import { describe, expect, it } from "vitest"; +import { getTestServer } from "./helpers/sharedTestServer"; +import { seedTestEnvironment } from "./helpers/seedTestEnvironment"; + +describe("Cross-cutting", () => { + it("shared prisma client can read from the postgres container", async () => { + const server = getTestServer(); + const count = await server.prisma.user.count(); + expect(count).toBeGreaterThanOrEqual(0); + }); + + // The auth path falls back to RevokedApiKey when a key isn't found + // in RuntimeEnvironment — letting customers continue to use a key + // for a configurable grace window after rotation. See + // models/runtimeEnvironment.server.ts. The grace lookup matches by + // (apiKey AND expiresAt > now) and rehydrates the env via the FK. + describe("Revoked API key grace window", () => { + const route = "/api/v1/runs/run_doesnotexist/result"; + + it("revoked key within grace (expiresAt > now): auth passes", async () => { + const server = getTestServer(); + const { environment } = await seedTestEnvironment(server.prisma); + // Mint a fresh "rotated" key that doesn't exist on any env, then + // record it as recently revoked with a future grace expiry. + const rotatedKey = `tr_dev_rotated_${Math.random().toString(36).slice(2)}`; + await server.prisma.revokedApiKey.create({ + data: { + apiKey: rotatedKey, + runtimeEnvironmentId: environment.id, + expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // +1 day + }, + }); + const res = await server.webapp.fetch(route, { + headers: { Authorization: `Bearer ${rotatedKey}` }, + }); + // Auth passed — the route's resource lookup just doesn't find + // run_doesnotexist. The point is NOT 401. + expect(res.status).not.toBe(401); + }); + + it("revoked key past grace (expiresAt < now): 401", async () => { + const server = getTestServer(); + const { environment } = await seedTestEnvironment(server.prisma); + const expiredKey = `tr_dev_expired_${Math.random().toString(36).slice(2)}`; + await server.prisma.revokedApiKey.create({ + data: { + apiKey: expiredKey, + runtimeEnvironmentId: environment.id, + expiresAt: new Date(Date.now() - 60 * 1000), // -1 minute + }, + }); + const res = await server.webapp.fetch(route, { + headers: { Authorization: `Bearer ${expiredKey}` }, + }); + expect(res.status).toBe(401); + }); + }); + + // JWT edge cases beyond what the smoke matrix covers (which only + // checks "wrong key" and "missing scope"). All target the same + // representative JWT route — the JWT validator is shared across + // routes via apiBuilder, so coverage here generalises. + describe("JWT edge cases", () => { + const route = "/api/v1/waitpoints/tokens/wp_does_not_exist/complete"; + + async function postWithJwt(jwt: string) { + const server = getTestServer(); + return server.webapp.fetch(route, { + method: "POST", + headers: { + Authorization: `Bearer ${jwt}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }); + } + + it("JWT with expirationTime in the past: 401", async () => { + const server = getTestServer(); + const { environment } = await seedTestEnvironment(server.prisma); + // generateJWT only accepts string expirationTimes (relative, like + // "15m"). To create a definitively-expired token use jose + // directly with an absolute past timestamp. + const secret = new TextEncoder().encode(environment.apiKey); + const jwt = await new SignJWT({ + pub: true, + sub: environment.id, + scopes: ["write:waitpoints"], + }) + .setIssuer("https://id.trigger.dev") + .setAudience("https://api.trigger.dev") + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(0) + .setExpirationTime(1) // 1970-01-01 — definitively expired + .sign(secret); + + const res = await postWithJwt(jwt); + expect(res.status).toBe(401); + }); + + it("JWT with pub: false: 401", async () => { + const server = getTestServer(); + const { environment } = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: false, sub: environment.id, scopes: ["write:waitpoints"] }, + expirationTime: "15m", + }); + // pub: false means "this token isn't meant for client-side use" + // — the auth layer rejects it for the same-class JWT routes. + const res = await postWithJwt(jwt); + expect(res.status).toBe(401); + }); + + it("JWT with no sub claim: 401", async () => { + const server = getTestServer(); + const { environment } = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: environment.apiKey, + payload: { pub: true, scopes: ["write:waitpoints"] }, + expirationTime: "15m", + }); + // No sub claim — auth can't resolve which env the token belongs + // to, so it must reject. (sub carries the env id.) + const res = await postWithJwt(jwt); + expect(res.status).toBe(401); + }); + + it("JWT signed with another env's apiKey (cross-env): 401", async () => { + const server = getTestServer(); + // env A's id but signed with env B's apiKey — sub-vs-signature + // mismatch the auth layer must catch. + const a = await seedTestEnvironment(server.prisma); + const b = await seedTestEnvironment(server.prisma); + const jwt = await generateJWT({ + secretKey: b.apiKey, // <-- WRONG key relative to the sub claim + payload: { pub: true, sub: a.environment.id, scopes: ["write:waitpoints"] }, + expirationTime: "15m", + }); + const res = await postWithJwt(jwt); + expect(res.status).toBe(401); + }); + + it("JWT malformed (three parts but invalid base64 in payload): 401", async () => { + // Three "."-separated parts so the JWT shape gate sees it as a + // candidate, but the payload segment is non-base64 garbage. + // Validator must surface this as 401, not 500. + const malformed = "eyJhbGciOiJIUzI1NiJ9.@@@notbase64@@@.signature"; + const res = await postWithJwt(malformed); + expect(res.status).toBe(401); + }); + }); + + // The auth layer resolves the JWT's env from the `sub` claim — NOT + // from the route path. So a JWT for env A hitting a route that + // fetches a resource from env B should never accidentally see env + // B's data. Test by minting a JWT for env A and asking for a + // resource that lives in env B — expect 404 (not 200). + describe("Cross-environment: JWT auth resolves env from sub, not URL", () => { + it("env A's JWT cannot read env B's resource: 404", async () => { + const server = getTestServer(); + const a = await seedTestEnvironment(server.prisma); + const b = await seedTestEnvironment(server.prisma); + + // Seed a real-ish run row in env B so the route would have + // something to find IF auth resolved the env from the URL. + const friendlyId = `run_${Math.random().toString(36).slice(2, 10)}`; + await server.prisma.taskRun.create({ + data: { + friendlyId, + taskIdentifier: "test-task", + payload: "{}", + payloadType: "application/json", + traceId: `trace_${Math.random().toString(36).slice(2)}`, + spanId: `span_${Math.random().toString(36).slice(2)}`, + runtimeEnvironmentId: b.environment.id, + projectId: b.project.id, + organizationId: b.organization.id, + engine: "V2", + status: "COMPLETED_SUCCESSFULLY", + queue: "task/test-task", + }, + }); + + const jwt = await generateJWT({ + secretKey: a.apiKey, + payload: { pub: true, sub: a.environment.id, scopes: ["read:runs"] }, + expirationTime: "15m", + }); + + const res = await server.webapp.fetch(`/api/v1/runs/${friendlyId}/result`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + // The route resolves runs scoped to the JWT's env (env A). The + // run lives in env B, so env A's view returns "not found" — + // critically, NOT 200. + expect(res.status).not.toBe(200); + expect([401, 404]).toContain(res.status); + }); + }); +}); diff --git a/apps/webapp/test/auth-dashboard.e2e.full.test.ts b/apps/webapp/test/auth-dashboard.e2e.full.test.ts new file mode 100644 index 00000000000..948b9c6c0cf --- /dev/null +++ b/apps/webapp/test/auth-dashboard.e2e.full.test.ts @@ -0,0 +1,122 @@ +// Comprehensive dashboard session-auth tests — see TRI-8742. +// Each test seeds a User + session cookie via seedTestUser / seedTestSession +// (helpers/seedTestSession.ts) and hits the shared webapp container. + +import { describe, expect, it } from "vitest"; +import { getTestServer } from "./helpers/sharedTestServer"; +import { seedTestSession, seedTestUser } from "./helpers/seedTestSession"; + +describe("Dashboard", () => { + it("shared webapp container redirects /admin/concurrency to /login when unauthenticated", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch("/admin/concurrency", { redirect: "manual" }); + expect(res.status).toBe(302); + }); + + // Admin pages migrated to dashboardLoader({ authorization: { requireSuper: true } }) + // in TRI-8717. The dashboardLoader resolves auth in three stages: + // 1. No session → redirect to /login?redirectTo=. + // 2. Session, user.admin === false → redirect to / (no path leakage). + // 3. Session, user.admin === true → run the loader handler. + // + // Coverage strategy: pick three representative routes (the index, a + // tabbed sub-page, and the back-office tree) rather than all 14 — + // they all share the same dashboardLoader config so testing every + // file would just confirm the wrapper works, which the harness + // already proves. If the wrapper config drifts per-route in the + // future, add targeted tests for the divergent ones. + describe("Admin pages — requireSuper gate", () => { + const adminRoutes = [ + "/admin", + "/admin/concurrency", + "/admin/back-office", + ]; + + for (const path of adminRoutes) { + describe(`GET ${path}`, () => { + it("no session: redirects to /login?redirectTo=", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(path, { redirect: "manual" }); + expect(res.status).toBe(302); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/login"); + // Path leaks deliberately so a successful login bounces the + // user back to where they were headed. + expect(location).toContain(`redirectTo=${encodeURIComponent(path)}`); + }); + + it("session for non-admin user: redirects to / (no path leakage)", async () => { + const server = getTestServer(); + const user = await seedTestUser(server.prisma, { admin: false }); + const cookie = await seedTestSession({ userId: user.id }); + const res = await server.webapp.fetch(path, { + redirect: "manual", + headers: { Cookie: cookie }, + }); + expect(res.status).toBe(302); + const location = res.headers.get("location") ?? ""; + // unauthorizedRedirect default in dashboardBuilder is "/". + // A non-admin landing on /admin shouldn't get redirectTo + // back to /admin once they upgrade — they're not getting in + // by re-auth. + expect(new URL(location, "http://localhost").pathname).toBe("/"); + }); + + it("session for admin user: 2xx", async () => { + const server = getTestServer(); + const user = await seedTestUser(server.prisma, { admin: true }); + const cookie = await seedTestSession({ userId: user.id }); + const res = await server.webapp.fetch(path, { + redirect: "manual", + headers: { Cookie: cookie }, + }); + // Loader handler ran — could be 200 (HTML) or 204 (Remix + // _data fetch). Either way, NOT a redirect. + expect(res.status).toBeLessThan(300); + }); + }); + } + }); + + // Action handlers behind requireSuper used to return 403 Unauthorized + // pre-RBAC — now they redirect to / via dashboardAction's + // unauthorizedRedirect. The ticket flagged this as a behaviour + // change worth locking in (any XHR fetcher that branched on 403 + // would have regressed silently). Use admin.feature-flags POST as + // the canary — it's the simplest action of the bunch. + describe("Admin action — requireSuper gate (admin.feature-flags POST)", () => { + const path = "/admin/feature-flags"; + + it("no session: redirects to /login (POST)", async () => { + const server = getTestServer(); + const res = await server.webapp.fetch(path, { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json" }, + redirect: "manual", + }); + expect(res.status).toBe(302); + const location = res.headers.get("location") ?? ""; + expect(location).toContain("/login"); + }); + + it("session for non-admin user: redirects to / (was 403 pre-RBAC)", async () => { + const server = getTestServer(); + const user = await seedTestUser(server.prisma, { admin: false }); + const cookie = await seedTestSession({ userId: user.id }); + const res = await server.webapp.fetch(path, { + method: "POST", + body: JSON.stringify({}), + headers: { "Content-Type": "application/json", Cookie: cookie }, + redirect: "manual", + }); + // Behaviour change from the TRI-8717 migration: the legacy + // path returned 403 Unauthorized; dashboardAction returns a + // 302 to "/" instead. Any client code branching on 403 needs + // updating — locking this in so a silent regression is loud. + expect(res.status).toBe(302); + const location = res.headers.get("location") ?? ""; + expect(new URL(location, "http://localhost").pathname).toBe("/"); + }); + }); +}); diff --git a/apps/webapp/test/authorization.test.ts b/apps/webapp/test/authorization.test.ts deleted file mode 100644 index 1dc574966f5..00000000000 --- a/apps/webapp/test/authorization.test.ts +++ /dev/null @@ -1,423 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { checkAuthorization, AuthorizationEntity } from "../app/services/authorization.server"; - -describe("checkAuthorization", () => { - // Test entities - const privateEntity: AuthorizationEntity = { type: "PRIVATE" }; - const publicEntity: AuthorizationEntity = { type: "PUBLIC" }; - const publicJwtEntityWithPermissions: AuthorizationEntity = { - type: "PUBLIC_JWT", - scopes: ["read:runs:run_1234", "read:tasks", "read:tags:tag_5678"], - }; - const publicJwtEntityNoPermissions: AuthorizationEntity = { type: "PUBLIC_JWT" }; - const publicJwtEntityWithTaskWritePermissions: AuthorizationEntity = { - type: "PUBLIC_JWT", - scopes: ["write:tasks:task-1"], - }; - - describe("PRIVATE entity", () => { - it("should always return authorized regardless of action or resource", () => { - const result1 = checkAuthorization(privateEntity, "read", { runs: "run_1234" }); - expect(result1.authorized).toBe(true); - expect(result1).not.toHaveProperty("reason"); - - const result2 = checkAuthorization(privateEntity, "read", { tasks: ["task_1", "task_2"] }); - expect(result2.authorized).toBe(true); - expect(result2).not.toHaveProperty("reason"); - - const result3 = checkAuthorization(privateEntity, "read", { tags: "nonexistent_tag" }); - expect(result3.authorized).toBe(true); - expect(result3).not.toHaveProperty("reason"); - }); - }); - - describe("PUBLIC entity", () => { - it("should always return unauthorized with reason regardless of action or resource", () => { - const result1 = checkAuthorization(publicEntity, "read", { runs: "run_1234" }); - expect(result1.authorized).toBe(false); - if (!result1.authorized) { - expect(result1.reason).toBe("PUBLIC type is deprecated and has no access"); - } - - const result2 = checkAuthorization(publicEntity, "read", { tasks: ["task_1", "task_2"] }); - expect(result2.authorized).toBe(false); - if (!result2.authorized) { - expect(result2.reason).toBe("PUBLIC type is deprecated and has no access"); - } - - const result3 = checkAuthorization(publicEntity, "read", { tags: "tag_5678" }); - expect(result3.authorized).toBe(false); - if (!result3.authorized) { - expect(result3.reason).toBe("PUBLIC type is deprecated and has no access"); - } - }); - }); - - describe("PUBLIC_JWT entity with task write scope", () => { - it("should return authorized for specific resource scope", () => { - const result = checkAuthorization(publicJwtEntityWithTaskWritePermissions, "write", { - tasks: "task-1", - }); - expect(result.authorized).toBe(true); - expect(result).not.toHaveProperty("reason"); - }); - - it("should return unauthorized with reason for unauthorized specific resources", () => { - const result = checkAuthorization(publicJwtEntityWithTaskWritePermissions, "write", { - tasks: "task-2", - }); - expect(result.authorized).toBe(false); - if (!result.authorized) { - expect(result.reason).toBe( - "Public Access Token is missing required permissions. Token has the following permissions: 'write:tasks:task-1'. See https://trigger.dev/docs/frontend/overview#authentication for more information." - ); - } - }); - }); - - describe("PUBLIC_JWT entity with scope", () => { - it("should return authorized for specific resource scope", () => { - const result = checkAuthorization(publicJwtEntityWithPermissions, "read", { - runs: "run_1234", - }); - expect(result.authorized).toBe(true); - expect(result).not.toHaveProperty("reason"); - }); - - it("should return unauthorized with reason for unauthorized specific resources", () => { - const result = checkAuthorization(publicJwtEntityWithPermissions, "read", { - runs: "run_5678", - }); - expect(result.authorized).toBe(false); - if (!result.authorized) { - expect(result.reason).toBe( - "Public Access Token is missing required permissions. Token has the following permissions: 'read:runs:run_1234', 'read:tasks', 'read:tags:tag_5678'. See https://trigger.dev/docs/frontend/overview#authentication for more information." - ); - } - }); - - it("should return authorized for general resource type scope", () => { - const result1 = checkAuthorization(publicJwtEntityWithPermissions, "read", { - tasks: "task_1234", - }); - expect(result1.authorized).toBe(true); - expect(result1).not.toHaveProperty("reason"); - - const result2 = checkAuthorization(publicJwtEntityWithPermissions, "read", { - tasks: ["task_5678", "task_9012"], - }); - expect(result2.authorized).toBe(true); - expect(result2).not.toHaveProperty("reason"); - }); - - it("should return authorized if any resource in an array is authorized", () => { - const result = checkAuthorization(publicJwtEntityWithPermissions, "read", { - tags: ["tag_1234", "tag_5678"], - }); - expect(result.authorized).toBe(true); - expect(result).not.toHaveProperty("reason"); - }); - - it("should return authorized for nonexistent resource types", () => { - const result = checkAuthorization(publicJwtEntityWithPermissions, "read", { - // @ts-expect-error - nonexistent: "resource", - }); - expect(result.authorized).toBe(false); - expect(result).toHaveProperty("reason"); - }); - }); - - describe("PUBLIC_JWT entity without scope", () => { - it("should always return unauthorized with reason regardless of action or resource", () => { - const result1 = checkAuthorization(publicJwtEntityNoPermissions, "read", { - runs: "run_1234", - }); - expect(result1.authorized).toBe(false); - if (!result1.authorized) { - expect(result1.reason).toBe( - "Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information." - ); - } - - const result2 = checkAuthorization(publicJwtEntityNoPermissions, "read", { - tasks: ["task_1", "task_2"], - }); - expect(result2.authorized).toBe(false); - if (!result2.authorized) { - expect(result2.reason).toBe( - "Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information." - ); - } - - const result3 = checkAuthorization(publicJwtEntityNoPermissions, "read", { - tags: "tag_5678", - }); - expect(result3.authorized).toBe(false); - if (!result3.authorized) { - expect(result3.reason).toBe( - "Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information." - ); - } - }); - }); - - describe("Edge cases", () => { - it("should handle empty resource objects", () => { - const result = checkAuthorization(publicJwtEntityWithPermissions, "read", {}); - expect(result.authorized).toBe(false); - if (!result.authorized) { - expect(result.reason).toBe("Resource object is empty"); - } - }); - - it("should handle undefined scope", () => { - const entityUndefinedPermissions: AuthorizationEntity = { type: "PUBLIC_JWT" }; - const result = checkAuthorization(entityUndefinedPermissions, "read", { runs: "run_1234" }); - expect(result.authorized).toBe(false); - if (!result.authorized) { - expect(result.reason).toBe( - "Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information." - ); - } - }); - - it("should handle empty scope array", () => { - const entityEmptyPermissions: AuthorizationEntity = { type: "PUBLIC_JWT", scopes: [] }; - const result = checkAuthorization(entityEmptyPermissions, "read", { runs: "run_1234" }); - expect(result.authorized).toBe(false); - if (!result.authorized) { - expect(result.reason).toBe( - "Public Access Token has no permissions. See https://trigger.dev/docs/frontend/overview#authentication for more information." - ); - } - }); - - it("should return authorized if any resource is authorized", () => { - const result = checkAuthorization(publicJwtEntityWithPermissions, "read", { - runs: "run_1234", // This is authorized - tasks: "task_5678", // This is authorized (general permission) - tags: "tag_3456", // This is not authorized - }); - expect(result.authorized).toBe(true); - expect(result).not.toHaveProperty("reason"); - }); - - it("should return unauthorized only if no resources are authorized", () => { - const result = checkAuthorization(publicJwtEntityWithPermissions, "read", { - runs: "run_5678", // Not authorized - tags: "tag_3456", // Not authorized - }); - expect(result.authorized).toBe(false); - if (!result.authorized) { - expect(result.reason).toContain("Public Access Token is missing required permissions"); - } - }); - }); - - describe("Super scope", () => { - const entityWithSuperPermissions: AuthorizationEntity = { - type: "PUBLIC_JWT", - scopes: ["read:all", "admin"], - }; - - const entityWithOneSuperPermission: AuthorizationEntity = { - type: "PUBLIC_JWT", - scopes: ["read:all"], - }; - - it("should grant access with any of the super scope", () => { - const result1 = checkAuthorization( - entityWithSuperPermissions, - "read", - { tasks: "task_1234" }, - ["read:all", "admin"] - ); - expect(result1.authorized).toBe(true); - expect(result1).not.toHaveProperty("reason"); - - const result2 = checkAuthorization( - entityWithSuperPermissions, - "read", - { tags: ["tag_1", "tag_2"] }, - ["write:all", "admin"] - ); - expect(result2.authorized).toBe(true); - expect(result2).not.toHaveProperty("reason"); - }); - - it("should grant access with one matching super permission", () => { - const result = checkAuthorization( - entityWithOneSuperPermission, - "read", - { runs: "run_5678" }, - ["read:all", "admin"] - ); - expect(result.authorized).toBe(true); - expect(result).not.toHaveProperty("reason"); - }); - - it("should not grant access when no super scope match", () => { - const result = checkAuthorization( - entityWithOneSuperPermission, - "read", - { tasks: "task_1234" }, - ["write:all", "admin"] - ); - expect(result.authorized).toBe(false); - if (!result.authorized) { - expect(result.reason).toBe( - "Public Access Token is missing required permissions. Token has the following permissions: 'read:all'. See https://trigger.dev/docs/frontend/overview#authentication for more information." - ); - } - }); - - it("should grant access to multiple resources with super scope", () => { - const result = checkAuthorization( - entityWithSuperPermissions, - "read", - { - tasks: "task_1234", - tags: ["tag_1", "tag_2"], - runs: "run_5678", - }, - ["read:all"] - ); - expect(result.authorized).toBe(true); - expect(result).not.toHaveProperty("reason"); - }); - - it("should fall back to specific scope when super scope are not provided", () => { - const entityWithSpecificPermissions: AuthorizationEntity = { - type: "PUBLIC_JWT", - scopes: ["read:tasks", "read:tags"], - }; - const result1 = checkAuthorization(entityWithSpecificPermissions, "read", { - tasks: "task_1234", - }); - expect(result1.authorized).toBe(true); - expect(result1).not.toHaveProperty("reason"); - - const result2 = checkAuthorization(entityWithSpecificPermissions, "read", { - runs: "run_5678", - }); - expect(result2.authorized).toBe(false); - if (!result2.authorized) { - expect(result2.reason).toBe( - "Public Access Token is missing required permissions. Token has the following permissions: 'read:tasks', 'read:tags'. See https://trigger.dev/docs/frontend/overview#authentication for more information." - ); - } - }); - }); - - describe("Query resource type", () => { - it("should grant access with read:query super scope", () => { - const entity: AuthorizationEntity = { - type: "PUBLIC_JWT", - scopes: ["read:query"], - }; - const result = checkAuthorization( - entity, - "read", - { query: "runs" }, - ["read:query", "read:all", "admin"] - ); - expect(result.authorized).toBe(true); - }); - - it("should grant access with table-specific query scope", () => { - const entity: AuthorizationEntity = { - type: "PUBLIC_JWT", - scopes: ["read:query:runs"], - }; - const result = checkAuthorization(entity, "read", { query: "runs" }); - expect(result.authorized).toBe(true); - }); - - it("should deny access to different table with table-specific scope", () => { - const entity: AuthorizationEntity = { - type: "PUBLIC_JWT", - scopes: ["read:query:runs"], - }; - const result = checkAuthorization(entity, "read", { query: "llm_metrics" }); - expect(result.authorized).toBe(false); - }); - - it("should grant access with general read:query scope to any table", () => { - const entity: AuthorizationEntity = { - type: "PUBLIC_JWT", - scopes: ["read:query"], - }; - - const runsResult = checkAuthorization(entity, "read", { query: "runs" }); - expect(runsResult.authorized).toBe(true); - - const metricsResult = checkAuthorization(entity, "read", { query: "metrics" }); - expect(metricsResult.authorized).toBe(true); - - const llmResult = checkAuthorization(entity, "read", { query: "llm_metrics" }); - expect(llmResult.authorized).toBe(true); - }); - - it("should grant access to multiple tables when querying with super scope", () => { - const entity: AuthorizationEntity = { - type: "PUBLIC_JWT", - scopes: ["read:query"], - }; - const result = checkAuthorization( - entity, - "read", - { query: ["runs", "llm_metrics"] }, - ["read:query", "read:all", "admin"] - ); - expect(result.authorized).toBe(true); - }); - - it("should grant access to schema with read:query scope", () => { - const entity: AuthorizationEntity = { - type: "PUBLIC_JWT", - scopes: ["read:query"], - }; - const result = checkAuthorization( - entity, - "read", - { query: "schema" }, - ["read:query", "read:all", "admin"] - ); - expect(result.authorized).toBe(true); - }); - }); - - describe("Without super scope", () => { - const entityWithoutSuperPermissions: AuthorizationEntity = { - type: "PUBLIC_JWT", - scopes: ["read:tasks"], - }; - - it("should still grant access based on specific scope", () => { - const result = checkAuthorization( - entityWithoutSuperPermissions, - "read", - { tasks: "task_1234" }, - ["read:all", "admin"] - ); - expect(result.authorized).toBe(true); - expect(result).not.toHaveProperty("reason"); - }); - - it("should deny access to resources not in scope", () => { - const result = checkAuthorization( - entityWithoutSuperPermissions, - "read", - { runs: "run_5678" }, - ["read:all", "admin"] - ); - expect(result.authorized).toBe(false); - if (!result.authorized) { - expect(result.reason).toBe( - "Public Access Token is missing required permissions. Token has the following permissions: 'read:tasks'. See https://trigger.dev/docs/frontend/overview#authentication for more information." - ); - } - }); - }); -}); diff --git a/apps/webapp/test/helpers/seedTestApiSession.ts b/apps/webapp/test/helpers/seedTestApiSession.ts new file mode 100644 index 00000000000..cb98c1798c9 --- /dev/null +++ b/apps/webapp/test/helpers/seedTestApiSession.ts @@ -0,0 +1,47 @@ +// Inserts a `Session` row directly via Prisma so route auth tests can +// exercise routes that resolve a session by friendlyId or externalId. +// +// Note: not to be confused with `seedTestSession` in this directory — +// that helper builds a *dashboard cookie session* for cookie-auth tests. +// This helper builds an *agent-stream Session row* (the chat.agent +// runtime concept). + +import type { PrismaClient, Session } from "@trigger.dev/database"; +import { randomBytes } from "node:crypto"; + +function randomHex(len = 12): string { + return randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len); +} + +export async function seedTestApiSession( + prisma: PrismaClient, + env: { + id: string; + type: string; + organizationId: string; + projectId: string; + }, + overrides?: { taskIdentifier?: string; externalId?: string | null } +): Promise { + const suffix = randomHex(8); + return prisma.session.create({ + data: { + id: `session_${suffix}`, + friendlyId: `session_${suffix}`, + // `null` lets a caller exercise the externalId-absent code path + // (single-id auth resource); omit the override to get a unique + // externalId for the multi-key path. + externalId: + overrides?.externalId === null + ? null + : overrides?.externalId ?? `ext_${suffix}`, + type: "chat.agent", + projectId: env.projectId, + runtimeEnvironmentId: env.id, + environmentType: env.type as Session["environmentType"], + organizationId: env.organizationId, + taskIdentifier: overrides?.taskIdentifier ?? `agent_${suffix}`, + triggerConfig: { basePayload: { messages: [], trigger: "preload" } }, + }, + }); +} diff --git a/apps/webapp/test/helpers/seedTestPAT.ts b/apps/webapp/test/helpers/seedTestPAT.ts new file mode 100644 index 00000000000..d977bf5882e --- /dev/null +++ b/apps/webapp/test/helpers/seedTestPAT.ts @@ -0,0 +1,59 @@ +import type { PrismaClient } from "@trigger.dev/database"; +import { createCipheriv, createHash, randomBytes } from "node:crypto"; + +// Must match ENCRYPTION_KEY in internal-packages/testcontainers/src/webapp.ts +const ENCRYPTION_KEY = "test-encryption-key-for-e2e!!!!!"; + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +function encryptToken(value: string, key: string) { + const nonce = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, nonce); + let encrypted = cipher.update(value, "utf8", "hex"); + encrypted += cipher.final("hex"); + return { + nonce: nonce.toString("hex"), + ciphertext: encrypted, + tag: cipher.getAuthTag().toString("hex"), + }; +} + +function obfuscate(token: string): string { + return `${token.slice(0, 11)}${"•".repeat(20)}${token.slice(-4)}`; +} + +export async function seedTestUser(prisma: PrismaClient, overrides?: { admin?: boolean }) { + const suffix = randomBytes(6).toString("hex"); + return prisma.user.create({ + data: { + email: `pat-user-${suffix}@test.local`, + authenticationMethod: "MAGIC_LINK", + admin: overrides?.admin ?? false, + }, + }); +} + +// Seeds a PersonalAccessToken row using the same hashing/encryption scheme as +// webapp's services/personalAccessToken.server.ts so the webapp subprocess can +// authenticate against it. +export async function seedTestPAT( + prisma: PrismaClient, + userId: string, + opts: { revoked?: boolean } = {} +): Promise<{ token: string; id: string }> { + const token = `tr_pat_${randomBytes(20).toString("hex")}`; + const encrypted = encryptToken(token, ENCRYPTION_KEY); + const row = await prisma.personalAccessToken.create({ + data: { + name: "e2e-test-pat", + userId, + encryptedToken: encrypted, + hashedToken: hashToken(token), + obfuscatedToken: obfuscate(token), + revokedAt: opts.revoked ? new Date() : null, + }, + }); + return { token, id: row.id }; +} diff --git a/apps/webapp/test/helpers/seedTestRun.ts b/apps/webapp/test/helpers/seedTestRun.ts new file mode 100644 index 00000000000..44137e45005 --- /dev/null +++ b/apps/webapp/test/helpers/seedTestRun.ts @@ -0,0 +1,61 @@ +import type { PrismaClient, TaskRun } from "@trigger.dev/database"; +import { customAlphabet, nanoid } from "nanoid"; + +const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21); + +export interface SeededRun { + run: TaskRun; + runFriendlyId: string; // `run_...` + batchFriendlyId?: string; // `batch_...` when { withBatch: true } +} + +// Minimum-viable TaskRun for auth-layer e2e tests — enough fields for +// ApiRetrieveRunPresenter.findRun to return it and for the authorization.resource +// callback to populate `runs`, `tags`, `batch`, `tasks` keys. +export async function seedTestRun( + prisma: PrismaClient, + opts: { + environmentId: string; + projectId: string; + runTags?: string[]; + withBatch?: boolean; + } +): Promise { + const runInternalId = idGenerator(); + const runFriendlyId = `run_${runInternalId}`; + + let batchInternalId: string | undefined; + if (opts.withBatch) { + batchInternalId = idGenerator(); + await prisma.batchTaskRun.create({ + data: { + id: batchInternalId, + friendlyId: `batch_${batchInternalId}`, + runtimeEnvironmentId: opts.environmentId, + }, + }); + } + + const run = await prisma.taskRun.create({ + data: { + id: runInternalId, + friendlyId: runFriendlyId, + taskIdentifier: "test-task", + payload: "{}", + payloadType: "application/json", + traceId: nanoid(32), + spanId: nanoid(16), + queue: "task/test-task", + runtimeEnvironmentId: opts.environmentId, + projectId: opts.projectId, + runTags: opts.runTags ?? [], + batchId: batchInternalId, + }, + }); + + return { + run, + runFriendlyId, + batchFriendlyId: batchInternalId ? `batch_${batchInternalId}` : undefined, + }; +} diff --git a/apps/webapp/test/helpers/seedTestSession.ts b/apps/webapp/test/helpers/seedTestSession.ts new file mode 100644 index 00000000000..3e51c5c2c63 --- /dev/null +++ b/apps/webapp/test/helpers/seedTestSession.ts @@ -0,0 +1,58 @@ +// Produces a `Cookie:` header value for an authenticated session that the +// webapp under test will accept. Mirrors the webapp's +// `services/sessionStorage.server.ts` config exactly — the SESSION_SECRET +// must match what the webapp container was started with (see +// `internal-packages/testcontainers/src/webapp.ts` — currently +// "test-session-secret-for-e2e-tests"). +// +// Used by dashboard auth tests (TRI-8742). Each test seeds its own user + +// session so test order doesn't matter. + +import { createCookieSessionStorage } from "@remix-run/node"; +import type { PrismaClient } from "@trigger.dev/database"; +import { randomBytes } from "node:crypto"; + +// Must match SESSION_SECRET in internal-packages/testcontainers/src/webapp.ts. +const SESSION_SECRET = "test-session-secret-for-e2e-tests"; + +// Shape of the session config in apps/webapp/app/services/sessionStorage.server.ts. +const sessionStorage = createCookieSessionStorage({ + cookie: { + name: "__session", + sameSite: "lax", + path: "/", + httpOnly: true, + secrets: [SESSION_SECRET], + secure: false, // NODE_ENV is "test" in the spawned webapp. + maxAge: 60 * 60 * 24 * 365, + }, +}); + +export async function seedTestUser( + prisma: PrismaClient, + overrides?: { admin?: boolean; email?: string } +) { + const suffix = randomBytes(6).toString("hex"); + return prisma.user.create({ + data: { + email: overrides?.email ?? `e2e-${suffix}@test.local`, + authenticationMethod: "MAGIC_LINK", + admin: overrides?.admin ?? false, + }, + }); +} + +// Builds the `Cookie:` header value for a given user. Set this on test +// requests to the webapp to authenticate as that user. +// +// remix-auth's default sessionKey is "user" and stores AuthUser as +// { userId } — see apps/webapp/app/services/authUser.ts. +export async function seedTestSession(opts: { userId: string }): Promise { + const session = await sessionStorage.getSession(); + session.set("user", { userId: opts.userId }); + const setCookie = await sessionStorage.commitSession(session); + // commitSession returns "__session=; Path=/; ...". The Cookie + // header only needs the name=value pair. + const firstSegment = setCookie.split(";")[0]; + return firstSegment; +} diff --git a/apps/webapp/test/helpers/seedTestUserProject.ts b/apps/webapp/test/helpers/seedTestUserProject.ts new file mode 100644 index 00000000000..3512054ec1f --- /dev/null +++ b/apps/webapp/test/helpers/seedTestUserProject.ts @@ -0,0 +1,67 @@ +import type { PrismaClient } from "@trigger.dev/database"; +import { randomBytes } from "node:crypto"; +import { seedTestPAT, seedTestUser } from "./seedTestPAT"; + +function randomHex(len = 12): string { + return randomBytes(Math.ceil(len / 2)).toString("hex").slice(0, len); +} + +// Composite test fixture: a User, an Organization with that user as a +// member, a Project owned by the org, a DEVELOPMENT environment, and a +// non-revoked PAT for the user. +// +// Used by the PAT-comprehensive matrix (TRI-8741) to exercise routes +// like GET /api/v1/projects/:projectRef/runs whose access check is +// `findProjectByRef(externalRef, userId)` — i.e. the project's org +// must have the userId in its members. seedTestEnvironment alone +// doesn't create the OrgMember link, which is why this helper exists. +// +// Caller passes `projectDeleted: true` to test the soft-deleted- +// project path; `userAdmin: true` to confirm the global admin flag +// doesn't add cross-org visibility (the route is per-user). +export async function seedTestUserProject( + prisma: PrismaClient, + opts: { userAdmin?: boolean; projectDeleted?: boolean } = {} +) { + const suffix = randomHex(8); + const apiKey = `tr_dev_${randomHex(24)}`; + const pkApiKey = `pk_dev_${randomHex(24)}`; + + const user = await seedTestUser(prisma, { admin: opts.userAdmin ?? false }); + + const organization = await prisma.organization.create({ + data: { + title: `e2e-pat-org-${suffix}`, + slug: `e2e-pat-org-${suffix}`, + v3Enabled: true, + members: { create: { userId: user.id, role: "ADMIN" } }, + }, + }); + + const project = await prisma.project.create({ + data: { + name: `e2e-pat-project-${suffix}`, + slug: `e2e-pat-proj-${suffix}`, + externalRef: `proj_${suffix}`, + organizationId: organization.id, + engine: "V2", + deletedAt: opts.projectDeleted ? new Date() : null, + }, + }); + + const environment = await prisma.runtimeEnvironment.create({ + data: { + slug: "dev", + type: "DEVELOPMENT", + apiKey, + pkApiKey, + shortcode: suffix.slice(0, 4), + projectId: project.id, + organizationId: organization.id, + }, + }); + + const pat = await seedTestPAT(prisma, user.id); + + return { user, organization, project, environment, pat }; +} diff --git a/apps/webapp/test/helpers/seedTestWaitpoint.ts b/apps/webapp/test/helpers/seedTestWaitpoint.ts new file mode 100644 index 00000000000..f4794b2b6c1 --- /dev/null +++ b/apps/webapp/test/helpers/seedTestWaitpoint.ts @@ -0,0 +1,29 @@ +import type { PrismaClient } from "@trigger.dev/database"; +import { customAlphabet } from "nanoid"; + +// Must match friendlyId.ts IdUtil alphabet so generated IDs are valid. +const idGenerator = customAlphabet("123456789abcdefghijkmnopqrstuvwxyz", 21); + +// Seeds a Waitpoint already in COMPLETED status so the waitpoints/:id/complete +// handler short-circuits with { success: true }. That keeps the "auth passes" +// assertion independent of run-engine workers (which are disabled in e2e). +export async function seedTestWaitpoint( + prisma: PrismaClient, + opts: { environmentId: string; projectId: string } +): Promise<{ id: string; friendlyId: string }> { + const internalId = idGenerator(); + const friendlyId = `waitpoint_${internalId}`; + await prisma.waitpoint.create({ + data: { + id: internalId, + friendlyId, + type: "MANUAL", + status: "COMPLETED", + idempotencyKey: internalId, + userProvidedIdempotencyKey: false, + environmentId: opts.environmentId, + projectId: opts.projectId, + }, + }); + return { id: internalId, friendlyId }; +} diff --git a/apps/webapp/test/helpers/sharedTestServer.ts b/apps/webapp/test/helpers/sharedTestServer.ts new file mode 100644 index 00000000000..35360fd221f --- /dev/null +++ b/apps/webapp/test/helpers/sharedTestServer.ts @@ -0,0 +1,53 @@ +// Per-worker access to the shared TestServer started by globalSetup. Each +// test file imports `getTestServer()` once at module top-level; the returned +// value is a singleton within that worker process. +// +// `webapp.fetch(path)` prepends the shared baseUrl. The PrismaClient is +// constructed lazily and disconnected on test-suite end via afterAll in the +// importing file (or left to the worker shutting down). + +import { PrismaClient } from "@trigger.dev/database"; +import { afterAll, inject } from "vitest"; + +interface SharedWebapp { + baseUrl: string; + fetch(path: string, init?: RequestInit): Promise; +} + +interface SharedTestServer { + webapp: SharedWebapp; + prisma: PrismaClient; +} + +let cached: SharedTestServer | undefined; + +export function getTestServer(): SharedTestServer { + if (cached) return cached; + + const baseUrl = inject("baseUrl"); + const databaseUrl = inject("databaseUrl"); + + if (!baseUrl || !databaseUrl) { + throw new Error( + "globalSetup didn't provide baseUrl/databaseUrl — run via vitest.e2e.full.config.ts" + ); + } + + const prisma = new PrismaClient({ datasources: { db: { url: databaseUrl } } }); + + cached = { + webapp: { + baseUrl, + fetch: (path, init) => fetch(`${baseUrl}${path}`, init), + }, + prisma, + }; + + // Disconnect the PrismaClient when the worker is done. globalSetup's + // teardown stops the container; this just releases the per-worker pool. + afterAll(async () => { + await prisma.$disconnect().catch(() => {}); + }); + + return cached; +} diff --git a/apps/webapp/test/setup/global-e2e-full-setup.ts b/apps/webapp/test/setup/global-e2e-full-setup.ts new file mode 100644 index 00000000000..31a9c15781f --- /dev/null +++ b/apps/webapp/test/setup/global-e2e-full-setup.ts @@ -0,0 +1,28 @@ +// vitest globalSetup — runs once for the whole *.e2e.full.test.ts suite. +// Boots one Postgres + Redis + webapp; tests connect to it via the +// `baseUrl` / `databaseUrl` values provided to test workers below. +// +// Each test file recreates its own PrismaClient connected to the shared DB +// (PrismaClient instances aren't serialisable across worker boundaries). + +import type { TestProject } from "vitest/node"; +import { startTestServer, type TestServer } from "@internal/testcontainers/webapp"; + +let server: TestServer | undefined; + +export default async function setup(project: TestProject) { + server = await startTestServer(); + project.provide("baseUrl", server.webapp.baseUrl); + project.provide("databaseUrl", server.databaseUrl); + + return async () => { + await server?.stop().catch(() => {}); + }; +} + +declare module "vitest" { + export interface ProvidedContext { + baseUrl: string; + databaseUrl: string; + } +} diff --git a/apps/webapp/test/utils/tracing.ts b/apps/webapp/test/utils/tracing.ts index 09500a6c354..80e398136a6 100644 --- a/apps/webapp/test/utils/tracing.ts +++ b/apps/webapp/test/utils/tracing.ts @@ -1,6 +1,6 @@ +import { context, propagation, trace } from "@opentelemetry/api"; import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node"; import { InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base"; -import { trace } from "@opentelemetry/api"; import { MeterProvider, InMemoryMetricExporter, @@ -9,15 +9,37 @@ import { } from "@opentelemetry/sdk-metrics"; export function createInMemoryTracing() { - // Initialize the tracer provider and exporter + // Webapp's vitest config uses `pool: "forks"` with `--no-file-parallelism`, + // so all test files in a shard share one process. globalThis persists + // across files even though vitest clears the module cache between them. + // + // OTel's `provider.register()` calls trace/context/propagation + // `setGlobal*` — and `setGlobal` no-ops (logs an error, returns false) + // when a global is already set. Two patterns hit that path: + // 1. `~/v3/tracer.server.ts` runs `provider.register()` via its + // `singleton("opentelemetry", setupTelemetry)` — first test in the + // shard to import that path sets the globals to webapp's tracer. + // 2. A subsequent test calls `createInMemoryTracing()` to swap in its + // own in-memory provider. Without disabling first, register() is + // a no-op — the test's provider receives spans via its + // SimpleSpanProcessor (provider-scoped), but `trace.getActiveSpan()` + // (used by code under test, e.g. sentryTraceContext.server.ts) + // reads from the stale global context manager from step 1. + // + // Disable first, then register, so the test's provider always wins + // for both span recording and the global API. After the test, the + // next caller's createInMemoryTracing rotates again — no leakage. + trace.disable(); + context.disable(); + propagation.disable(); + const exporter = new InMemorySpanExporter(); const provider = new NodeTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)], }); provider.register(); - // Retrieve the tracer - const tracer = trace.getTracer("test-tracer"); + const tracer = provider.getTracer("test-tracer"); return { exporter, diff --git a/apps/webapp/test/validateGitBranchName.test.ts b/apps/webapp/test/validateGitBranchName.test.ts index 28f4056c463..91742c6ca76 100644 --- a/apps/webapp/test/validateGitBranchName.test.ts +++ b/apps/webapp/test/validateGitBranchName.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isValidGitBranchName, sanitizeBranchName } from "~/v3/gitBranch"; +import { isValidGitBranchName, sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; describe("isValidGitBranchName", () => { it("returns true for a valid branch name", async () => { diff --git a/apps/webapp/vitest.config.ts b/apps/webapp/vitest.config.ts index 2e51eb3f17d..66f697706a5 100644 --- a/apps/webapp/vitest.config.ts +++ b/apps/webapp/vitest.config.ts @@ -4,7 +4,10 @@ import tsconfigPaths from "vite-tsconfig-paths"; export default defineConfig({ test: { include: ["test/**/*.test.ts"], - exclude: ["test/**/*.e2e.test.ts"], + // *.e2e.test.ts: smoke matrix, run via vitest.e2e.config.ts. + // *.e2e.full.test.ts: full auth suite, runs via vitest.e2e.full.config.ts + // (needs a globalSetup-spawned webapp + Postgres container). + exclude: ["test/**/*.e2e.test.ts", "test/**/*.e2e.full.test.ts"], globals: true, pool: "forks", }, diff --git a/apps/webapp/vitest.e2e.full.config.ts b/apps/webapp/vitest.e2e.full.config.ts new file mode 100644 index 00000000000..47a4b0a8084 --- /dev/null +++ b/apps/webapp/vitest.e2e.full.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +// Comprehensive auth e2e suite — see TRI-8731. Boots a single +// webapp + Postgres + Redis container in globalSetup and rapid-fires +// tests against it across multiple test files. Distinct from the smoke +// suite (vitest.e2e.config.ts) which uses per-file beforeAll setup and +// runs in default CI on every PR. +export default defineConfig({ + test: { + include: ["test/**/*.e2e.full.test.ts"], + globalSetup: ["./test/setup/global-e2e-full-setup.ts"], + globals: true, + pool: "forks", + testTimeout: 60_000, + hookTimeout: 180_000, + }, + // @ts-ignore + plugins: [tsconfigPaths({ projects: ["./tsconfig.json"] })], +}); diff --git a/internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql b/internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql new file mode 100644 index 00000000000..d7cdc1a0c0b --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260430140000_add_rbac_role_id_to_org_member_invite/migration.sql @@ -0,0 +1,5 @@ +-- TRI-8892: optional RBAC role assignment carried on the invite. When +-- set, the accept-invite flow calls the loaded RBAC plugin's +-- setUserRole(rbacRoleId) after the OrgMember insert; otherwise the +-- runtime fallback derives the role from the legacy `role` column. +ALTER TABLE "OrgMemberInvite" ADD COLUMN IF NOT EXISTS "rbacRoleId" TEXT; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index dcce2727683..f588bdfc453 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -289,6 +289,16 @@ model OrgMemberInvite { email String role OrgMemberRole @default(MEMBER) + /// Optional RBAC role to assign on invite acceptance. When set, the + /// accept-invite flow calls the loaded RBAC plugin's setUserRole with + /// this id after creating the OrgMember. Null = legacy behaviour, the + /// runtime fallback derives the role from `role` above. + /// + /// Plain text (not an FK) — the RBAC plugin's RbacRole table lives on + /// a separate schema (Drizzle, not Prisma) so we can't model the FK + /// here. Validation happens at write time (action) and read time + /// (acceptInvite). + rbacRoleId String? organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade, onUpdate: Cascade) organizationId String diff --git a/internal-packages/rbac/package.json b/internal-packages/rbac/package.json new file mode 100644 index 00000000000..d04089e4ff7 --- /dev/null +++ b/internal-packages/rbac/package.json @@ -0,0 +1,24 @@ +{ + "name": "@trigger.dev/rbac", + "private": true, + "version": "0.0.1", + "main": "./dist/src/index.js", + "types": "./dist/src/index.d.ts", + "dependencies": { + "@trigger.dev/core": "workspace:*", + "@trigger.dev/plugins": "workspace:*" + }, + "devDependencies": { + "@trigger.dev/database": "workspace:*", + "@types/node": "^20.14.14", + "rimraf": "6.0.1" + }, + "scripts": { + "clean": "rimraf dist", + "typecheck": "tsc --noEmit", + "build": "pnpm run clean && tsc --noEmit false --outDir dist --declaration", + "dev": "tsc --noEmit false --outDir dist --declaration --watch", + "test": "vitest run", + "test:watch": "vitest" + } +} diff --git a/internal-packages/rbac/src/ability.test.ts b/internal-packages/rbac/src/ability.test.ts new file mode 100644 index 00000000000..e361bf0d544 --- /dev/null +++ b/internal-packages/rbac/src/ability.test.ts @@ -0,0 +1,164 @@ +import { describe, it, expect } from "vitest"; +import { permissiveAbility, superAbility, denyAbility, buildFallbackAbility, buildJwtAbility } from "./ability.js"; + +describe("permissiveAbility", () => { + it("allows any action on any resource type", () => { + expect(permissiveAbility.can("read", { type: "run" })).toBe(true); + expect(permissiveAbility.can("write", { type: "deployment" })).toBe(true); + expect(permissiveAbility.can("delete", { type: "task" })).toBe(true); + }); + + it("allows actions on specific resource instances", () => { + expect(permissiveAbility.can("read", { type: "run", id: "run_abc123" })).toBe(true); + }); + + it("does not grant super-user access", () => { + expect(permissiveAbility.canSuper()).toBe(false); + }); +}); + +describe("superAbility", () => { + it("allows any action on any resource", () => { + expect(superAbility.can("read", { type: "run" })).toBe(true); + expect(superAbility.can("write", { type: "deployment" })).toBe(true); + }); + + it("grants super-user access", () => { + expect(superAbility.canSuper()).toBe(true); + }); +}); + +describe("denyAbility", () => { + it("denies all actions", () => { + expect(denyAbility.can("read", { type: "run" })).toBe(false); + expect(denyAbility.can("write", { type: "deployment" })).toBe(false); + }); + + it("does not grant super-user access", () => { + expect(denyAbility.canSuper()).toBe(false); + }); +}); + +describe("buildJwtAbility", () => { + it("allows action matching a general scope", () => { + const ability = buildJwtAbility(["read:runs"]); + expect(ability.can("read", { type: "runs" })).toBe(true); + expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true); + }); + + it("allows only the specific ID for a scoped permission", () => { + const ability = buildJwtAbility(["read:runs:run_abc"]); + expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true); + expect(ability.can("read", { type: "runs", id: "run_xyz" })).toBe(false); + expect(ability.can("read", { type: "runs" })).toBe(false); + }); + + it("preserves colons in the resource id (everything after the 2nd colon)", () => { + // Resource ids can contain colons (e.g. user-provided tags like + // `env:staging`). The naive `[a, b, c] = scope.split(":")` form + // truncated `read:tags:env:staging` → scopeId="env" and silently + // mis-matched. Regression coverage for the multi-colon id path. + const ability = buildJwtAbility(["read:tags:env:staging"]); + expect(ability.can("read", { type: "tags", id: "env:staging" })).toBe(true); + expect(ability.can("read", { type: "tags", id: "env" })).toBe(false); + expect(ability.can("read", { type: "tags", id: "env:prod" })).toBe(false); + }); + + it("allows any read with read:all scope", () => { + const ability = buildJwtAbility(["read:all"]); + expect(ability.can("read", { type: "runs" })).toBe(true); + expect(ability.can("read", { type: "tasks" })).toBe(true); + expect(ability.can("write", { type: "runs" })).toBe(false); + }); + + it("allows everything with admin scope", () => { + const ability = buildJwtAbility(["admin"]); + expect(ability.can("read", { type: "runs" })).toBe(true); + expect(ability.can("write", { type: "deployments" })).toBe(true); + }); + + // Pre-RBAC, the legacy checkAuthorization string-matched superScopes; + // a scope `admin:sessions` only granted access to routes that + // explicitly listed it. After the JWT-ability split we must not let + // `admin:` act as a universal wildcard — it should grant + // only the `admin` action against resources of that type. + it("admin: is not a universal wildcard", () => { + const ability = buildJwtAbility(["admin:sessions"]); + expect(ability.can("read", { type: "runs" })).toBe(false); + expect(ability.can("write", { type: "tasks" })).toBe(false); + expect(ability.can("admin", { type: "runs" })).toBe(false); + // But it does grant the admin action on its own type. + expect(ability.can("admin", { type: "sessions" })).toBe(true); + expect(ability.can("admin", { type: "sessions", id: "ses_abc" })).toBe(true); + }); + + it("admin:: grants admin action only on that exact resource", () => { + const ability = buildJwtAbility(["admin:sessions:ses_abc"]); + expect(ability.can("admin", { type: "sessions", id: "ses_abc" })).toBe(true); + expect(ability.can("admin", { type: "sessions", id: "ses_xyz" })).toBe(false); + expect(ability.can("admin", { type: "runs" })).toBe(false); + expect(ability.can("read", { type: "sessions", id: "ses_abc" })).toBe(false); + }); + + it("never grants canSuper", () => { + expect(buildJwtAbility(["admin"]).canSuper()).toBe(false); + expect(buildJwtAbility(["read:all"]).canSuper()).toBe(false); + expect(buildJwtAbility([]).canSuper()).toBe(false); + }); + + it("denies everything for empty scopes", () => { + const ability = buildJwtAbility([]); + expect(ability.can("read", { type: "runs" })).toBe(false); + }); + + it("denies wrong action with general resource scope", () => { + const ability = buildJwtAbility(["read:runs"]); + expect(ability.can("write", { type: "runs" })).toBe(false); + }); +}); + +describe("buildJwtAbility — array resources", () => { + it("authorizes when any resource in the array passes a scope check", () => { + const ability = buildJwtAbility(["read:batch:batch_abc"]); + const resources = [ + { type: "runs", id: "run_xyz" }, + { type: "batch", id: "batch_abc" }, + { type: "tasks", id: "task_other" }, + ]; + expect(ability.can("read", resources)).toBe(true); + }); + + it("rejects when no resource in the array passes a scope check", () => { + const ability = buildJwtAbility(["read:batch:batch_abc"]); + const resources = [ + { type: "runs", id: "run_xyz" }, + { type: "batch", id: "batch_other" }, + { type: "tasks", id: "task_other" }, + ]; + expect(ability.can("read", resources)).toBe(false); + }); + + it("empty array never authorizes", () => { + const ability = buildJwtAbility(["read:all"]); + expect(ability.can("read", [])).toBe(false); + }); + + it("authorizes a single resource via the non-array form (backwards compatible)", () => { + const ability = buildJwtAbility(["read:runs:run_abc"]); + expect(ability.can("read", { type: "runs", id: "run_abc" })).toBe(true); + }); +}); + +describe("buildFallbackAbility", () => { + it("returns permissiveAbility for non-admin users", () => { + const ability = buildFallbackAbility(false); + expect(ability.can("read", { type: "run" })).toBe(true); + expect(ability.canSuper()).toBe(false); + }); + + it("returns superAbility for admin users", () => { + const ability = buildFallbackAbility(true); + expect(ability.can("read", { type: "run" })).toBe(true); + expect(ability.canSuper()).toBe(true); + }); +}); diff --git a/internal-packages/rbac/src/ability.ts b/internal-packages/rbac/src/ability.ts new file mode 100644 index 00000000000..0ff53ea8d88 --- /dev/null +++ b/internal-packages/rbac/src/ability.ts @@ -0,0 +1,63 @@ +import type { RbacAbility, RbacResource } from "@trigger.dev/plugins"; + +/** Every authenticated non-admin subject: can do anything, cannot do super-user actions. */ +export const permissiveAbility: RbacAbility = { + can: () => true, + canSuper: () => false, +}; + +/** Platform admin (user.admin = true): can do everything including super-user actions. */ +export const superAbility: RbacAbility = { + can: () => true, + canSuper: () => true, +}; + +/** Deprecated PUBLIC tokens and unauthenticated subjects: denied everything. */ +export const denyAbility: RbacAbility = { + can: () => false, + canSuper: () => false, +}; + +export function buildFallbackAbility(isAdmin: boolean): RbacAbility { + return isAdmin ? superAbility : permissiveAbility; +} + +/** Builds an ability from JWT scope strings like "read:runs", "read:runs:run_abc", "read:all", "admin". */ +export function buildJwtAbility(scopes: string[]): RbacAbility { + const matches = (action: string, r: RbacResource): boolean => + scopes.some((scope) => { + // Only the first two colons are delimiters — everything after the + // second colon is the resource id (which may itself contain colons, + // e.g. user-provided tags like "env:staging"). Naive + // `split(":")` + 3-tuple destructuring truncated such ids to the + // first segment and silently failed to match. + const parts = scope.split(":"); + const scopeAction = parts[0]; + const scopeType = parts[1]; + const scopeId = parts.length > 2 ? parts.slice(2).join(":") : undefined; + // Bare `admin` is the universal wildcard. `admin:` is *not* — + // it falls through to normal matching as action="admin" against + // resources of that type. Pre-RBAC, the legacy checkAuthorization + // string-matched superScopes; `admin:sessions` only granted access + // to routes that explicitly listed it. Treating `admin:` + // as universal here would silently broaden any such tokens. + if (scopeAction === "admin" && !scopeType) return true; + if (scopeAction !== action && scopeAction !== "*") return false; + if (scopeType === "all") return true; + if (scopeType !== r.type) return false; + if (!scopeId) return true; + return scopeId === r.id; + }); + return { + can(action: string, resource: RbacResource | RbacResource[]): boolean { + // Array form means "any element passes → authorized", matching the + // legacy multi-key checkAuthorization semantic. + return Array.isArray(resource) + ? resource.some((r) => matches(action, r)) + : matches(action, resource); + }, + canSuper(): boolean { + return false; + }, + }; +} diff --git a/internal-packages/rbac/src/fallback.ts b/internal-packages/rbac/src/fallback.ts new file mode 100644 index 00000000000..d28c4817705 --- /dev/null +++ b/internal-packages/rbac/src/fallback.ts @@ -0,0 +1,439 @@ +import type { + Permission, + Role, + RbacEnvironment, + RbacUser, + RbacSubject, + RbacResource, + BearerAuthResult, + PatAuthResult, + SessionAuthResult, + RoleAssignmentResult, + RoleBaseAccessController, + RoleMutationResult, +} from "@trigger.dev/plugins"; +import { createHash } from "node:crypto"; +import type { PrismaClient } from "@trigger.dev/database"; +import { validateJWT } from "@trigger.dev/core/v3/jwt"; +import { sanitizeBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; +import { buildFallbackAbility, buildJwtAbility, permissiveAbility } from "./ability.js"; + +export type FallbackPrismaClients = { + // Used for writes (setUserRole, mutateRole, etc.) and any reads that + // can't tolerate replica lag (currently none on this controller, but + // kept for symmetry with the rest of the webapp). + primary: PrismaClient; + // Used for read-only auth-path queries: bearer-token env lookup, + // PAT lookup, session user lookup. Spreads the high-frequency auth + // load away from the primary, matching what `findEnvironmentByApiKey` + // / `findEnvironmentById` did before this PR. + replica: PrismaClient; +}; + +// Backwards-compat: a single PrismaClient is treated as both primary +// and replica. Callers that care about replica isolation pass the +// explicit FallbackPrismaClients shape. +type PrismaInput = PrismaClient | FallbackPrismaClients; + +function resolvePrismaClients(input: PrismaInput): FallbackPrismaClients { + return "primary" in input ? input : { primary: input, replica: input }; +} + +export class RoleBaseAccessFallback { + private readonly clients: FallbackPrismaClients; + + constructor(prisma: PrismaInput) { + this.clients = resolvePrismaClients(prisma); + } + + create(): RoleBaseAccessFallbackController { + return new RoleBaseAccessFallbackController(this.clients); + } +} + +class RoleBaseAccessFallbackController implements RoleBaseAccessController { + private readonly prisma: PrismaClient; // alias for primary — used by writes + private readonly replica: PrismaClient; + + constructor(clients: FallbackPrismaClients) { + this.prisma = clients.primary; + this.replica = clients.replica; + } + + async isUsingPlugin(): Promise { + return false; + } + + async authenticateBearer( + request: Request, + options?: { allowJWT?: boolean } + ): Promise { + // Deprecated public API keys (`pk_*` minted long before public JWTs + // landed) are intentionally NOT handled here. The legacy + // `findEnvironmentByPublicApiKey` path looked them up via the + // `pkApiKey` column, but that token format hasn't been issued for + // years and no live client should be sending one. Any `pk_*` bearer + // on a route that goes through the apiBuilder now returns 401 — + // public access goes through the JWT path (`isPublicJWT(rawToken)` + // below) instead. The deprecated lookup is still exported from + // `apps/webapp/app/models/runtimeEnvironment.server.ts` for the + // pre-RBAC routes that haven't been migrated, but it's a dead + // code path for any route that uses `createLoaderApiRoute` / + // `createActionApiRoute`. + const rawToken = request.headers.get("Authorization")?.replace(/^Bearer /, "").trim(); + if (!rawToken) return { ok: false, status: 401, error: "Invalid or Missing API key" }; + + if (options?.allowJWT && isPublicJWT(rawToken)) { + const envId = extractJWTSub(rawToken); + if (!envId) return { ok: false, status: 401, error: "Invalid Public Access Token" }; + + // Match the include shape of the slim AuthenticatedEnvironment so + // the bridge can use the returned env without a follow-up fetch. + const env = await this.replica.runtimeEnvironment.findFirst({ + where: { id: envId }, + include: { + project: true, + organization: true, + orgMember: { + select: { + userId: true, + user: { select: { id: true, displayName: true, name: true } }, + }, + }, + parentEnvironment: { select: { id: true, apiKey: true } }, + }, + }); + if (!env || env.project.deletedAt !== null) { + return { ok: false, status: 401, error: "Invalid Public Access Token" }; + } + + const signingKey = env.parentEnvironment?.apiKey ?? env.apiKey; + const result = await validateJWT(rawToken, signingKey); + if (!result.ok) return { ok: false, status: 401, error: "Public Access Token is invalid" }; + + const scopes = Array.isArray(result.payload.scopes) + ? (result.payload.scopes as string[]) + : []; + const realtime = result.payload.realtime as { skipColumns?: string[] } | undefined; + const oneTimeUse = result.payload.otu === true; + + return { + ok: true, + environment: toAuthenticatedEnvironment(env), + subject: { + type: "publicJWT", + environmentId: env.id, + organizationId: env.organizationId, + projectId: env.projectId, + }, + ability: buildJwtAbility(scopes), + jwt: { realtime, oneTimeUse }, + }; + } + + // PREVIEW envs are parents — operating "on a branch" means routing + // to a child env keyed by branchName. The customer authenticates + // with the parent's apiKey + an `x-trigger-branch` header. Mirror + // findEnvironmentByApiKey: include the matching child env so the + // pivot below can adopt its identity. + const branchName = sanitizeBranchName(request.headers.get("x-trigger-branch")); + // Match the include shape of the slim AuthenticatedEnvironment so + // the apiBuilder bridge can use the returned env directly without a + // follow-up findEnvironmentById call. + const include = { + project: true, + organization: true, + orgMember: { + select: { + userId: true, + user: { select: { id: true, displayName: true, name: true } }, + }, + }, + parentEnvironment: { select: { id: true, apiKey: true } }, + childEnvironments: branchName + ? { where: { branchName, archivedAt: null } } + : undefined, + } as const; + let env = await this.replica.runtimeEnvironment.findFirst({ + where: { apiKey: rawToken }, + include, + }); + + // Revoked API key grace window — mirrors `findEnvironmentByApiKey` + // in apps/webapp/app/models/runtimeEnvironment.server.ts. Recently + // rotated keys keep working until their `expiresAt`; without this + // branch a customer who rotates an env API key gets immediate 401s + // on the new auth path. The PR's e2e suite covers this in + // auth-cross-cutting.e2e.full.test.ts ("revoked key within grace"). + if (!env) { + const revoked = await this.replica.revokedApiKey.findFirst({ + where: { apiKey: rawToken, expiresAt: { gt: new Date() } }, + include: { runtimeEnvironment: { include } }, + }); + env = revoked?.runtimeEnvironment ?? null; + } + + if (!env || env.project.deletedAt !== null) { + return { ok: false, status: 401, error: "Invalid API key" }; + } + + // PREVIEW env requires a branch header; pivot to the child env so + // downstream code operates on the branch (its own id, but the + // parent's apiKey/orgMember/organization/project — exactly what + // findEnvironmentByApiKey does for the legacy auth path). + if (env.type === "PREVIEW") { + if (!branchName) { + return { + ok: false, + status: 401, + error: "x-trigger-branch header required for preview env", + }; + } + const child = env.childEnvironments?.[0]; + if (!child) { + return { ok: false, status: 401, error: "No matching branch env" }; + } + // Pivot to the child env: child's id/type/branchName, parent's + // apiKey/orgMember/organization/project. parentEnvironment is set + // explicitly here so the slim shape stays internally consistent. + env = { + ...child, + apiKey: env.apiKey, + orgMember: env.orgMember, + organization: env.organization, + project: env.project, + parentEnvironment: { id: env.id, apiKey: env.apiKey }, + childEnvironments: [], + }; + } + + const subject: RbacSubject = { + type: "user", + userId: env.orgMember?.userId ?? "", + organizationId: env.organizationId, + projectId: env.projectId, + }; + + return { + ok: true, + environment: toAuthenticatedEnvironment(env), + subject, + ability: permissiveAbility, + }; + } + + async authenticateSession( + _request: Request, + context: { userId: string | null; organizationId?: string; projectId?: string } + ): Promise { + if (!context.userId) return { ok: false, reason: "unauthenticated" }; + + const user = await this.replica.user.findFirst({ where: { id: context.userId } }); + if (!user) return { ok: false, reason: "unauthenticated" }; + + const subject: RbacSubject = { + type: "user", + userId: user.id, + organizationId: context.organizationId ?? "", + projectId: context.projectId, + }; + + return { + ok: true, + user: toRbacUser(user), + subject, + ability: buildFallbackAbility(user.admin), + }; + } + + async authenticateAuthorizeBearer( + request: Request, + check: { action: string; resource: RbacResource | RbacResource[] }, + options?: { allowJWT?: boolean } + ): Promise { + const auth = await this.authenticateBearer(request, options); + if (!auth.ok) return auth; + if (!auth.ability.can(check.action, check.resource)) { + return { ok: false, status: 403, error: "Unauthorized" }; + } + return auth; + } + + async authenticateAuthorizeSession( + request: Request, + context: { userId: string | null; organizationId?: string; projectId?: string }, + check: { action: string; resource: RbacResource | RbacResource[] } + ): Promise { + const auth = await this.authenticateSession(request, context); + if (!auth.ok) return auth; + if (!auth.ability.can(check.action, check.resource)) { + return { ok: false, reason: "unauthorized" }; + } + return auth; + } + + async authenticatePat( + request: Request, + context: { organizationId?: string; projectId?: string } + ): Promise { + const rawToken = request.headers + .get("Authorization") + ?.replace(/^Bearer /, "") + .trim(); + if (!rawToken || !rawToken.startsWith("tr_pat_")) { + return { ok: false, status: 401, error: "Invalid or Missing PAT" }; + } + + const hashedToken = createHash("sha256").update(rawToken).digest("hex"); + const pat = await this.replica.personalAccessToken.findFirst({ + where: { hashedToken, revokedAt: null }, + // Include `lastAccessedAt` so the host can throttle its own write + // (see `PatAuthResult.lastAccessedAt` jsdoc). Without this the host + // would need a second findFirst just to decide whether to fire the + // updateMany, turning 1 DB roundtrip into 2. + select: { id: true, userId: true, lastAccessedAt: true }, + }); + if (!pat) { + return { ok: false, status: 401, error: "Invalid PAT" }; + } + + return { + ok: true, + tokenId: pat.id, + userId: pat.userId, + lastAccessedAt: pat.lastAccessedAt, + subject: { + type: "personalAccessToken", + tokenId: pat.id, + organizationId: context.organizationId ?? "", + projectId: context.projectId, + }, + // No plugin → no role lookup. PATs in the OSS world are pure + // user-identity tokens; the route's own authorization block (or + // the absence of one) decides what they can do, same as it did + // before this method existed. + ability: permissiveAbility, + }; + } + + async systemRoles(_organizationId: string) { + // No plugin installed → no seeded roles. Callers handle null by + // hiding role-picker UI / skipping role assignment writes. + return null; + } + + async allPermissions(): Promise { + return []; + } + + async allRoles(): Promise { + return []; + } + + // Permissive — the default fallback applies no gating. The Teams + // page UI uses this to decide which role options to render as + // disabled; with no plugin installed allRoles() returns [] anyway, + // so the practical effect is "no roles to gate". + async getAssignableRoleIds(): Promise { + return []; + } + + async createRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } + + async updateRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } + + async deleteRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } + + async getUserRole(): Promise { + return null; + } + + async getUserRoles(userIds: string[]): Promise> { + return new Map(userIds.map((id) => [id, null])); + } + + async setUserRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } + + async removeUserRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } + + async getTokenRole(): Promise { + return null; + } + + async setTokenRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } + + async removeTokenRole(): Promise { + return { ok: false, error: "RBAC plugin not installed" }; + } +} + +function isPublicJWT(token: string): boolean { + const parts = token.split("."); + if (parts.length !== 3) return false; + try { + const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")); + return payload !== null && typeof payload === "object" && payload.pub === true; + } catch { + return false; + } +} + +function extractJWTSub(token: string): string | undefined { + const parts = token.split("."); + if (parts.length !== 3) return undefined; + try { + const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")); + return payload !== null && typeof payload === "object" && typeof payload.sub === "string" + ? payload.sub + : undefined; + } catch { + return undefined; + } +} + +// Coerce a Prisma RuntimeEnvironment payload (with project/organization/ +// orgMember/parentEnvironment includes) into the slim AuthenticatedEnvironment +// the auth contract carries. The slim type accepts both `number` and +// Decimal-like for `concurrencyLimitBurstFactor`, but explicit coercion +// here keeps the value a plain number across the auth boundary so +// downstream consumers don't have to narrow before doing arithmetic. +function toAuthenticatedEnvironment(env: RbacEnvironment): RbacEnvironment { + const burst = env.concurrencyLimitBurstFactor; + return { + ...env, + concurrencyLimitBurstFactor: typeof burst === "number" ? burst : burst.toNumber(), + }; +} + +function toRbacUser(user: { + id: string; + email: string; + name: string | null; + displayName: string | null; + avatarUrl: string | null; + admin: boolean; + confirmedBasicDetails: boolean; +}): RbacUser { + return { + id: user.id, + email: user.email, + name: user.name, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + admin: user.admin, + confirmedBasicDetails: user.confirmedBasicDetails, + isImpersonating: false, + }; +} diff --git a/internal-packages/rbac/src/index.ts b/internal-packages/rbac/src/index.ts new file mode 100644 index 00000000000..af76d7b394d --- /dev/null +++ b/internal-packages/rbac/src/index.ts @@ -0,0 +1,258 @@ +import type { + Permission, + RbacAbility, + Role, + RbacResource, + RoleAssignmentResult, + RoleBaseAccessController, + RoleBasedAccessControlPlugin, + RoleMutationResult, +} from "@trigger.dev/plugins"; +import type { PrismaClient } from "@trigger.dev/database"; +import { RoleBaseAccessFallback } from "./fallback.js"; +export type { RoleBaseAccessController, RbacAbility, RbacResource } from "@trigger.dev/plugins"; + +// Either a single PrismaClient (used for both writes and reads — fine +// for callers that don't have a separate replica), or `{primary, replica}` +// where reads on the auth hot path go to the replica. The fallback +// reads on every request, so callers with a replica should pass both. +export type RbacPrismaInput = PrismaClient | { primary: PrismaClient; replica: PrismaClient }; + +export type RbacCreateOptions = { + // When true, skip loading the plugin, useful for tests + forceFallback?: boolean; +}; + +// Route actions that historically authorised via the legacy checkAuthorization's +// superScopes escape hatch — e.g. a JWT with scope "write:tasks" was accepted by +// a route with action: "trigger" because "write:tasks" was listed in the route's +// superScopes array. The new ability model matches scope-action strictly, so we +// restore the prior semantic here: when the underlying ability denies for action +// X, retry with each aliased action. +const ACTION_ALIASES: Record = { + trigger: ["write"], + batchTrigger: ["write"], + update: ["write"], +}; + +export function withActionAliases(underlying: RbacAbility): RbacAbility { + return { + can(action: string, resource: RbacResource | RbacResource[]): boolean { + if (underlying.can(action, resource)) return true; + const aliases = ACTION_ALIASES[action] ?? []; + return aliases.some((a) => underlying.can(a, resource)); + }, + canSuper: () => underlying.canSuper(), + }; +} + +// Loads the plugin lazily; falls back to the fallback implementation if not installed. +// Synchronous create() avoids top-level await (not supported in the webapp's CJS build). +class LazyController implements RoleBaseAccessController { + private readonly _init: Promise; + + constructor(prisma: RbacPrismaInput, options?: RbacCreateOptions) { + this._init = this.load(prisma, options); + } + + private async load( + prisma: RbacPrismaInput, + options?: RbacCreateOptions + ): Promise { + if (options?.forceFallback) { + return new RoleBaseAccessFallback(prisma).create(); + } + const moduleName = "@triggerdotdev/plugins/rbac"; + try { + const module = await import(moduleName); + const plugin: RoleBasedAccessControlPlugin = module.default; + console.log("RBAC: using plugin implementation"); + return plugin.create(); + } catch (err) { + // The dynamic import either succeeded or failed for one of two + // distinct reasons. Distinguishing them is critical for debugging + // — silently swallowing the error here is what produced "why is + // the fallback being used?" mysteries before. + // + // 1. The plugin itself is absent (no install) — expected. + // Logged at info level only when RBAC_LOG_FALLBACK=1 so + // production logs stay quiet. + // 2. Anything else (transitive dep missing, init error, syntax + // error in the plugin's dist, etc.) — a real bug. Always + // logged loudly so it surfaces in CI / production logs. + // + // Node throws ERR_MODULE_NOT_FOUND for both cases — the *plugin* + // module being absent and a *transitive* dep of the plugin + // being absent. Disambiguate by checking whether the missing + // specifier in the error message is the plugin's own moduleName. + const code = (err as NodeJS.ErrnoException | undefined)?.code; + const message = err instanceof Error ? err.message : String(err); + const isModuleNotFound = + code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND"; + const isPluginItselfMissing = + isModuleNotFound && message.includes(moduleName); + + if (!isPluginItselfMissing) { + // Either the error wasn't a missing-module error at all, or the + // plugin was found but a transitive dep failed to resolve. + // Either way: a real problem worth surfacing. + console.error( + "RBAC: plugin found but failed to load; falling back to default implementation", + err + ); + } else if (process.env.RBAC_LOG_FALLBACK === "1") { + console.log( + "RBAC: no plugin installed (ERR_MODULE_NOT_FOUND); using fallback" + ); + } + return new RoleBaseAccessFallback(prisma).create(); + } + } + + private async c(): Promise { + return this._init; + } + + async isUsingPlugin(): Promise { + return (await this.c()).isUsingPlugin(); + } + + async authenticateBearer(...args: Parameters) { + const result = await (await this.c()).authenticateBearer(...args); + return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result; + } + + async authenticateSession(...args: Parameters) { + const result = await (await this.c()).authenticateSession(...args); + return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result; + } + + // Don't delegate to the underlying Authorize variants — that would run the + // inline ability check against the unwrapped ability. Use our wrapped + // authenticate* and do the ability check here instead. + async authenticateAuthorizeBearer( + request: Parameters[0], + check: Parameters[1], + options?: Parameters[2] + ) { + const auth = await this.authenticateBearer(request, options); + if (!auth.ok) return auth; + if (!auth.ability.can(check.action, check.resource)) { + return { ok: false as const, status: 403 as const, error: "Unauthorized" }; + } + return auth; + } + + async authenticateAuthorizeSession( + request: Parameters[0], + context: Parameters[1], + check: Parameters[2] + ) { + const auth = await this.authenticateSession(request, context); + if (!auth.ok) return auth; + if (!auth.ability.can(check.action, check.resource)) { + return { ok: false as const, reason: "unauthorized" as const }; + } + return auth; + } + + async authenticatePat(...args: Parameters) { + const result = await (await this.c()).authenticatePat(...args); + return result.ok ? { ...result, ability: withActionAliases(result.ability) } : result; + } + + async systemRoles(...args: Parameters) { + return (await this.c()).systemRoles(...args); + } + + async allPermissions( + ...args: Parameters + ): Promise { + return (await this.c()).allPermissions(...args); + } + + async allRoles(...args: Parameters): Promise { + return (await this.c()).allRoles(...args); + } + + async getAssignableRoleIds( + ...args: Parameters + ): Promise { + return (await this.c()).getAssignableRoleIds(...args); + } + + async createRole( + ...args: Parameters + ): Promise { + return (await this.c()).createRole(...args); + } + + async updateRole( + ...args: Parameters + ): Promise { + return (await this.c()).updateRole(...args); + } + + async deleteRole( + ...args: Parameters + ): Promise { + return (await this.c()).deleteRole(...args); + } + + async getUserRole( + ...args: Parameters + ): Promise { + return (await this.c()).getUserRole(...args); + } + + async getUserRoles( + ...args: Parameters + ): Promise> { + return (await this.c()).getUserRoles(...args); + } + + async setUserRole( + ...args: Parameters + ): Promise { + return (await this.c()).setUserRole(...args); + } + + async removeUserRole( + ...args: Parameters + ): Promise { + return (await this.c()).removeUserRole(...args); + } + + async getTokenRole( + ...args: Parameters + ): Promise { + return (await this.c()).getTokenRole(...args); + } + + async setTokenRole( + ...args: Parameters + ): Promise { + return (await this.c()).setTokenRole(...args); + } + + async removeTokenRole( + ...args: Parameters + ): Promise { + return (await this.c()).removeTokenRole(...args); + } +} + +class RoleBaseAccess { + // Synchronous — returns a lazy controller that resolves any installed + // plugin on first call. + create( + prisma: RbacPrismaInput, + options?: RbacCreateOptions + ): RoleBaseAccessController { + return new LazyController(prisma, options); + } +} + +const loader = new RoleBaseAccess(); + +export default loader; diff --git a/internal-packages/rbac/src/loader.test.ts b/internal-packages/rbac/src/loader.test.ts new file mode 100644 index 00000000000..151bdcf9683 --- /dev/null +++ b/internal-packages/rbac/src/loader.test.ts @@ -0,0 +1,69 @@ +import type { RbacAbility } from "@trigger.dev/plugins"; +import { describe, expect, it } from "vitest"; +import { buildJwtAbility } from "./ability.js"; +import { withActionAliases } from "./index.js"; + +describe("withActionAliases", () => { + it("direct action match passes through unchanged", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks"])); + expect(ability.can("write", { type: "tasks", id: "task_x" })).toBe(true); + }); + + it("trigger action is satisfied by a write:tasks scope (alias retry)", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks"])); + expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true); + }); + + it("batchTrigger action is satisfied by a write:tasks scope (alias retry)", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks"])); + expect(ability.can("batchTrigger", { type: "tasks", id: "task_x" })).toBe(true); + }); + + it("update action is satisfied by a write:prompts scope (alias retry)", () => { + const ability = withActionAliases(buildJwtAbility(["write:prompts"])); + expect(ability.can("update", { type: "prompts", id: "p_x" })).toBe(true); + }); + + it("id-scoped write scope satisfies the aliased action on matching id", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"])); + expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true); + }); + + it("id-scoped write scope denies the aliased action on a different id", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"])); + expect(ability.can("trigger", { type: "tasks", id: "task_other" })).toBe(false); + }); + + it("read scope does not satisfy a trigger action (aliases are write-only)", () => { + const ability = withActionAliases(buildJwtAbility(["read:tasks"])); + expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(false); + }); + + it("non-aliased custom action only matches its direct action scope", () => { + const ability = withActionAliases(buildJwtAbility(["read:runs"])); + expect(ability.can("someOtherAction", { type: "runs", id: "run_x" })).toBe(false); + }); + + it("admin scope continues to grant everything regardless of aliases", () => { + const ability = withActionAliases(buildJwtAbility(["admin"])); + expect(ability.can("trigger", { type: "tasks", id: "task_x" })).toBe(true); + expect(ability.can("batchTrigger", { type: "tasks", id: "task_x" })).toBe(true); + expect(ability.can("anything", { type: "whatever", id: "x" })).toBe(true); + }); + + it("array resource form: alias retry applies when any element passes", () => { + const ability = withActionAliases(buildJwtAbility(["write:tasks:task_x"])); + const resources = [ + { type: "tasks", id: "task_other" }, + { type: "tasks", id: "task_x" }, + ]; + expect(ability.can("trigger", resources)).toBe(true); + }); + + it("canSuper is delegated unchanged", () => { + const allowSuper: RbacAbility = { can: () => false, canSuper: () => true }; + const denySuper: RbacAbility = { can: () => false, canSuper: () => false }; + expect(withActionAliases(allowSuper).canSuper()).toBe(true); + expect(withActionAliases(denySuper).canSuper()).toBe(false); + }); +}); diff --git a/internal-packages/rbac/tsconfig.json b/internal-packages/rbac/tsconfig.json new file mode 100644 index 00000000000..8da0857b403 --- /dev/null +++ b/internal-packages/rbac/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2019", + "lib": ["ES2019", "DOM"], + "module": "ESNext", + "moduleResolution": "Bundler", + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "preserveWatchOutput": true, + "skipLibCheck": true, + "noEmit": true, + "strict": true, + "customConditions": ["@triggerdotdev/source"] + }, + "exclude": ["node_modules"] +} diff --git a/internal-packages/rbac/vitest.config.ts b/internal-packages/rbac/vitest.config.ts new file mode 100644 index 00000000000..e07f05e842b --- /dev/null +++ b/internal-packages/rbac/vitest.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["**/*.test.ts"], + globals: true, + isolate: true, + testTimeout: 10_000, + }, +}); diff --git a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts index fdcf1a6f89a..06c80f67f2c 100644 --- a/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts +++ b/internal-packages/run-engine/src/engine/systems/runAttemptSystem.ts @@ -63,6 +63,7 @@ import { import { SystemResources } from "./systems.js"; import { WaitpointSystem } from "./waitpointSystem.js"; import { BatchId, RunId } from "@trigger.dev/core/v3/isomorphic"; +import type { AuthenticatedEnvironment } from "../../shared/index.js"; export type RunAttemptSystemOptions = { resources: SystemResources; @@ -1055,7 +1056,16 @@ export class RunAttemptSystem { organization: { id: run.runtimeEnvironment.organizationId, }, - environment: run.runtimeEnvironment, + // The Prisma payload structurally satisfies the slim + // AuthenticatedEnvironment except for `concurrencyLimitBurstFactor` + // (Decimal vs number). Coerce that one field; cast away + // the excess-property mismatch (the rest of Prisma's + // RuntimeEnvironment columns are extra, not missing). + environment: { + ...run.runtimeEnvironment, + concurrencyLimitBurstFactor: + run.runtimeEnvironment.concurrencyLimitBurstFactor.toNumber(), + } as unknown as AuthenticatedEnvironment, retryAt, }); diff --git a/internal-packages/run-engine/src/shared/index.ts b/internal-packages/run-engine/src/shared/index.ts index e2b36e464e9..30006b20957 100644 --- a/internal-packages/run-engine/src/shared/index.ts +++ b/internal-packages/run-engine/src/shared/index.ts @@ -1,21 +1,24 @@ import type { Attributes } from "@internal/tracing"; -import type { Prisma } from "@trigger.dev/database"; -export type AuthenticatedEnvironment = Prisma.RuntimeEnvironmentGetPayload<{ - include: { project: true; organization: true; orgMember: true }; -}>; +// Slim, structural shape carried across the auth boundary. Defined in +// @trigger.dev/core so it's importable from internal packages and the +// RBAC plugin contract without depending on @trigger.dev/database. +export type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; +import type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; +// Run-engine internal type — what enqueue/dequeue/concurrency code +// actually needs from an env. Independent of `AuthenticatedEnvironment` +// (the auth-boundary slim type) because internals receive Prisma +// payloads where `concurrencyLimitBurstFactor` is `Decimal`. Accept +// both number and a Decimal-like duck type so callers don't need to +// coerce at every site. export type MinimalAuthenticatedEnvironment = { - id: AuthenticatedEnvironment["id"]; + id: string; type: AuthenticatedEnvironment["type"]; - maximumConcurrencyLimit: AuthenticatedEnvironment["maximumConcurrencyLimit"]; - concurrencyLimitBurstFactor: AuthenticatedEnvironment["concurrencyLimitBurstFactor"]; - project: { - id: AuthenticatedEnvironment["project"]["id"]; - }; - organization: { - id: AuthenticatedEnvironment["organization"]["id"]; - }; + maximumConcurrencyLimit: number; + concurrencyLimitBurstFactor: number | { toNumber(): number }; + project: { id: string }; + organization: { id: string }; }; const SemanticEnvResources = { diff --git a/internal-packages/testcontainers/src/utils.ts b/internal-packages/testcontainers/src/utils.ts index b3f69f77d0a..eca9b06d388 100644 --- a/internal-packages/testcontainers/src/utils.ts +++ b/internal-packages/testcontainers/src/utils.ts @@ -7,7 +7,7 @@ import path from "path"; import { isDebug } from "std-env"; import { GenericContainer, StartedNetwork, StartedTestContainer, Wait } from "testcontainers"; import { x } from "tinyexec"; -import { expect, TaskContext } from "vitest"; +import type { TaskContext } from "vitest"; import { ClickHouseContainer, runClickhouseMigrations } from "./clickhouse"; import { MinIOContainer } from "./minio"; import { getContainerMetadata, getTaskMetadata, logCleanup, logSetup } from "./logs"; @@ -186,8 +186,21 @@ export async function createMinIOContainer(network: StartedNetwork) { } export function assertNonNullable(value: T): asserts value is NonNullable { - expect(value).toBeDefined(); - expect(value).not.toBeNull(); + // Plain throw — *not* `vitest.expect`. Two reasons: + // 1. This module is imported by globalSetup files that run before any + // vitest worker exists, so `import { expect }` from "vitest" at + // top level can crash on init. + // 2. Lazy-loading via `require("vitest")` (the prior fix) collides + // with OTel auto-instrumentation: `@opentelemetry/instrumentation` + // hooks `require()` via `require-in-the-middle`, and vitest is + // ESM-only — the require() throws "Vitest cannot be imported in + // a CommonJS module using require()", failing every test that + // uses `assertNonNullable` after OTel's been touched. + // The plain throw still gives vitest a useful failure (the message is + // shown in the stack trace) without the instrumentation hazard. + if (value === null || value === undefined) { + throw new Error(`assertNonNullable: value was ${value === null ? "null" : "undefined"}`); + } } export async function withContainerSetup({ diff --git a/internal-packages/testcontainers/src/webapp.ts b/internal-packages/testcontainers/src/webapp.ts index 9530f4c38fb..108eb911971 100644 --- a/internal-packages/testcontainers/src/webapp.ts +++ b/internal-packages/testcontainers/src/webapp.ts @@ -37,13 +37,29 @@ export interface WebappInstance { fetch(path: string, init?: RequestInit): Promise; } +export interface StartWebappOptions { + /** + * When true (default), the spawned webapp runs with `RBAC_FORCE_FALLBACK=1` + * so the default fallback handles all auth checks. The comprehensive + * suite (`*.e2e.full.test.ts`) relies on this — it's pinned to the + * fallback so results don't depend on whether `@triggerdotdev/plugins/rbac` + * happens to be installed in the local node_modules. + * + * Set to false to spawn a webapp that loads any installed RBAC + * plugin instead, for testing the plugin path. + */ + forceRbacFallback?: boolean; +} + export async function startWebapp( databaseUrl: string, - redis: { host: string; port: number } + redis: { host: string; port: number }, + options: StartWebappOptions = {} ): Promise<{ instance: WebappInstance; stop: () => Promise; }> { + const forceRbacFallback = options.forceRbacFallback ?? true; const port = await findFreePort(); // Merge NODE_PATH so transitive pnpm deps (hoisted to .pnpm/node_modules) are resolvable @@ -56,7 +72,12 @@ export async function startWebapp( cwd: WEBAPP_ROOT, env: { ...process.env, - NODE_ENV: "test", + // Match `pnpm run start` (production-mode boot). NODE_ENV=test + // surfaces a circular-init regression in the production bundle + // — see TRI-8731 — that production-mode dodges by initialising + // modules in a different order. Tests don't depend on test-mode + // semantics; they only need an isolated webapp + DB. + NODE_ENV: "production", DATABASE_URL: databaseUrl, DIRECT_URL: databaseUrl, PORT: String(port), @@ -81,6 +102,11 @@ export async function startWebapp( RUN_ENGINE_TTL_SYSTEM_DISABLED: "true", // disables TTL expiry system (BoolEnv) RUN_ENGINE_TTL_CONSUMERS_DISABLED: "true", // disables TTL consumers (BoolEnv) RUN_REPLICATION_ENABLED: "0", + // Force the RBAC loader to use the default fallback in e2e tests + // so auth behaviour is deterministic regardless of whether a + // plugin is installed in the local node_modules. Set to "0" / + // undefined to spawn a webapp that loads any installed plugin. + ...(forceRbacFallback ? { RBAC_FORCE_FALLBACK: "1" } : {}), NODE_PATH: nodePath, }, stdio: ["ignore", "pipe", "pipe"], @@ -147,15 +173,21 @@ export async function startWebapp( export interface TestServer { webapp: WebappInstance; prisma: PrismaClient; + // Postgres connection string. Useful when test workers run in separate + // processes and need to construct their own clients against the same DB. + databaseUrl: string; stop: () => Promise; } /** Convenience helper: starts a postgres + redis container + webapp and returns both for testing. */ -export async function startTestServer(): Promise { +export async function startTestServer( + options: StartWebappOptions = {} +): Promise { const network = await new Network().start(); // Track each resource as we acquire it so we can tear it down if a later step fails. let pgContainer: Awaited>["container"] | undefined; + let pgUrl: string | undefined; let redisContainer: Awaited>["container"] | undefined; let prisma: PrismaClient | undefined; let stopWebapp: (() => Promise) | undefined; @@ -164,13 +196,18 @@ export async function startTestServer(): Promise { try { const pg = await createPostgresContainer(network); pgContainer = pg.container; + pgUrl = pg.url; const { container: rc } = await createRedisContainer({ network }); redisContainer = rc; prisma = new PrismaClient({ datasources: { db: { url: pg.url } } }); await prisma.$connect(); // pre-warm pool; surface connection failures before tests start - const started = await startWebapp(pg.url, { host: rc.getHost(), port: rc.getPort() }); + const started = await startWebapp( + pg.url, + { host: rc.getHost(), port: rc.getPort() }, + options + ); webapp = started.instance; stopWebapp = started.stop; } catch (err) { @@ -190,5 +227,5 @@ export async function startTestServer(): Promise { await network.stop().catch((err) => console.error("network.stop failed:", err)); }; - return { webapp, prisma: prisma!, stop }; + return { webapp, prisma: prisma!, databaseUrl: pgUrl!, stop }; } diff --git a/packages/core/package.json b/packages/core/package.json index cd62bc97d6f..00c0315adb2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -30,6 +30,7 @@ "./v3/tracer": "./src/v3/tracer.ts", "./v3/build": "./src/v3/build/index.ts", "./v3/apps": "./src/v3/apps/index.ts", + "./v3/auth/environment": "./src/v3/auth/environment.ts", "./v3/jwt": "./src/v3/jwt.ts", "./v3/errors": "./src/v3/errors.ts", "./v3/logger-api": "./src/v3/logger-api.ts", @@ -37,6 +38,7 @@ "./v3/semanticInternalAttributes": "./src/v3/semanticInternalAttributes.ts", "./v3/utils/durations": "./src/v3/utils/durations.ts", "./v3/utils/flattenAttributes": "./src/v3/utils/flattenAttributes.ts", + "./v3/utils/gitBranch": "./src/v3/utils/gitBranch.ts", "./v3/utils/ioSerialization": "./src/v3/utils/ioSerialization.ts", "./v3/utils/omit": "./src/v3/utils/omit.ts", "./v3/utils/retries": "./src/v3/utils/retries.ts", @@ -96,12 +98,18 @@ "v3/semanticInternalAttributes": [ "dist/commonjs/v3/semanticInternalAttributes.d.ts" ], + "v3/auth/environment": [ + "dist/commonjs/v3/auth/environment.d.ts" + ], "v3/utils/durations": [ "dist/commonjs/v3/utils/durations.d.ts" ], "v3/utils/flattenAttributes": [ "dist/commonjs/v3/utils/flattenAttributes.d.ts" ], + "v3/utils/gitBranch": [ + "dist/commonjs/v3/utils/gitBranch.d.ts" + ], "v3/utils/ioSerialization": [ "dist/commonjs/v3/utils/ioSerialization.d.ts" ], @@ -325,6 +333,17 @@ "default": "./dist/commonjs/v3/apps/index.js" } }, + "./v3/auth/environment": { + "import": { + "@triggerdotdev/source": "./src/v3/auth/environment.ts", + "types": "./dist/esm/v3/auth/environment.d.ts", + "default": "./dist/esm/v3/auth/environment.js" + }, + "require": { + "types": "./dist/commonjs/v3/auth/environment.d.ts", + "default": "./dist/commonjs/v3/auth/environment.js" + } + }, "./v3/jwt": { "import": { "@triggerdotdev/source": "./src/v3/jwt.ts", @@ -402,6 +421,17 @@ "default": "./dist/commonjs/v3/utils/flattenAttributes.js" } }, + "./v3/utils/gitBranch": { + "import": { + "@triggerdotdev/source": "./src/v3/utils/gitBranch.ts", + "types": "./dist/esm/v3/utils/gitBranch.d.ts", + "default": "./dist/esm/v3/utils/gitBranch.js" + }, + "require": { + "types": "./dist/commonjs/v3/utils/gitBranch.d.ts", + "default": "./dist/commonjs/v3/utils/gitBranch.js" + } + }, "./v3/utils/ioSerialization": { "import": { "@triggerdotdev/source": "./src/v3/utils/ioSerialization.ts", diff --git a/packages/core/src/v3/auth/environment.ts b/packages/core/src/v3/auth/environment.ts new file mode 100644 index 00000000000..8918f191300 --- /dev/null +++ b/packages/core/src/v3/auth/environment.ts @@ -0,0 +1,108 @@ +// Slim shape of an authenticated runtime environment, structural and +// independent of @trigger.dev/database. Carried across the auth boundary +// (RBAC plugin contract → host webapp) so plugins can return all the +// fields handlers consume without a follow-up DB lookup. +// +// This is hand-rolled rather than derived from `Prisma.RuntimeEnvironmentGetPayload` +// because the contract package (@trigger.dev/plugins) is published while +// @trigger.dev/database is private — and because callers of this type +// genuinely use only a fraction of the columns Prisma would expose. +// +// If a downstream consumer needs a field that's not here: +// - Used in the auth-cross-cutting hot path → add it +// - Used in a service that already loads the env → fetch it there instead +// +// `concurrencyLimitBurstFactor` is a `Decimal(4,2)` in Postgres — values +// are O(2.00) in practice; coerced to `number` here (lossless at this +// scale, avoids dragging in Prisma's Decimal class via type imports). + +// String-literal unions mirror the corresponding Prisma enums. Defining +// them here keeps the contract structural (no @trigger.dev/database +// import) while giving downstream consumers the same exact union they +// expect when this value is passed to a Prisma column. +export type RuntimeEnvironmentType = + | "PRODUCTION" + | "STAGING" + | "DEVELOPMENT" + | "PREVIEW"; + +export type RunEngineVersion = "V1" | "V2"; + +// Prisma's Decimal class. Accept it structurally so consumers (mostly +// the webapp's `runtimeEnvironment.server.ts` model functions) can pass +// raw Prisma rows without coercion. Plugins that don't have a Decimal +// type at hand (cloud's Drizzle plugin) return plain `number`. +type DecimalLike = { toNumber(): number }; + +export type AuthenticatedEnvironment = { + id: string; + slug: string; + type: RuntimeEnvironmentType; + apiKey: string; + organizationId: string; + projectId: string; + orgMemberId: string | null; + parentEnvironmentId: string | null; + branchName: string | null; + archivedAt: Date | null; + paused: boolean; + shortcode: string; + maximumConcurrencyLimit: number; + concurrencyLimitBurstFactor: number | DecimalLike; + // Prisma JSON column. Specific flags read it with their own narrower + // types. Pass-through for legacy override paths in marqs / sharedQueue. + builtInEnvironmentVariableOverrides: unknown; + // Bookkeeping timestamps. Prisma rows always have them; non-Prisma + // plugins can fill in with `new Date()` or whatever's appropriate. + createdAt: Date; + updatedAt: Date; + + project: { + id: string; + slug: string; + name: string; + externalRef: string; + engine: RunEngineVersion; + deletedAt: Date | null; + defaultWorkerGroupId: string | null; + // Same id as env.organizationId — present on Prisma's Project row + // and read by deployment services that operate on the project alone. + organizationId: string; + // Build-server bookkeeping. Read by remote-image-builder when + // creating Depot builds. + builderProjectId: string | null; + }; + + organization: { + id: string; + slug: string; + title: string; + streamBasinName: string | null; + maximumConcurrencyLimit: number | null; + runsEnabled: boolean; + maximumDevQueueSize: number | null; + maximumDeployedQueueSize: number | null; + // Per-org feature flags + rate-limit config. Loosely typed (Prisma + // JSON) — handlers that care about specific keys read with their + // own narrower types. + featureFlags: unknown; + apiRateLimiterConfig: unknown; + batchRateLimitConfig: unknown; + batchQueueConcurrencyConfig: unknown; + }; + + // `user` is optional because most call sites only fetch `userId`. + // Code paths that need user details (display name etc.) include it + // explicitly in their Prisma query. The whole field is optional too + // so admin construction sites that build env literals without it + // satisfy the type. + orgMember?: { + userId: string; + user?: { id: string; displayName: string | null; name: string | null }; + } | null; + + // Optional + nullable: optional so admin routes that don't explicitly + // include parentEnvironment satisfy the type; nullable so Prisma rows + // with a null left-join result satisfy too. + parentEnvironment?: { id: string; apiKey: string } | null; +}; diff --git a/apps/webapp/app/v3/gitBranch.ts b/packages/core/src/v3/utils/gitBranch.ts similarity index 73% rename from apps/webapp/app/v3/gitBranch.ts rename to packages/core/src/v3/utils/gitBranch.ts index 06c76f06241..b1f2f2df27a 100644 --- a/apps/webapp/app/v3/gitBranch.ts +++ b/packages/core/src/v3/utils/gitBranch.ts @@ -1,35 +1,23 @@ export function isValidGitBranchName(branch: string): boolean { - // Must not be empty if (!branch) return false; - // Disallowed characters: space, ~, ^, :, ?, *, [, \ if (/[ \~\^:\?\*\[\\]/.test(branch)) return false; - // Disallow ASCII control characters (0-31) and DEL (127) for (let i = 0; i < branch.length; i++) { const code = branch.charCodeAt(i); if ((code >= 0 && code <= 31) || code === 127) return false; } - // Cannot start or end with a slash if (branch.startsWith("/") || branch.endsWith("/")) return false; - - // Cannot have consecutive slashes if (branch.includes("//")) return false; - - // Cannot contain '..' if (branch.includes("..")) return false; - - // Cannot contain '@{' if (branch.includes("@{")) return false; - - // Cannot end with '.lock' if (branch.endsWith(".lock")) return false; return true; } -export function sanitizeBranchName(ref: string | undefined): string | null { +export function sanitizeBranchName(ref: string | null | undefined): string | null { if (!ref) return null; if (ref.startsWith("refs/heads/")) return ref.substring("refs/heads/".length); if (ref.startsWith("refs/remotes/")) return ref.substring("refs/remotes/".length); @@ -37,7 +25,6 @@ export function sanitizeBranchName(ref: string | undefined): string | null { if (ref.startsWith("refs/pull/")) return ref.substring("refs/pull/".length); if (ref.startsWith("refs/merge/")) return ref.substring("refs/merge/".length); if (ref.startsWith("refs/release/")) return ref.substring("refs/release/".length); - //unknown ref format, so reject if (ref.startsWith("refs/")) return null; return ref; diff --git a/packages/plugins/CHANGELOG.md b/packages/plugins/CHANGELOG.md new file mode 100644 index 00000000000..ed4841c95e1 --- /dev/null +++ b/packages/plugins/CHANGELOG.md @@ -0,0 +1,7 @@ +# @trigger.dev/plugins + +## 0.0.0-prerelease-20260506134321 + +### Patch Changes + +- b3a967765: The public interfaces for a plugin system. Initially consolidated authentication and authorization interfaces. diff --git a/packages/plugins/package.json b/packages/plugins/package.json new file mode 100644 index 00000000000..7b52afb7b87 --- /dev/null +++ b/packages/plugins/package.json @@ -0,0 +1,46 @@ +{ + "name": "@trigger.dev/plugins", + "version": "4.4.6", + "description": "Plugin contracts and interfaces for Trigger.dev", + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/triggerdotdev/trigger.dev", + "directory": "packages/plugins" + }, + "type": "module", + "files": [ + "dist" + ], + "dependencies": { + "@trigger.dev/core": "workspace:*" + }, + "scripts": { + "clean": "rimraf dist .turbo", + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@types/node": "^20.14.14", + "rimraf": "6.0.1", + "tsup": "^8.4.0", + "typescript": "^5.3.0" + }, + "engines": { + "node": ">=18.20.0" + }, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + } +} diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts new file mode 100644 index 00000000000..9a03d93b66b --- /dev/null +++ b/packages/plugins/src/index.ts @@ -0,0 +1,23 @@ +export type { + RoleBasedAccessControlPlugin, + RoleBaseAccessController, + RoleAssignmentResult, + RoleMutationResult, + Permission, + Role, + RbacAbility, + RbacSubject, + RbacResource, + RbacEnvironment, + RbacUser, + BearerAuthResult, + SessionAuthResult, + PatAuthResult, + SystemRole, + AuthenticatedEnvironment, +} from "./rbac.js"; + +// Convenience re-exports — gives plugin authors (and the cloud workspace +// link) one import surface without reaching into @trigger.dev/core +// directly. Both helpers live in core; this is purely a forwarder. +export { sanitizeBranchName, isValidGitBranchName } from "@trigger.dev/core/v3/utils/gitBranch"; diff --git a/packages/plugins/src/rbac.ts b/packages/plugins/src/rbac.ts new file mode 100644 index 00000000000..7b59a3ab233 --- /dev/null +++ b/packages/plugins/src/rbac.ts @@ -0,0 +1,277 @@ +/** + * Plugin-owned metadata for a built-in system role. The plugin returns + * these in canonical order (highest authority first) so the dashboard + * can render columns / build a level ladder without knowing role names. + * + * Roles the plugin doesn't expose at all (e.g. seeded but with the + * `is_hidden` flag set in the cloud plugin) are not returned by + * `systemRoles()` — there's no "advertised but absent" state. + * + * `available` indicates whether the role is assignable on the *org's + * plan*. v1: Free/Hobby plans get Owner+Admin available; Pro+ adds + * Developer. Consumers may render unavailable rows with an upgrade + * badge, hide them, or otherwise gate UI on the flag. + */ +export type SystemRole = { + id: string; + name: string; + description: string; + available: boolean; +}; + +export type Permission = { + // `:` — display name, derived from the ability rule. + name: string; + description: string; + // Display bucket for the Roles page (e.g. "Runs", "Tasks"). The page + // groups permissions by this string and lists groups in the order they + // first appear in `allPermissions()`, so the plugin owns both the + // bucket label and the section ordering. Omit for "no grouping". + group?: string; + // Inverted rules (CASL `cannot`) surface as ✗ in the Roles page. + inverted?: boolean; + // CASL conditions (e.g. `{ envType: "PRODUCTION" }`) — when present, + // the Roles page renders a tier badge alongside the permission row. + conditions?: Record; +}; + +export type Role = { + id: string; + name: string; + description: string; + permissions: Permission[]; + isSystem: boolean; +}; + +export type RbacSubject = + | { type: "user"; userId: string; organizationId: string; projectId?: string } + | { type: "personalAccessToken"; tokenId: string; organizationId: string; projectId?: string } + | { type: "publicJWT"; environmentId: string; organizationId: string; projectId?: string }; + +export type RbacResource = { + type: string; + id?: string; + // Extra fields a route may pass for condition-based ability checks — + // e.g. `envType` for env-tier-scoped rules ("Member can read envvars + // unless envType === 'PRODUCTION'"). The plugin's ability matcher + // (CASL) reads these off the resource object; routes that don't use + // conditional rules can keep passing `{ type, id? }`. + [key: string]: unknown; +}; + +// The plugin contract carries the same env shape that host webapps' auth +// flows use. Defined in @trigger.dev/core so it's importable from any +// internal package without going through the plugin contract itself. +export type { AuthenticatedEnvironment } from "@trigger.dev/core/v3/auth/environment"; +import type { AuthenticatedEnvironment as RbacEnv } from "@trigger.dev/core/v3/auth/environment"; + +/** @deprecated Renamed to `AuthenticatedEnvironment`. Kept as alias for transitional code. */ +export type RbacEnvironment = RbacEnv; + +export type RbacUser = { + id: string; + email: string; + name: string | null; + displayName: string | null; + avatarUrl: string | null; + admin: boolean; + confirmedBasicDetails: boolean; + isImpersonating: boolean; +}; + +/** Pre-built ability returned by authenticate* — all checks are sync, no DB call. */ +export interface RbacAbility { + // Array form means "grant access if any resource in the array passes" — + // used by routes that touch multiple resources (e.g. a run also carries + // a batch id, tags, a task identifier) so a JWT scoped to any of them + // grants access. + can(action: string, resource: RbacResource | RbacResource[]): boolean; + canSuper(): boolean; +} + +export type BearerAuthResult = + | { ok: false; status: 401 | 403; error: string } + | { + ok: true; + environment: RbacEnv; + subject: RbacSubject; + ability: RbacAbility; + jwt?: { realtime?: { skipColumns?: string[] }; oneTimeUse?: boolean }; + }; + +export type SessionAuthResult = + | { ok: false; reason: "unauthenticated" | "unauthorized" } + | { ok: true; user: RbacUser; subject: RbacSubject; ability: RbacAbility }; + +// PAT auth deliberately omits `environment` — PATs are user identity +// tokens, not environment tokens. The ability is resolved per-request +// from the user's role in the target org (passed via `context`), +// intersected with the PAT's optional max-role cap. +export type PatAuthResult = + | { ok: false; status: 401 | 403; error: string } + | { + ok: true; + tokenId: string; + userId: string; + // The token's stored `lastAccessedAt`, returned alongside the + // identity so the host can throttle the per-request update in JS + // (skip the DB roundtrip when the value is fresh). Plugins must + // include this column in their auth lookup; the host owns the + // throttle window + the UPDATE itself. Null on a never-accessed + // token. The plugin contract requires this so the apiBuilder can + // collapse PAT auth + lastAccessedAt update from 2 queries to 1 + // in the fresh-cache case — matching pre-RBAC main's query count. + lastAccessedAt: Date | null; + subject: RbacSubject; + ability: RbacAbility; + }; + +export interface RoleBaseAccessController { + // True when a real RBAC plugin is loaded (i.e. cloud); false when the + // OSS fallback is in use. Hosts gate behaviour that's only meaningful + // when the plugin is present (e.g. skipping role-attachment writes, + // hiding role-pickers in the UI, branching on whether ability checks + // are authoritative or permissive). + isUsingPlugin(): Promise; + + // API routes (Bearer token): one DB query → identity + pre-built ability + // options.allowJWT: when true, accepts PUBLIC_JWT tokens in addition to environment API keys + authenticateBearer(request: Request, options?: { allowJWT?: boolean }): Promise; + + // Dashboard loaders/actions (session cookie): one DB query → user + pre-built ability. + // The caller resolves `userId` from the session cookie and passes it in. + // (`null` means "no authenticated user"; the plugin returns `{ ok: false, + // reason: "unauthenticated" }`.) The plugin used to take a + // `helpers.getSessionUserId(request)` callback at create-time; pulling the + // userId resolution into the caller drops a static module-load coupling + // from the plugin's host module to the host's session-cookie code. + authenticateSession( + request: Request, + context: { userId: string | null; organizationId?: string; projectId?: string } + ): Promise; + + // PAT-authenticated routes (Authorization: Bearer tr_pat_…). The token + // identifies the user; the effective ability is `min(user's current + // role in the target org, the PAT's optional max-role cap)`. The user's + // actual org membership is the floor — if they've been demoted or + // removed, the PAT auto-narrows. The cap is set at PAT creation and + // ceilings the token even when the user is more privileged. + // + // No plugin installed → fallback returns a permissive ability so PAT + // routes that don't yet declare an `authorization` block keep working + // exactly as they did pre-RBAC. + authenticatePat( + request: Request, + context: { organizationId?: string; projectId?: string } + ): Promise; + + // Convenience: authenticate + ability.can() check in one call; returns ok:false if check fails. + // resource accepts the same single-or-array shape as RbacAbility.can — array form means + // "grant access if any element passes". + authenticateAuthorizeBearer( + request: Request, + check: { action: string; resource: RbacResource | RbacResource[] }, + options?: { allowJWT?: boolean } + ): Promise; + + authenticateAuthorizeSession( + request: Request, + context: { userId: string | null; organizationId?: string; projectId?: string }, + check: { action: string; resource: RbacResource | RbacResource[] } + ): Promise; + + // Plugin-owned catalogue of built-in system roles for the given org, + // in canonical order (highest authority first). Returns null when no + // plugin is installed — there are no seeded roles to refer to in that + // case (the default fallback's `allRoles` returns []). + // + // Hidden roles (e.g. Member in v1) are filtered out entirely. Each + // entry's `available` flag reflects whether the org's plan permits + // assigning that role; consumers can render unavailable entries with + // an upgrade badge or hide them. + systemRoles(organizationId: string): Promise; + + // Role introspection. The fallback returns []; a plugin may return + // its own role catalogue. + allPermissions(organizationId: string): Promise; + allRoles(organizationId: string): Promise; + + // Of the roles returned by `allRoles(organizationId)`, which IDs may + // be assigned right now? Used by the Teams page UI to disable + // role-dropdown options the org isn't allowed to assign. The default + // fallback returns every role id (permissive — it doesn't apply any + // gating). Server-side enforcement lives in setUserRole; this method + // is purely a UI affordance. + getAssignableRoleIds(organizationId: string): Promise; + + // Role management. Mutation methods return a discriminated Result + // rather than throwing — the dashboard surfaces `error` strings + // directly to the user (system role edits, gating, validation + // conflicts), so a thrown exception is only ever for unexpected + // failures (DB outage, bug). The default fallback returns + // `{ ok: false, error: "RBAC plugin not installed" }` for these. + createRole(params: { + organizationId: string; + name: string; + description: string; + permissions: string[]; + }): Promise; + + updateRole(params: { + roleId: string; + name?: string; + description?: string; + permissions?: string[]; + }): Promise; + + deleteRole(roleId: string): Promise; + + // Role assignments. Same Result discipline as the role-management + // methods above. The default fallback returns + // `{ ok: false, error: "RBAC plugin not installed" }`. + getUserRole(params: { + userId: string; + organizationId: string; + projectId?: string; + }): Promise; + + // Batch variant for callers that need per-user roles for many users + // in one round-trip (e.g. the Team page rendering N members). + // Org-scoped only — project-scoped reads still go through getUserRole. + // Returns a Map keyed by userId; users with no resolvable role map to + // null. The default fallback returns a Map of all userIds → null. + getUserRoles( + userIds: string[], + organizationId: string + ): Promise>; + + setUserRole(params: { + userId: string; + organizationId: string; + roleId: string; + projectId?: string; + }): Promise; + + removeUserRole(params: { + userId: string; + organizationId: string; + projectId?: string; + }): Promise; + + getTokenRole(tokenId: string): Promise; + setTokenRole(params: { tokenId: string; roleId: string }): Promise; + removeTokenRole(tokenId: string): Promise; +} + +// Mutation result for role create/update — success carries the new +// `role`, failure carries a user-facing `error` string. +export type RoleMutationResult = + | { ok: true; role: Role } + | { ok: false; error: string }; + +// Result for assignment / deletion mutations that don't return a value. +export type RoleAssignmentResult = { ok: true } | { ok: false; error: string }; + +export interface RoleBasedAccessControlPlugin { + create(): RoleBaseAccessController | Promise; +} diff --git a/packages/plugins/tsconfig.json b/packages/plugins/tsconfig.json new file mode 100644 index 00000000000..e16a109bd98 --- /dev/null +++ b/packages/plugins/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../.configs/tsconfig.base.json", + "compilerOptions": { + "sourceMap": true + }, + "include": ["./src/**/*.ts"] +} diff --git a/packages/plugins/tsup.config.ts b/packages/plugins/tsup.config.ts new file mode 100644 index 00000000000..4dff9109b7f --- /dev/null +++ b/packages/plugins/tsup.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "tsup"; + +export default defineConfig({ + entry: ["src/index.ts"], + format: ["cjs", "esm"], + dts: true, + splitting: false, + sourcemap: true, + clean: true, + treeshake: true, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 82e8a7ecb11..58ef3508d2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,6 +536,9 @@ importers: '@trigger.dev/platform': specifier: 1.0.27 version: 1.0.27 + '@trigger.dev/rbac': + specifier: workspace:* + version: link:../../internal-packages/rbac '@trigger.dev/redis-worker': specifier: workspace:* version: link:../../packages/redis-worker @@ -1147,7 +1150,7 @@ importers: version: 18.3.1 react-email: specifier: ^2.1.1 - version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0) + version: 2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0) resend: specifier: ^3.2.0 version: 3.2.0 @@ -1200,6 +1203,25 @@ importers: specifier: ^1.167.3 version: 1.167.3 + internal-packages/rbac: + dependencies: + '@trigger.dev/core': + specifier: workspace:* + version: link:../../packages/core + '@trigger.dev/plugins': + specifier: workspace:* + version: link:../../packages/plugins + devDependencies: + '@trigger.dev/database': + specifier: workspace:* + version: link:../database + '@types/node': + specifier: 20.14.14 + version: 20.14.14 + rimraf: + specifier: 6.0.1 + version: 6.0.1 + internal-packages/redis: dependencies: '@trigger.dev/core': @@ -1888,6 +1910,25 @@ importers: specifier: 4.17.0 version: 4.17.0 + packages/plugins: + dependencies: + '@trigger.dev/core': + specifier: workspace:* + version: link:../core + devDependencies: + '@types/node': + specifier: 20.14.14 + version: 20.14.14 + rimraf: + specifier: 6.0.1 + version: 6.0.1 + tsup: + specifier: ^8.4.0 + version: 8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.10)(tsx@4.20.6)(typescript@5.5.4)(yaml@2.8.3) + typescript: + specifier: 5.5.4 + version: 5.5.4 + packages/python: dependencies: '@trigger.dev/core': @@ -18525,10 +18566,6 @@ packages: resolution: {integrity: sha512-Zc+8eJlFMvgatPZTl6A9L/yht8QqdmUNtURHaKZLmKBE12hNPSrqNkUp2cs3M/UKmNVVAMFQYSjYIVHDjW5zew==} engines: {node: '>=12.0.0'} - tinyglobby@0.2.12: - resolution: {integrity: sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==} - engines: {node: '>=12.0.0'} - tinyglobby@0.2.13: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} @@ -37161,6 +37198,15 @@ snapshots: tsx: 4.17.0 yaml: 2.8.3 + postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.10)(tsx@4.20.6)(yaml@2.8.3): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + jiti: 2.4.2 + postcss: 8.5.10 + tsx: 4.20.6 + yaml: 2.8.3 + postcss-loader@8.1.1(postcss@8.5.10)(typescript@5.5.4)(webpack@5.102.1(@swc/core@1.3.26)(esbuild@0.15.18)): dependencies: cosmiconfig: 9.0.0(typescript@5.5.4) @@ -37706,7 +37752,7 @@ snapshots: react: 18.2.0 react-dom: 18.2.0(react@18.2.0) - react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(eslint@8.31.0): + react-email@2.1.2(@opentelemetry/api@1.9.0)(@swc/helpers@0.5.15)(bufferutil@4.0.9)(eslint@8.31.0): dependencies: '@babel/parser': 7.24.1 '@radix-ui/colors': 1.0.1 @@ -37743,8 +37789,8 @@ snapshots: react: 18.3.1 react-dom: 18.2.0(react@18.3.1) shelljs: 0.8.5 - socket.io: 4.7.3 - socket.io-client: 4.7.3 + socket.io: 4.7.3(bufferutil@4.0.9) + socket.io-client: 4.7.3(bufferutil@4.0.9) sonner: 1.3.1(react-dom@18.2.0(react@18.3.1))(react@18.3.1) source-map-js: 1.0.2 stacktrace-parser: 0.1.10 @@ -38956,7 +39002,7 @@ snapshots: - supports-color - utf-8-validate - socket.io-client@4.7.3: + socket.io-client@4.7.3(bufferutil@4.0.9): dependencies: '@socket.io/component-emitter': 3.1.0 debug: 4.3.7(supports-color@10.0.0) @@ -38985,7 +39031,7 @@ snapshots: transitivePeerDependencies: - supports-color - socket.io@4.7.3: + socket.io@4.7.3(bufferutil@4.0.9): dependencies: accepts: 1.3.8 base64id: 2.0.0 @@ -39790,11 +39836,6 @@ snapshots: fdir: 6.4.3(picomatch@4.0.4) picomatch: 4.0.4 - tinyglobby@0.2.12: - dependencies: - fdir: 6.4.4(picomatch@4.0.4) - picomatch: 4.0.4 - tinyglobby@0.2.13: dependencies: fdir: 6.4.4(picomatch@4.0.4) @@ -39974,7 +40015,7 @@ snapshots: cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 - debug: 4.4.0 + debug: 4.4.3(supports-color@10.0.0) esbuild: 0.25.1 joycon: 3.1.1 picocolors: 1.1.1 @@ -39984,7 +40025,35 @@ snapshots: source-map: 0.8.0-beta.0 sucrase: 3.35.0 tinyexec: 0.3.2 - tinyglobby: 0.2.12 + tinyglobby: 0.2.13 + tree-kill: 1.2.2 + optionalDependencies: + '@swc/core': 1.3.101(@swc/helpers@0.5.15) + postcss: 8.5.10 + typescript: 5.5.4 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsup@8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.10)(tsx@4.20.6)(typescript@5.5.4)(yaml@2.8.3): + dependencies: + bundle-require: 5.1.0(esbuild@0.25.1) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3(supports-color@10.0.0) + esbuild: 0.25.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.10)(tsx@4.20.6)(yaml@2.8.3) + resolve-from: 5.0.0 + rollup: 4.60.1 + source-map: 0.8.0-beta.0 + sucrase: 3.35.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.13 tree-kill: 1.2.2 optionalDependencies: '@swc/core': 1.3.101(@swc/helpers@0.5.15) From e8ef374fe0a6d780df8e6b54b42c43392d4b5a6d Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 12 May 2026 18:37:19 +0100 Subject: [PATCH 004/238] fix(webapp,run-engine): honor per-queue length cap on concurrency-key queues (#3558) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Queues that use concurrency keys can no longer bypass the per-queue length cap, and the "Queued | Running" columns in the dashboard now show the true total across all CK variants instead of 0. The cap and the dashboard both relied on `ZCARD` of the base queue key, but CK-keyed runs live under `:ck:` keys. Any queue that used concurrency keys read 0 — letting a single CK variant grow unbounded past the user's configured cap. ## Fix Two per-base-queue counters are maintained inside the CK Lua scripts: `:lengthCounter` and `:runningCounter`. Non-CK enqueue/dequeue paths are untouched. Counters are lazy-initialized the first time a CK enqueue (or nack) lands on a queue: the Lua script sums `ZCARD` across the variants tracked by `ckIndex`, sets the counter, then `INCR`s. Pre-existing CK backlog on already-populated queues is captured automatically — no batch migration required. `INCR`/`DECR` is gated on `ZADD`/`SADD` returning 1 (a new entry vs an idempotent no-op), so duplicate enqueues or re-dequeues don't inflate the counter. The counter is `SET` with a 24-hour TTL on init. `INCR`/`DECR` do not extend the TTL, so the counter expires daily and the next CK operation re-seeds it from `ckIndex`. This bounds any drift that accumulates during the rolling-deploy overlap window — where old (un-Tracked) and new (Tracked) webapp instances briefly coexist — to ≤24 hours, with no admin sweep or background reconciler needed. Read paths pipeline `ZCARD`/`SCARD` on the base key + `GET` on the counter and sum. A missing counter is treated as 0, so pure non-CK queues see the same answer as before. The counter-aware scripts ship alongside the originals with a `Tracked` suffix for rolling-deploy safety; a follow-up PR will drop the originals once this has rolled out. ## Test plan - [ ] `pnpm run test --filter @internal/run-engine` — 116 tests pass, including a new `ckCounters.test.ts` covering lazy init from pre-existing backlog, churn, floor-at-zero, the non-CK regression case, mixed CK + non-CK on the same base queue, idempotent re-enqueue (ZADD-already-exists), 24h TTL on the counter, and nack re-seeding after counter expiry. - [ ] Verified end-to-end against a live local environment: - Triggered 24 CK enqueues across 4 variants → `lengthCounter=16`, `runningCounter=8`, dashboard showed Queued=16 / Running=8 for the CK queue. - Set the env queue cap to 16, triggered 12 more enqueues → 8 succeeded, 4 rejected with `QueueSizeLimitExceededError`. - Deleted the counter on a queue with 31 messages already sitting in CK variants, triggered one more enqueue → counter materialized to 31 from the `ckIndex` sum, then INCR'd. --- .../fix-ck-queue-length-cap-and-dashboard.md | 6 + .../run-engine/src/run-queue/index.ts | 1364 ++++++++++++++++- .../run-engine/src/run-queue/keyProducer.ts | 18 + .../src/run-queue/tests/ckCounters.test.ts | 643 ++++++++ .../run-engine/src/run-queue/types.ts | 6 + .../hello-world/src/trigger/ckCounters.ts | 101 ++ 6 files changed, 2054 insertions(+), 84 deletions(-) create mode 100644 .server-changes/fix-ck-queue-length-cap-and-dashboard.md create mode 100644 internal-packages/run-engine/src/run-queue/tests/ckCounters.test.ts create mode 100644 references/hello-world/src/trigger/ckCounters.ts diff --git a/.server-changes/fix-ck-queue-length-cap-and-dashboard.md b/.server-changes/fix-ck-queue-length-cap-and-dashboard.md new file mode 100644 index 00000000000..9b225b29d92 --- /dev/null +++ b/.server-changes/fix-ck-queue-length-cap-and-dashboard.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Per-queue length limits and the dashboard's "Queued | Running" columns now reflect the true total across all concurrency-key variants. Previously both read 0 for any queue that used concurrency keys, allowing the per-queue cap to be bypassed. diff --git a/internal-packages/run-engine/src/run-queue/index.ts b/internal-packages/run-engine/src/run-queue/index.ts index de0df73ad05..c695cacad07 100644 --- a/internal-packages/run-engine/src/run-queue/index.ts +++ b/internal-packages/run-engine/src/run-queue/index.ts @@ -76,6 +76,14 @@ export type RunQueueOptions = { masterQueueCooloffCountThreshold?: number; masterQueueConsumerDequeueCount?: number; processWorkerQueueDebounceMs?: number; + /** + * TTL (seconds) applied to the per-base-queue lengthCounter/runningCounter on + * lazy-init. Bounds the maximum window for any drift accumulated during a + * rolling-deploy v1/v2 overlap. INCR/DECR do NOT extend the TTL, so the + * counter expires this long after init regardless of activity, and the next + * CK operation re-anchors from ckIndex. Default: 86400 (24h). + */ + counterTtlSeconds?: number; workerOptions?: { pollIntervalMs?: number; immediatePollIntervalMs?: number; @@ -186,6 +194,7 @@ export class RunQueue { public keys: RunQueueKeyProducer; private queueSelectionStrategy: RunQueueSelectionStrategy; private shardCount: number; + private counterTtlSeconds: number; private abortController: AbortController; private worker: Worker; private workerQueueResolver: WorkerQueueResolver; @@ -195,6 +204,7 @@ export class RunQueue { constructor(public readonly options: RunQueueOptions) { this.shardCount = options.shardCount ?? 2; + this.counterTtlSeconds = options.counterTtlSeconds ?? 86400; this.retryOptions = options.retryOptions ?? defaultRetrySettings; this.redis = createRedisClient(options.redis, { onError: (error) => { @@ -397,7 +407,31 @@ export class RunQueue { queue: string, concurrencyKey?: string ) { - return this.redis.zcard(this.keys.queueKey(env, queue, concurrencyKey)); + // Per-variant length when caller specifies a concurrency key + if (concurrencyKey) { + return this.redis.zcard(this.keys.queueKey(env, queue, concurrencyKey)); + } + + // Aggregate base-queue length = ZCARD(base) + GET(lengthCounter). + // The counter is non-existent for queues that have never had a CK enqueue — + // in that case it returns null which we treat as 0 and the base ZCARD is + // the whole truth. + const baseKey = this.keys.queueKey(env, queue); + const lengthCounterKey = this.keys.queueLengthCounterKey(env, queue); + + const pipeline = this.redis.pipeline(); + pipeline.zcard(baseKey); + pipeline.get(lengthCounterKey); + const results = await pipeline.exec(); + + if (!results) { + return 0; + } + const [baseErr, baseVal] = results[0]; + const [ctrErr, ctrVal] = results[1]; + const baseCount = baseErr || baseVal == null ? 0 : (baseVal as number); + const ctrCount = ctrErr || ctrVal == null ? 0 : Number(ctrVal); + return baseCount + ctrCount; } public async lengthOfQueueAvailableMessages( @@ -481,33 +515,34 @@ export class RunQueue { env: MinimalAuthenticatedEnvironment, queues: string[] ): Promise> { + // For each queue, SCARD(base:currentDequeued) + GET(runningCounter). Missing + // counter is treated as 0 so non-CK queues just see the base SCARD. const pipeline = this.redis.pipeline(); - - // Queue up all SCARD commands in the pipeline queues.forEach((queue) => { pipeline.scard(this.keys.queueCurrentDequeuedKey(env, queue)); + pipeline.get(this.keys.queueRunningCounterKey(env, queue)); }); - // Execute pipeline and get results const results = await pipeline.exec(); - // If results is null, return all queues with 0 concurrency - if (!results) { - return queues.reduce( + const empty = (): Record => + queues.reduce( (acc, queue) => { acc[queue] = 0; return acc; }, {} as Record ); - } - // Map results back to queue names, handling potential errors + if (!results) return empty(); + return queues.reduce( (acc, queue, index) => { - const [err, value] = results[index]; - // If there was an error or value is null/undefined, use 0 - acc[queue] = err || value == null ? 0 : (value as number); + const [baseErr, baseVal] = results[index * 2]; + const [ctrErr, ctrVal] = results[index * 2 + 1]; + const baseCount = baseErr || baseVal == null ? 0 : (baseVal as number); + const ctrCount = ctrErr || ctrVal == null ? 0 : Number(ctrVal); + acc[queue] = baseCount + ctrCount; return acc; }, {} as Record @@ -518,29 +553,34 @@ export class RunQueue { env: MinimalAuthenticatedEnvironment, queues: string[] ): Promise> { + // For each queue, ZCARD(base) + GET(lengthCounter). Missing counter is + // treated as 0 so non-CK queues just see the base ZCARD. const pipeline = this.redis.pipeline(); - - // Queue up all ZCARD commands in the pipeline queues.forEach((queue) => { pipeline.zcard(this.keys.queueKey(env, queue)); + pipeline.get(this.keys.queueLengthCounterKey(env, queue)); }); const results = await pipeline.exec(); - if (!results) { - return queues.reduce( + const empty = (): Record => + queues.reduce( (acc, queue) => { acc[queue] = 0; return acc; }, {} as Record ); - } + + if (!results) return empty(); return queues.reduce( (acc, queue, index) => { - const [err, value] = results![index]; - acc[queue] = err || value == null ? 0 : (value as number); + const [baseErr, baseVal] = results[index * 2]; + const [ctrErr, ctrVal] = results[index * 2 + 1]; + const baseCount = baseErr || baseVal == null ? 0 : (baseVal as number); + const ctrCount = ctrErr || ctrVal == null ? 0 : Number(ctrVal); + acc[queue] = baseCount + ctrCount; return acc; }, {} as Record @@ -946,6 +986,23 @@ export class RunQueue { [SemanticAttributes.CONCURRENCY_KEY]: message.concurrencyKey, }); + // CK queues route through the Tracked variant so the base-queue + // runningCounter stays in sync. Non-CK queues keep the original + // release path — no counter to maintain. + if (message.concurrencyKey) { + return this.redis.releaseConcurrencyTracked( + this.keys.queueCurrentConcurrencyKeyFromQueue(message.queue), + this.keys.envCurrentConcurrencyKeyFromQueue(message.queue), + this.keys.queueCurrentDequeuedKeyFromQueue(message.queue), + this.keys.envCurrentDequeuedKeyFromQueue(message.queue), + this.keys.queueRunningCounterKeyFromQueue(message.queue), + this.keys.ckIndexKeyFromQueue(message.queue), + messageId, + this.options.redis.keyPrefix ?? "", + String(this.counterTtlSeconds) + ); + } + return this.redis.releaseConcurrency( this.keys.queueCurrentConcurrencyKeyFromQueue(message.queue), this.keys.envCurrentConcurrencyKeyFromQueue(message.queue), @@ -1379,7 +1436,7 @@ export class RunQueue { const visibilityTimeoutMs = (ttlSystem.visibilityTimeoutMs ?? 30_000).toString(); // Atomically get and remove expired runs from TTL set, ack them from normal queues, and enqueue to TTL worker - const results = await this.redis.expireTtlRuns( + const results = await this.redis.expireTtlRunsTracked( ttlQueueKey, keyPrefix, now.toString(), @@ -1831,9 +1888,12 @@ export class RunQueue { if (message.concurrencyKey) { const ckIndexKey = this.keys.ckIndexKeyFromQueue(message.queue); const ckWildcardName = this.keys.toCkWildcard(message.queue); + const lengthCounterKey = this.keys.queueLengthCounterKeyFromQueue(message.queue); + const baseQueueKey = this.keys.baseQueueKeyFromQueue(message.queue); + const ckKeyPrefix = this.options.redis.keyPrefix ?? ""; if (ttlInfo) { - result = await this.redis.enqueueMessageWithTtlCk( + result = await this.redis.enqueueMessageWithTtlCkTracked( // keys masterQueueKey, queueKey, @@ -1849,6 +1909,8 @@ export class RunQueue { queueConcurrencyLimitKey, envConcurrencyLimitKey, envConcurrencyLimitBurstFactorKey, + lengthCounterKey, + baseQueueKey, // args queueName, messageId, @@ -1861,10 +1923,12 @@ export class RunQueue { defaultEnvConcurrencyLimit, defaultEnvConcurrencyBurstFactor, currentTime, - enableFastPathArg + enableFastPathArg, + ckKeyPrefix, + String(this.counterTtlSeconds) ); } else { - result = await this.redis.enqueueMessageCk( + result = await this.redis.enqueueMessageCkTracked( // keys masterQueueKey, queueKey, @@ -1879,6 +1943,8 @@ export class RunQueue { queueConcurrencyLimitKey, envConcurrencyLimitKey, envConcurrencyLimitBurstFactorKey, + lengthCounterKey, + baseQueueKey, // args queueName, messageId, @@ -1889,7 +1955,9 @@ export class RunQueue { defaultEnvConcurrencyLimit, defaultEnvConcurrencyBurstFactor, currentTime, - enableFastPathArg + enableFastPathArg, + ckKeyPrefix, + String(this.counterTtlSeconds) ); } } else if (ttlInfo) { @@ -2129,7 +2197,9 @@ export class RunQueue { maxCount, }); - const result = await this.redis.dequeueMessagesFromCkQueue( + const lengthCounterKey = this.keys.queueLengthCounterKeyFromQueue(ckWildcardQueue); + + const result = await this.redis.dequeueMessagesFromCkQueueTracked( //keys ckIndexKey, queueConcurrencyLimitKey, @@ -2140,6 +2210,7 @@ export class RunQueue { envQueueKey, masterQueueKey, ttlQueueKey, + lengthCounterKey, //args ckWildcardQueue, String(Date.now()), @@ -2374,8 +2445,10 @@ export class RunQueue { if (message.concurrencyKey) { const ckIndexKey = this.keys.ckIndexKeyFromQueue(message.queue); const ckWildcardName = this.keys.toCkWildcard(message.queue); + const lengthCounterKey = this.keys.queueLengthCounterKeyFromQueue(message.queue); + const runningCounterKey = this.keys.queueRunningCounterKeyFromQueue(message.queue); - return this.redis.acknowledgeMessageCk( + return this.redis.acknowledgeMessageCkTracked( masterQueueKey, messageKey, messageQueue, @@ -2386,6 +2459,8 @@ export class RunQueue { envQueueKey, workerQueueKey, ckIndexKey, + lengthCounterKey, + runningCounterKey, messageId, messageQueue, messageKeyValue, @@ -2441,6 +2516,20 @@ export class RunQueue { service: this.name, }); + if (queue.includes(":ck:")) { + return this.redis.clearMessageFromConcurrencySetsTracked( + queueCurrentConcurrencyKey, + envCurrentConcurrencyKey, + queueCurrentDequeuedKey, + envCurrentDequeuedKey, + this.keys.queueRunningCounterKeyFromQueue(queue), + this.keys.ckIndexKeyFromQueue(queue), + messageId, + this.options.redis.keyPrefix ?? "", + String(this.counterTtlSeconds) + ); + } + return this.redis.clearMessageFromConcurrencySets( queueCurrentConcurrencyKey, envCurrentConcurrencyKey, @@ -2485,8 +2574,10 @@ export class RunQueue { if (message.concurrencyKey) { const ckIndexKey = this.keys.ckIndexKeyFromQueue(message.queue); const ckWildcardName = this.keys.toCkWildcard(message.queue); + const lengthCounterKey = this.keys.queueLengthCounterKeyFromQueue(message.queue); + const runningCounterKey = this.keys.queueRunningCounterKeyFromQueue(message.queue); - await this.redis.nackMessageCk( + await this.redis.nackMessageCkTracked( //keys masterQueueKey, messageKey, @@ -2497,12 +2588,16 @@ export class RunQueue { envCurrentDequeuedKey, envQueueKey, ckIndexKey, + lengthCounterKey, + runningCounterKey, //args messageId, messageQueue, JSON.stringify(message), String(messageScore), - ckWildcardName + ckWildcardName, + this.options.redis.keyPrefix ?? "", + String(this.counterTtlSeconds) ); } else { await this.redis.nackMessage( @@ -2542,8 +2637,10 @@ export class RunQueue { if (message.concurrencyKey) { const ckIndexKey = this.keys.ckIndexKeyFromQueue(message.queue); const ckWildcardName = this.keys.toCkWildcard(message.queue); + const lengthCounterKey = this.keys.queueLengthCounterKeyFromQueue(message.queue); + const runningCounterKey = this.keys.queueRunningCounterKeyFromQueue(message.queue); - await this.redis.moveToDeadLetterQueueCk( + await this.redis.moveToDeadLetterQueueCkTracked( masterQueueKey, messageKey, messageQueue, @@ -2554,6 +2651,8 @@ export class RunQueue { envQueueKey, deadLetterQueueKey, ckIndexKey, + lengthCounterKey, + runningCounterKey, messageId, messageQueue, ckWildcardName @@ -2857,9 +2956,10 @@ export class RunQueue { messageKey, }); - const rawMessage = await this.redis.dequeueMessageFromKey( + const rawMessage = await this.redis.dequeueMessageFromKeyTracked( messageKey, - this.options.redis.keyPrefix ?? "" + this.options.redis.keyPrefix ?? "", + String(this.counterTtlSeconds) ); if (!rawMessage) { @@ -3302,6 +3402,246 @@ redis.call('SREM', envCurrentConcurrencyKey, messageId) redis.call('SREM', queueCurrentDequeuedKey, messageId) redis.call('SREM', envCurrentDequeuedKey, messageId) +return 0 + `, + }); + + // Tracked variants of the CK enqueue scripts. Identical to the originals except + // they maintain a per-base-queue `lengthCounter` so the trigger-time queue-length + // cap and the dashboard's "Queued" column can see the true aggregate across all + // CK variants. The counter is lazy-initialized inside the script the first time + // a CK enqueue touches a base queue by summing ZCARDs across the existing + // ckIndex variants + the base queue zset. The runningCounter is touched in the + // *Tracked variants of dequeueMessageFromKey and the ack/nack/dlq/release/clear + // scripts. + this.redis.defineCommand("enqueueMessageCkTracked", { + numberOfKeys: 15, + lua: ` +local masterQueueKey = KEYS[1] +local queueKey = KEYS[2] +local messageKey = KEYS[3] +local queueCurrentConcurrencyKey = KEYS[4] +local envCurrentConcurrencyKey = KEYS[5] +local queueCurrentDequeuedKey = KEYS[6] +local envCurrentDequeuedKey = KEYS[7] +local envQueueKey = KEYS[8] +local ckIndexKey = KEYS[9] +-- Fast-path keys (KEYS 10-13) +local workerQueueKey = KEYS[10] +local queueConcurrencyLimitKey = KEYS[11] +local envConcurrencyLimitKey = KEYS[12] +local envConcurrencyLimitBurstFactorKey = KEYS[13] +-- Counter keys (KEYS 14-15) +local lengthCounterKey = KEYS[14] +local baseQueueKey = KEYS[15] + +local queueName = ARGV[1] +local messageId = ARGV[2] +local messageData = ARGV[3] +local messageScore = ARGV[4] +local ckWildcardName = ARGV[5] +-- Fast-path args (ARGV 6-10) +local messageKeyValue = ARGV[6] +local defaultEnvConcurrencyLimit = ARGV[7] +local defaultEnvConcurrencyBurstFactor = ARGV[8] +local currentTime = ARGV[9] +local enableFastPath = ARGV[10] +-- keyPrefix for prepending to variant names stored as values in ckIndex +local keyPrefix = ARGV[11] +-- TTL (seconds) applied to counter lazy-init SETs +local counterTtl = ARGV[12] + +-- Fast path: check if we can skip the queue and go directly to worker queue +if enableFastPath == '1' then + local available = redis.call('ZRANGEBYSCORE', queueKey, '-inf', currentTime, 'LIMIT', 0, 1) + if #available == 0 then + local envCurrent = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0') + local envLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) + local envBurstFactor = tonumber(redis.call('GET', envConcurrencyLimitBurstFactorKey) or defaultEnvConcurrencyBurstFactor) + local envLimitWithBurst = math.floor(envLimit * envBurstFactor) + + if envCurrent < envLimitWithBurst then + local queueCurrent = tonumber(redis.call('SCARD', queueCurrentConcurrencyKey) or '0') + local queueLimit = math.min( + tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), + envLimit + ) + + if queueCurrent < queueLimit then + redis.call('SET', messageKey, messageData) + redis.call('SADD', queueCurrentConcurrencyKey, messageId) + redis.call('SADD', envCurrentConcurrencyKey, messageId) + redis.call('RPUSH', workerQueueKey, messageKeyValue) + -- Fast-path skips the CK variant zset entirely; lengthCounter is unchanged. + -- runningCounter is bumped later by dequeueMessageFromKeyTracked when the + -- worker pulls the message from the worker queue. + return 1 + end + end + end +end + +-- Slow path: normal enqueue +redis.call('SET', messageKey, messageData) + +-- Lazy-init lengthCounter from existing ckIndex variants (once per base queue per 24h). +-- The 24h TTL means the counter periodically re-anchors to truth, bounding any drift +-- that accumulated during rolling-deploy overlap windows. +-- Run BEFORE the ZADD so we capture pre-state; the subsequent INCR accounts for the new message. +-- The counter tracks ONLY CK-variant messages — the read path adds ZCARD(base) separately, +-- so the base zset is intentionally excluded here. +if redis.call('EXISTS', lengthCounterKey) == 0 then + local total = 0 + local variants = redis.call('ZRANGE', ckIndexKey, 0, -1) + for _, v in ipairs(variants) do + total = total + tonumber(redis.call('ZCARD', keyPrefix .. v) or '0') + end + redis.call('SET', lengthCounterKey, total, 'EX', counterTtl) +end + +-- INCR is gated on ZADD returning 1 (new entry). A duplicate enqueue (same messageId +-- already in the variant zset) returns 0 and must not bump the counter. +local added = redis.call('ZADD', queueKey, messageScore, messageId) +redis.call('ZADD', envQueueKey, messageScore, messageId) +if added == 1 then + redis.call('INCR', lengthCounterKey) +end + +-- Rebalance CK index +local earliest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') +if #earliest > 0 then + redis.call('ZADD', ckIndexKey, earliest[2], queueName) +end + +-- Rebalance master queue with ck:* member +local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') +if #earliestIdx > 0 then + redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) +end + +-- Remove old-format entry from master queue (transition cleanup) +redis.call('ZREM', masterQueueKey, queueName) + +-- Update the concurrency keys +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +redis.call('SREM', queueCurrentDequeuedKey, messageId) +redis.call('SREM', envCurrentDequeuedKey, messageId) + +return 0 + `, + }); + + this.redis.defineCommand("enqueueMessageWithTtlCkTracked", { + numberOfKeys: 16, + lua: ` +local masterQueueKey = KEYS[1] +local queueKey = KEYS[2] +local messageKey = KEYS[3] +local queueCurrentConcurrencyKey = KEYS[4] +local envCurrentConcurrencyKey = KEYS[5] +local queueCurrentDequeuedKey = KEYS[6] +local envCurrentDequeuedKey = KEYS[7] +local envQueueKey = KEYS[8] +local ttlQueueKey = KEYS[9] +local ckIndexKey = KEYS[10] +-- Fast-path keys (KEYS 11-14) +local workerQueueKey = KEYS[11] +local queueConcurrencyLimitKey = KEYS[12] +local envConcurrencyLimitKey = KEYS[13] +local envConcurrencyLimitBurstFactorKey = KEYS[14] +-- Counter keys (KEYS 15-16) +local lengthCounterKey = KEYS[15] +local baseQueueKey = KEYS[16] + +local queueName = ARGV[1] +local messageId = ARGV[2] +local messageData = ARGV[3] +local messageScore = ARGV[4] +local ttlMember = ARGV[5] +local ttlScore = ARGV[6] +local ckWildcardName = ARGV[7] +-- Fast-path args (ARGV 8-12) +local messageKeyValue = ARGV[8] +local defaultEnvConcurrencyLimit = ARGV[9] +local defaultEnvConcurrencyBurstFactor = ARGV[10] +local currentTime = ARGV[11] +local enableFastPath = ARGV[12] +-- keyPrefix for prepending to variant names stored as values in ckIndex +local keyPrefix = ARGV[13] +-- TTL (seconds) applied to counter lazy-init SETs +local counterTtl = ARGV[14] + +-- Fast path: check if we can skip the queue and go directly to worker queue +if enableFastPath == '1' then + local available = redis.call('ZRANGEBYSCORE', queueKey, '-inf', currentTime, 'LIMIT', 0, 1) + if #available == 0 then + local envCurrent = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0') + local envLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) + local envBurstFactor = tonumber(redis.call('GET', envConcurrencyLimitBurstFactorKey) or defaultEnvConcurrencyBurstFactor) + local envLimitWithBurst = math.floor(envLimit * envBurstFactor) + + if envCurrent < envLimitWithBurst then + local queueCurrent = tonumber(redis.call('SCARD', queueCurrentConcurrencyKey) or '0') + local queueLimit = math.min( + tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), + envLimit + ) + + if queueCurrent < queueLimit then + redis.call('SET', messageKey, messageData) + redis.call('SADD', queueCurrentConcurrencyKey, messageId) + redis.call('SADD', envCurrentConcurrencyKey, messageId) + redis.call('RPUSH', workerQueueKey, messageKeyValue) + return 1 + end + end + end +end + +-- Slow path: normal enqueue +redis.call('SET', messageKey, messageData) + +-- Lazy-init lengthCounter from existing ckIndex variants (once per base queue per 24h). +-- See enqueueMessageCkTracked for the TTL rationale. +if redis.call('EXISTS', lengthCounterKey) == 0 then + local total = 0 + local variants = redis.call('ZRANGE', ckIndexKey, 0, -1) + for _, v in ipairs(variants) do + total = total + tonumber(redis.call('ZCARD', keyPrefix .. v) or '0') + end + redis.call('SET', lengthCounterKey, total, 'EX', counterTtl) +end + +-- INCR is gated on ZADD returning 1 (new entry). +local added = redis.call('ZADD', queueKey, messageScore, messageId) +redis.call('ZADD', envQueueKey, messageScore, messageId) +redis.call('ZADD', ttlQueueKey, ttlScore, ttlMember) +if added == 1 then + redis.call('INCR', lengthCounterKey) +end + +-- Rebalance CK index +local earliest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') +if #earliest > 0 then + redis.call('ZADD', ckIndexKey, earliest[2], queueName) +end + +-- Rebalance master queue with ck:* member +local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') +if #earliestIdx > 0 then + redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) +end + +-- Remove old-format entry from master queue (transition cleanup) +redis.call('ZREM', masterQueueKey, queueName) + +-- Update the concurrency keys +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +redis.call('SREM', queueCurrentDequeuedKey, messageId) +redis.call('SREM', envCurrentDequeuedKey, messageId) + return 0 `, }); @@ -3384,7 +3724,7 @@ for i, member in ipairs(expiredMembers) do redis.call('SREM', envDequeuedKey, runId) -- Rebalance CK index if this is a CK queue - local ckMatch = string.match(rawQueueKey, "^(.+):ck:.+$") + local ckMatch = string.match(rawQueueKey, "(.-):ck:") if ckMatch then local ckIndexKey = keyPrefix .. ckMatch .. ":ckIndex" local earliest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') @@ -3415,41 +3755,153 @@ return results `, }); - this.redis.defineCommand("dequeueMessagesFromQueue", { - numberOfKeys: 10, + // Tracked variant: same as expireTtlRuns, with floored DECRs of the + // per-base-queue lengthCounter (for every successful ZREM from a CK variant) + // and runningCounter (when SREM from currentDequeued actually removed something). + this.redis.defineCommand("expireTtlRunsTracked", { + numberOfKeys: 1, lua: ` -local queueKey = KEYS[1] -local queueConcurrencyLimitKey = KEYS[2] -local envConcurrencyLimitKey = KEYS[3] -local envConcurrencyLimitBurstFactorKey = KEYS[4] -local queueCurrentConcurrencyKey = KEYS[5] -local envCurrentConcurrencyKey = KEYS[6] -local messageKeyPrefix = KEYS[7] -local envQueueKey = KEYS[8] -local masterQueueKey = KEYS[9] -local ttlQueueKey = KEYS[10] -- Optional: TTL sorted set key (empty string if not used) - -local queueName = ARGV[1] +local ttlQueueKey = KEYS[1] +local keyPrefix = ARGV[1] local currentTime = tonumber(ARGV[2]) -local defaultEnvConcurrencyLimit = ARGV[3] -local defaultEnvConcurrencyBurstFactor = ARGV[4] -local keyPrefix = ARGV[5] -local maxCount = tonumber(ARGV[6] or '1') - --- Check current env concurrency against the limit -local envCurrentConcurrency = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0') -local envConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) -local envConcurrencyLimitBurstFactor = tonumber(redis.call('GET', envConcurrencyLimitBurstFactorKey) or defaultEnvConcurrencyBurstFactor) -local envConcurrencyLimitWithBurstFactor = math.floor(envConcurrencyLimit * envConcurrencyLimitBurstFactor) +local batchSize = tonumber(ARGV[3]) +local shardCount = tonumber(ARGV[4]) +local workerQueueKey = ARGV[5] +local workerItemsKey = ARGV[6] +local visibilityTimeoutMs = tonumber(ARGV[7]) -if envCurrentConcurrency >= envConcurrencyLimitWithBurstFactor then - return nil +local function decrFloored(key) + if tonumber(redis.call('GET', key) or '0') > 0 then + redis.call('DECR', key) + end end --- Check current queue concurrency against the limit -local queueCurrentConcurrency = tonumber(redis.call('SCARD', queueCurrentConcurrencyKey) or '0') -local queueConcurrencyLimit = math.min(tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), envConcurrencyLimit) -local totalQueueConcurrencyLimit = queueConcurrencyLimit +local expiredMembers = redis.call('ZRANGEBYSCORE', ttlQueueKey, '-inf', currentTime, 'LIMIT', 0, batchSize) + +if #expiredMembers == 0 then + return {} +end + +local time = redis.call('TIME') +local nowMs = tonumber(time[1]) * 1000 + math.floor(tonumber(time[2]) / 1000) + +local results = {} + +for i, member in ipairs(expiredMembers) do + local pipePos1 = string.find(member, "|", 1, true) + if pipePos1 then + local pipePos2 = string.find(member, "|", pipePos1 + 1, true) + if pipePos2 then + local rawQueueKey = string.sub(member, 1, pipePos1 - 1) + local runId = string.sub(member, pipePos1 + 1, pipePos2 - 1) + local orgId = string.sub(member, pipePos2 + 1) + + local queueKey = keyPrefix .. rawQueueKey + + redis.call('ZREM', ttlQueueKey, member) + + local orgKeyStart = string.find(rawQueueKey, "{org:", 1, true) + local orgKeyEnd = string.find(rawQueueKey, "}", orgKeyStart, true) + local orgFromQueue = string.sub(rawQueueKey, orgKeyStart + 5, orgKeyEnd - 1) + + local messageKey = keyPrefix .. "{org:" .. orgFromQueue .. "}:message:" .. runId + + redis.call('DEL', messageKey) + + -- ZREM from queue; if successful AND this is a CK variant, DECR lengthCounter. + local removedFromZset = redis.call('ZREM', queueKey, runId) + + local envMatch = string.match(rawQueueKey, ":env:([^:]+)") + if envMatch then + local envQueueKey = keyPrefix .. "{org:" .. orgFromQueue .. "}:env:" .. envMatch + redis.call('ZREM', envQueueKey, runId) + end + + local concurrencyKey = queueKey .. ":currentConcurrency" + local dequeuedKey = queueKey .. ":currentDequeued" + redis.call('SREM', concurrencyKey, runId) + local removedFromDequeued = redis.call('SREM', dequeuedKey, runId) + + local projMatch = string.match(rawQueueKey, ":proj:([^:]+):env:") + local envConcurrencyKey = keyPrefix .. "{org:" .. orgFromQueue .. "}:proj:" .. (projMatch or "") .. ":env:" .. (envMatch or "") .. ":currentConcurrency" + local envDequeuedKey = keyPrefix .. "{org:" .. orgFromQueue .. "}:proj:" .. (projMatch or "") .. ":env:" .. (envMatch or "") .. ":currentDequeued" + redis.call('SREM', envConcurrencyKey, runId) + redis.call('SREM', envDequeuedKey, runId) + + -- Rebalance CK index AND update counters if this is a CK queue + local ckMatch = string.match(rawQueueKey, "(.-):ck:") + if ckMatch then + local lengthCounterKey = keyPrefix .. ckMatch .. ":lengthCounter" + local runningCounterKey = keyPrefix .. ckMatch .. ":runningCounter" + if removedFromZset == 1 then + decrFloored(lengthCounterKey) + end + if removedFromDequeued == 1 then + decrFloored(runningCounterKey) + end + + local ckIndexKey = keyPrefix .. ckMatch .. ":ckIndex" + local earliest = redis.call('ZRANGE', queueKey, 0, 0, 'WITHSCORES') + if #earliest == 0 then + redis.call('ZREM', ckIndexKey, rawQueueKey) + else + redis.call('ZADD', ckIndexKey, earliest[2], rawQueueKey) + end + end + + local serializedItem = cjson.encode({ + job = "expireTtlRun", + item = { runId = runId, orgId = orgId, queueKey = rawQueueKey }, + visibilityTimeoutMs = visibilityTimeoutMs, + attempt = 0 + }) + redis.call('ZADD', workerQueueKey, nowMs, runId) + redis.call('HSET', workerItemsKey, runId, serializedItem) + + table.insert(results, member) + end + end +end + +return results + `, + }); + + this.redis.defineCommand("dequeueMessagesFromQueue", { + numberOfKeys: 10, + lua: ` +local queueKey = KEYS[1] +local queueConcurrencyLimitKey = KEYS[2] +local envConcurrencyLimitKey = KEYS[3] +local envConcurrencyLimitBurstFactorKey = KEYS[4] +local queueCurrentConcurrencyKey = KEYS[5] +local envCurrentConcurrencyKey = KEYS[6] +local messageKeyPrefix = KEYS[7] +local envQueueKey = KEYS[8] +local masterQueueKey = KEYS[9] +local ttlQueueKey = KEYS[10] -- Optional: TTL sorted set key (empty string if not used) + +local queueName = ARGV[1] +local currentTime = tonumber(ARGV[2]) +local defaultEnvConcurrencyLimit = ARGV[3] +local defaultEnvConcurrencyBurstFactor = ARGV[4] +local keyPrefix = ARGV[5] +local maxCount = tonumber(ARGV[6] or '1') + +-- Check current env concurrency against the limit +local envCurrentConcurrency = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0') +local envConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) +local envConcurrencyLimitBurstFactor = tonumber(redis.call('GET', envConcurrencyLimitBurstFactorKey) or defaultEnvConcurrencyBurstFactor) +local envConcurrencyLimitWithBurstFactor = math.floor(envConcurrencyLimit * envConcurrencyLimitBurstFactor) + +if envCurrentConcurrency >= envConcurrencyLimitWithBurstFactor then + return nil +end + +-- Check current queue concurrency against the limit +local queueCurrentConcurrency = tonumber(redis.call('SCARD', queueCurrentConcurrencyKey) or '0') +local queueConcurrencyLimit = math.min(tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), envConcurrencyLimit) +local totalQueueConcurrencyLimit = queueConcurrencyLimit -- Check condition only if concurrencyLimit exists if queueCurrentConcurrency >= totalQueueConcurrencyLimit then @@ -3680,6 +4132,151 @@ else redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) end +return results + `, + }); + + // Tracked variant: same as dequeueMessagesFromCkQueue plus DECR of the + // per-base-queue lengthCounter for every message removed from a CK variant + // (normal dequeue, TTL-expired, or stale-orphan path — all of which were + // counted at enqueue time). + this.redis.defineCommand("dequeueMessagesFromCkQueueTracked", { + numberOfKeys: 10, + lua: ` +local ckIndexKey = KEYS[1] +local queueConcurrencyLimitKey = KEYS[2] +local envConcurrencyLimitKey = KEYS[3] +local envConcurrencyLimitBurstFactorKey = KEYS[4] +local envCurrentConcurrencyKey = KEYS[5] +local messageKeyPrefix = KEYS[6] +local envQueueKey = KEYS[7] +local masterQueueKey = KEYS[8] +local ttlQueueKey = KEYS[9] +local lengthCounterKey = KEYS[10] + +local ckWildcardName = ARGV[1] +local currentTime = tonumber(ARGV[2]) +local defaultEnvConcurrencyLimit = ARGV[3] +local defaultEnvConcurrencyBurstFactor = ARGV[4] +local keyPrefix = ARGV[5] +local maxCount = tonumber(ARGV[6] or '1') + +local function decrLengthCounter() + if tonumber(redis.call('GET', lengthCounterKey) or '0') > 0 then + redis.call('DECR', lengthCounterKey) + end +end + +-- Check env concurrency +local envCurrentConcurrency = tonumber(redis.call('SCARD', envCurrentConcurrencyKey) or '0') +local envConcurrencyLimit = tonumber(redis.call('GET', envConcurrencyLimitKey) or defaultEnvConcurrencyLimit) +local envConcurrencyLimitBurstFactor = tonumber(redis.call('GET', envConcurrencyLimitBurstFactorKey) or defaultEnvConcurrencyBurstFactor) +local envConcurrencyLimitWithBurstFactor = math.floor(envConcurrencyLimit * envConcurrencyLimitBurstFactor) + +if envCurrentConcurrency >= envConcurrencyLimitWithBurstFactor then + return nil +end + +local queueConcurrencyLimit = math.min(tonumber(redis.call('GET', queueConcurrencyLimitKey) or '1000000'), envConcurrencyLimit) + +local envAvailableCapacity = envConcurrencyLimitWithBurstFactor - envCurrentConcurrency +local actualMaxCount = math.min(maxCount, envAvailableCapacity) + +if actualMaxCount <= 0 then + return nil +end + +local ckQueues = redis.call('ZRANGEBYSCORE', ckIndexKey, '-inf', tostring(currentTime), 'LIMIT', 0, actualMaxCount * 3) + +if #ckQueues == 0 then + local anyIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') + if #anyIdx == 0 then + redis.call('ZREM', masterQueueKey, ckWildcardName) + else + redis.call('ZADD', masterQueueKey, anyIdx[2], ckWildcardName) + end + return nil +end + +local results = {} +local dequeuedCount = 0 + +for _, ckQueueName in ipairs(ckQueues) do + if dequeuedCount >= actualMaxCount then + break + end + + local fullQueueKey = keyPrefix .. ckQueueName + + local ckConcurrencyKey = fullQueueKey .. ':currentConcurrency' + local ckCurrentConcurrency = tonumber(redis.call('SCARD', ckConcurrencyKey) or '0') + + if ckCurrentConcurrency < queueConcurrencyLimit then + local messages = redis.call('ZRANGEBYSCORE', fullQueueKey, '-inf', tostring(currentTime), 'WITHSCORES', 'LIMIT', 0, 1) + + if #messages >= 2 then + local messageId = messages[1] + local messageScore = messages[2] + + local messageKey = messageKeyPrefix .. messageId + local messagePayload = redis.call('GET', messageKey) + + if messagePayload then + local messageData = cjson.decode(messagePayload) + local ttlExpiresAt = messageData and messageData.ttlExpiresAt + + if ttlExpiresAt and ttlExpiresAt <= currentTime then + redis.call('ZREM', fullQueueKey, messageId) + redis.call('ZREM', envQueueKey, messageId) + decrLengthCounter() + else + redis.call('ZREM', fullQueueKey, messageId) + redis.call('ZREM', envQueueKey, messageId) + decrLengthCounter() + redis.call('SADD', ckConcurrencyKey, messageId) + redis.call('SADD', envCurrentConcurrencyKey, messageId) + + if ttlQueueKey and ttlQueueKey ~= '' and ttlExpiresAt then + local ttlMember = ckQueueName .. '|' .. messageId .. '|' .. (messageData.orgId or '') + redis.call('ZREM', ttlQueueKey, ttlMember) + end + + table.insert(results, messageId) + table.insert(results, messageScore) + table.insert(results, messagePayload) + + dequeuedCount = dequeuedCount + 1 + end + else + redis.call('ZREM', fullQueueKey, messageId) + redis.call('ZREM', envQueueKey, messageId) + decrLengthCounter() + end + + local earliest = redis.call('ZRANGE', fullQueueKey, 0, 0, 'WITHSCORES') + if #earliest == 0 then + redis.call('ZREM', ckIndexKey, ckQueueName) + else + redis.call('ZADD', ckIndexKey, earliest[2], ckQueueName) + end + else + local any = redis.call('ZRANGE', fullQueueKey, 0, 0, 'WITHSCORES') + if #any == 0 then + redis.call('ZREM', ckIndexKey, ckQueueName) + else + redis.call('ZADD', ckIndexKey, any[2], ckQueueName) + end + end + end +end + +local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') +if #earliestIdx == 0 then + redis.call('ZREM', masterQueueKey, ckWildcardName) +else + redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) +end + return results `, }); @@ -3732,6 +4329,75 @@ redis.call('SADD', queueCurrentDequeuedKey, messageData.runId) redis.call('SADD', envCurrentDequeuedKey, messageData.runId) -- Return the message data +return message + `, + }); + + // Tracked variant: same as dequeueMessageFromKey, plus runningCounter INCR + // when the message lives on a CK variant queue. The runningCounter is + // lazy-initialized from the existing ckIndex variants' currentDequeued sets. + // Note: ckIndex only tracks variants that currently have queued messages, + // so the init misses any variant that's "running-only" (all messages + // dequeued, none queued). Those are reabsorbed naturally as messages ack + // (floored DECR) and new dequeues land. + this.redis.defineCommand("dequeueMessageFromKeyTracked", { + numberOfKeys: 1, + lua: ` +local messageKey = KEYS[1] +local keyPrefix = ARGV[1] +-- TTL (seconds) applied to counter lazy-init SETs +local counterTtl = ARGV[2] + +local message = redis.call('GET', messageKey) +if not message then + return nil +end + +local messageData = cjson.decode(message) +local queue = messageData.queue + +local queueCurrentDequeuedKey = keyPrefix .. queue .. ':currentDequeued' +local envCurrentDequeuedKey = keyPrefix .. string.match(queue, "(.+):queue:") .. ':currentDequeued' + +-- SADD first so we know if this dequeue is new (return 1) or a duplicate (return 0). +-- INCR runningCounter is gated on the new-membership result so re-dequeues don't inflate. +-- The alternative (lazy-init before SADD) was rejected because we need the SADD return +-- value to gate the INCR, and the lazy-init seed under SADD-first already reflects the +-- new membership. We compensate with the total-1 in the seed math below. +local addedDeq = redis.call('SADD', queueCurrentDequeuedKey, messageData.runId) +redis.call('SADD', envCurrentDequeuedKey, messageData.runId) + +-- If CK + new addition, bump the runningCounter for the base queue (lazy init from ckIndex). +-- The counter tracks ONLY CK-variant currentDequeued — the read path adds the base +-- SCARD separately, so we exclude the base currentDequeued here. 24h TTL on init +-- bounds any drift from rolling-deploy v1/v2 overlap. +local baseQueue = string.match(queue, "(.-):ck:") +if baseQueue and addedDeq == 1 then + local runningCounterKey = keyPrefix .. baseQueue .. ':runningCounter' + if redis.call('EXISTS', runningCounterKey) == 0 then + local ckIndexKey = keyPrefix .. baseQueue .. ':ckIndex' + local total = 0 + local ownVariantSeen = false + local variants = redis.call('ZRANGE', ckIndexKey, 0, -1) + for _, v in ipairs(variants) do + if v == queue then + ownVariantSeen = true + end + total = total + tonumber(redis.call('SCARD', keyPrefix .. v .. ':currentDequeued') or '0') + end + -- Fast-path messages skip the variant zset and ckIndex entirely (see + -- enqueueMessageCkTracked fast path). If our variant isn't in ckIndex, the loop + -- missed its SCARD; add it manually. Either way, the SCARD we sum already + -- reflects our just-SADD'd member, so subtract 1 before SETting (the INCR + -- below will add it back). + if not ownVariantSeen then + total = total + tonumber(redis.call('SCARD', queueCurrentDequeuedKey) or '0') + end + redis.call('SET', runningCounterKey, math.max(0, total - 1), 'EX', counterTtl) + end + redis.call('INCR', runningCounterKey) +end + return message `, }); @@ -3853,9 +4519,180 @@ local earliestMessage = redis.call('ZRANGE', messageQueue, 0, 0, 'WITHSCORES') if #earliestMessage == 0 then redis.call('ZREM', masterQueueKey, messageQueueName) else - redis.call('ZADD', masterQueueKey, earliestMessage[2], messageQueueName) + redis.call('ZADD', masterQueueKey, earliestMessage[2], messageQueueName) +end + +-- Add the message to the dead letter queue +redis.call('ZADD', deadLetterQueueKey, tonumber(redis.call('TIME')[1]), messageId) + +-- Update the concurrency keys +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +redis.call('SREM', queueCurrentDequeuedKey, messageId) +redis.call('SREM', envCurrentDequeuedKey, messageId) +`, + }); + + // CK-aware acknowledge: rebalances CK index and master queue with :ck:* member + this.redis.defineCommand("acknowledgeMessageCk", { + numberOfKeys: 10, + lua: ` +-- Keys: +local masterQueueKey = KEYS[1] +local messageKey = KEYS[2] +local messageQueueKey = KEYS[3] +local queueCurrentConcurrencyKey = KEYS[4] +local envCurrentConcurrencyKey = KEYS[5] +local queueCurrentDequeuedKey = KEYS[6] +local envCurrentDequeuedKey = KEYS[7] +local envQueueKey = KEYS[8] +local workerQueueKey = KEYS[9] +local ckIndexKey = KEYS[10] + +-- Args: +local messageId = ARGV[1] +local messageQueueName = ARGV[2] +local messageKeyValue = ARGV[3] +local removeFromWorkerQueue = ARGV[4] +local ckWildcardName = ARGV[5] + +-- Remove the message from the message key +redis.call('DEL', messageKey) + +-- Remove the message from the CK-specific queue +redis.call('ZREM', messageQueueKey, messageId) +redis.call('ZREM', envQueueKey, messageId) + +-- Rebalance CK index +local earliestInCkQueue = redis.call('ZRANGE', messageQueueKey, 0, 0, 'WITHSCORES') +if #earliestInCkQueue == 0 then + redis.call('ZREM', ckIndexKey, messageQueueName) +else + redis.call('ZADD', ckIndexKey, earliestInCkQueue[2], messageQueueName) +end + +-- Rebalance master queue with ck:* member +local earliestInCkIndex = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') +if #earliestInCkIndex == 0 then + redis.call('ZREM', masterQueueKey, ckWildcardName) +else + redis.call('ZADD', masterQueueKey, earliestInCkIndex[2], ckWildcardName) +end + +-- Remove old-format entry from master queue (transition cleanup) +redis.call('ZREM', masterQueueKey, messageQueueName) + +-- Update the concurrency keys +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +redis.call('SREM', queueCurrentDequeuedKey, messageId) +redis.call('SREM', envCurrentDequeuedKey, messageId) + +-- Remove the message from the worker queue +if removeFromWorkerQueue == '1' then + redis.call('LREM', workerQueueKey, 0, messageKeyValue) +end +`, + }); + + // CK-aware nack: rebalances CK index and master queue with :ck:* member + this.redis.defineCommand("nackMessageCk", { + numberOfKeys: 9, + lua: ` +-- Keys: +local masterQueueKey = KEYS[1] +local messageKey = KEYS[2] +local messageQueueKey = KEYS[3] +local queueCurrentConcurrencyKey = KEYS[4] +local envCurrentConcurrencyKey = KEYS[5] +local queueCurrentDequeuedKey = KEYS[6] +local envCurrentDequeuedKey = KEYS[7] +local envQueueKey = KEYS[8] +local ckIndexKey = KEYS[9] + +-- Args: +local messageId = ARGV[1] +local messageQueueName = ARGV[2] +local messageData = ARGV[3] +local messageScore = tonumber(ARGV[4]) +local ckWildcardName = ARGV[5] + +-- Update the message data +redis.call('SET', messageKey, messageData) + +-- Update the concurrency keys +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +redis.call('SREM', queueCurrentDequeuedKey, messageId) +redis.call('SREM', envCurrentDequeuedKey, messageId) + +-- Enqueue the message back into the CK-specific queue +redis.call('ZADD', messageQueueKey, messageScore, messageId) +redis.call('ZADD', envQueueKey, messageScore, messageId) + +-- Rebalance CK index +local earliest = redis.call('ZRANGE', messageQueueKey, 0, 0, 'WITHSCORES') +if #earliest > 0 then + redis.call('ZADD', ckIndexKey, earliest[2], messageQueueName) +end + +-- Rebalance master queue with ck:* member +local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') +if #earliestIdx == 0 then + redis.call('ZREM', masterQueueKey, ckWildcardName) +else + redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) +end + +-- Remove old-format entry from master queue (transition cleanup) +redis.call('ZREM', masterQueueKey, messageQueueName) +`, + }); + + // CK-aware move to dead letter queue + this.redis.defineCommand("moveToDeadLetterQueueCk", { + numberOfKeys: 10, + lua: ` +-- Keys: +local masterQueueKey = KEYS[1] +local messageKey = KEYS[2] +local messageQueue = KEYS[3] +local queueCurrentConcurrencyKey = KEYS[4] +local envCurrentConcurrencyKey = KEYS[5] +local queueCurrentDequeuedKey = KEYS[6] +local envCurrentDequeuedKey = KEYS[7] +local envQueueKey = KEYS[8] +local deadLetterQueueKey = KEYS[9] +local ckIndexKey = KEYS[10] + +-- Args: +local messageId = ARGV[1] +local messageQueueName = ARGV[2] +local ckWildcardName = ARGV[3] + +-- Remove the message from the CK-specific queue +redis.call('ZREM', messageQueue, messageId) +redis.call('ZREM', envQueueKey, messageId) + +-- Rebalance CK index +local earliest = redis.call('ZRANGE', messageQueue, 0, 0, 'WITHSCORES') +if #earliest == 0 then + redis.call('ZREM', ckIndexKey, messageQueueName) +else + redis.call('ZADD', ckIndexKey, earliest[2], messageQueueName) +end + +-- Rebalance master queue with ck:* member +local earliestIdx = redis.call('ZRANGE', ckIndexKey, 0, 0, 'WITHSCORES') +if #earliestIdx == 0 then + redis.call('ZREM', masterQueueKey, ckWildcardName) +else + redis.call('ZADD', masterQueueKey, earliestIdx[2], ckWildcardName) end +-- Remove old-format entry from master queue (transition cleanup) +redis.call('ZREM', masterQueueKey, messageQueueName) + -- Add the message to the dead letter queue redis.call('ZADD', deadLetterQueueKey, tonumber(redis.call('TIME')[1]), messageId) @@ -3867,9 +4704,12 @@ redis.call('SREM', envCurrentDequeuedKey, messageId) `, }); - // CK-aware acknowledge: rebalances CK index and master queue with :ck:* member - this.redis.defineCommand("acknowledgeMessageCk", { - numberOfKeys: 10, + // Tracked variant: same as acknowledgeMessageCk, plus floored DECRs of the + // per-base-queue lengthCounter (defensive — only fires when the ZREM actually + // removed something) and runningCounter (when SREM currentDequeued actually + // removed something). + this.redis.defineCommand("acknowledgeMessageCkTracked", { + numberOfKeys: 12, lua: ` -- Keys: local masterQueueKey = KEYS[1] @@ -3882,6 +4722,8 @@ local envCurrentDequeuedKey = KEYS[7] local envQueueKey = KEYS[8] local workerQueueKey = KEYS[9] local ckIndexKey = KEYS[10] +local lengthCounterKey = KEYS[11] +local runningCounterKey = KEYS[12] -- Args: local messageId = ARGV[1] @@ -3890,12 +4732,23 @@ local messageKeyValue = ARGV[3] local removeFromWorkerQueue = ARGV[4] local ckWildcardName = ARGV[5] +local function decrFloored(key) + if tonumber(redis.call('GET', key) or '0') > 0 then + redis.call('DECR', key) + end +end + -- Remove the message from the message key redis.call('DEL', messageKey) --- Remove the message from the CK-specific queue -redis.call('ZREM', messageQueueKey, messageId) +-- Remove the message from the CK-specific queue. The ZREM is defensive — by +-- ack time the message is normally in currentConcurrency, not the zset — but +-- if it does remove something, the counter was tracking that entry so decr. +local removedFromZset = redis.call('ZREM', messageQueueKey, messageId) redis.call('ZREM', envQueueKey, messageId) +if removedFromZset == 1 then + decrFloored(lengthCounterKey) +end -- Rebalance CK index local earliestInCkQueue = redis.call('ZRANGE', messageQueueKey, 0, 0, 'WITHSCORES') @@ -3916,11 +4769,15 @@ end -- Remove old-format entry from master queue (transition cleanup) redis.call('ZREM', masterQueueKey, messageQueueName) --- Update the concurrency keys +-- Update the concurrency keys. DECR runningCounter only when SREM +-- currentDequeued actually removed an entry (the message was in flight). redis.call('SREM', queueCurrentConcurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) -redis.call('SREM', queueCurrentDequeuedKey, messageId) +local removedFromDequeued = redis.call('SREM', queueCurrentDequeuedKey, messageId) redis.call('SREM', envCurrentDequeuedKey, messageId) +if removedFromDequeued == 1 then + decrFloored(runningCounterKey) +end -- Remove the message from the worker queue if removeFromWorkerQueue == '1' then @@ -3929,9 +4786,11 @@ end `, }); - // CK-aware nack: rebalances CK index and master queue with :ck:* member - this.redis.defineCommand("nackMessageCk", { - numberOfKeys: 9, + // Tracked variant: same as nackMessageCk. SREM currentDequeued may DECR + // runningCounter (floored); ZADD back to the variant zset INCRs + // lengthCounter only when ZADD reported a new entry. + this.redis.defineCommand("nackMessageCkTracked", { + numberOfKeys: 11, lua: ` -- Keys: local masterQueueKey = KEYS[1] @@ -3943,6 +4802,8 @@ local queueCurrentDequeuedKey = KEYS[6] local envCurrentDequeuedKey = KEYS[7] local envQueueKey = KEYS[8] local ckIndexKey = KEYS[9] +local lengthCounterKey = KEYS[10] +local runningCounterKey = KEYS[11] -- Args: local messageId = ARGV[1] @@ -3950,19 +4811,52 @@ local messageQueueName = ARGV[2] local messageData = ARGV[3] local messageScore = tonumber(ARGV[4]) local ckWildcardName = ARGV[5] +-- keyPrefix for prepending to variant names stored as values in ckIndex (lazy-init only) +local keyPrefix = ARGV[6] +-- TTL (seconds) applied to counter lazy-init SETs +local counterTtl = ARGV[7] + +local function decrFloored(key) + if tonumber(redis.call('GET', key) or '0') > 0 then + redis.call('DECR', key) + end +end -- Update the message data redis.call('SET', messageKey, messageData) --- Update the concurrency keys +-- Update the concurrency keys. nack only DECRs runningCounter, never INCRs it, +-- so we skip the eager lazy-init here (unlike releaseConcurrencyTracked, which +-- mirrors the same DECR pattern with init). A post-TTL nack's floored DECR +-- no-ops; the next dequeueMessageFromKeyTracked reseeds from current state. redis.call('SREM', queueCurrentConcurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) -redis.call('SREM', queueCurrentDequeuedKey, messageId) +local removedFromDequeued = redis.call('SREM', queueCurrentDequeuedKey, messageId) redis.call('SREM', envCurrentDequeuedKey, messageId) +if removedFromDequeued == 1 then + decrFloored(runningCounterKey) +end --- Enqueue the message back into the CK-specific queue -redis.call('ZADD', messageQueueKey, messageScore, messageId) +-- Lazy-init lengthCounter if missing (e.g. expired via 24h TTL). nack re-queues a +-- message, which means lengthCounter must be present before we INCR. Without this, +-- a nack after counter expiry would create the counter at 1 and stay drifted until +-- next reset. +if redis.call('EXISTS', lengthCounterKey) == 0 then + local total = 0 + local variants = redis.call('ZRANGE', ckIndexKey, 0, -1) + for _, v in ipairs(variants) do + total = total + tonumber(redis.call('ZCARD', keyPrefix .. v) or '0') + end + redis.call('SET', lengthCounterKey, total, 'EX', counterTtl) +end + +-- Enqueue the message back into the CK-specific queue. INCR lengthCounter only if +-- it's a new entry (ZADD returns 1). +local added = redis.call('ZADD', messageQueueKey, messageScore, messageId) redis.call('ZADD', envQueueKey, messageScore, messageId) +if added == 1 then + redis.call('INCR', lengthCounterKey) +end -- Rebalance CK index local earliest = redis.call('ZRANGE', messageQueueKey, 0, 0, 'WITHSCORES') @@ -3983,9 +4877,10 @@ redis.call('ZREM', masterQueueKey, messageQueueName) `, }); - // CK-aware move to dead letter queue - this.redis.defineCommand("moveToDeadLetterQueueCk", { - numberOfKeys: 10, + // Tracked variant: same as moveToDeadLetterQueueCk. ZREM may DECR + // lengthCounter (defensive); SREM currentDequeued may DECR runningCounter. + this.redis.defineCommand("moveToDeadLetterQueueCkTracked", { + numberOfKeys: 12, lua: ` -- Keys: local masterQueueKey = KEYS[1] @@ -3998,15 +4893,28 @@ local envCurrentDequeuedKey = KEYS[7] local envQueueKey = KEYS[8] local deadLetterQueueKey = KEYS[9] local ckIndexKey = KEYS[10] +local lengthCounterKey = KEYS[11] +local runningCounterKey = KEYS[12] -- Args: local messageId = ARGV[1] local messageQueueName = ARGV[2] local ckWildcardName = ARGV[3] --- Remove the message from the CK-specific queue -redis.call('ZREM', messageQueue, messageId) +local function decrFloored(key) + if tonumber(redis.call('GET', key) or '0') > 0 then + redis.call('DECR', key) + end +end + +-- Remove the message from the CK-specific queue. ZREM may be a no-op if the +-- message was already moved to currentConcurrency; only decr when it actually +-- removes something. +local removedFromZset = redis.call('ZREM', messageQueue, messageId) redis.call('ZREM', envQueueKey, messageId) +if removedFromZset == 1 then + decrFloored(lengthCounterKey) +end -- Rebalance CK index local earliest = redis.call('ZRANGE', messageQueue, 0, 0, 'WITHSCORES') @@ -4030,11 +4938,15 @@ redis.call('ZREM', masterQueueKey, messageQueueName) -- Add the message to the dead letter queue redis.call('ZADD', deadLetterQueueKey, tonumber(redis.call('TIME')[1]), messageId) --- Update the concurrency keys +-- Update the concurrency keys. DECR runningCounter only when SREM +-- currentDequeued actually removed an entry. redis.call('SREM', queueCurrentConcurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) -redis.call('SREM', queueCurrentDequeuedKey, messageId) +local removedFromDequeued = redis.call('SREM', queueCurrentDequeuedKey, messageId) redis.call('SREM', envCurrentDequeuedKey, messageId) +if removedFromDequeued == 1 then + decrFloored(runningCounterKey) +end `, }); @@ -4058,6 +4970,54 @@ redis.call('SREM', envCurrentDequeuedKey, messageId) `, }); + // Tracked variant: same as releaseConcurrency, with a floored DECR of the + // base-queue runningCounter when SREM currentDequeued actually removed + // something. Caller should only invoke this variant for CK queues — non-CK + // queues should keep calling releaseConcurrency. + this.redis.defineCommand("releaseConcurrencyTracked", { + numberOfKeys: 6, + lua: ` +-- Keys: +local queueCurrentConcurrencyKey = KEYS[1] +local envCurrentConcurrencyKey = KEYS[2] +local queueCurrentDequeuedKey = KEYS[3] +local envCurrentDequeuedKey = KEYS[4] +local runningCounterKey = KEYS[5] +local ckIndexKey = KEYS[6] + +-- Args: +local messageId = ARGV[1] +local keyPrefix = ARGV[2] +-- TTL (seconds) applied to counter lazy-init SETs +local counterTtl = ARGV[3] + +-- Lazy-init runningCounter if missing (e.g. expired via 24h TTL). Runs BEFORE +-- the SREM so the seed captures pre-release state; the subsequent DECR accounts +-- for the message we're about to release. Without this, a release landing after +-- counter expiry would silently no-op the DECR and the next dequeue would seed +-- to post-release truth — bounded drift but inconsistent with nack/enqueue. +if redis.call('EXISTS', runningCounterKey) == 0 then + local total = 0 + local variants = redis.call('ZRANGE', ckIndexKey, 0, -1) + for _, v in ipairs(variants) do + total = total + tonumber(redis.call('SCARD', keyPrefix .. v .. ':currentDequeued') or '0') + end + redis.call('SET', runningCounterKey, total, 'EX', counterTtl) +end + +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +local removedFromDequeued = redis.call('SREM', queueCurrentDequeuedKey, messageId) +redis.call('SREM', envCurrentDequeuedKey, messageId) + +if removedFromDequeued == 1 then + if tonumber(redis.call('GET', runningCounterKey) or '0') > 0 then + redis.call('DECR', runningCounterKey) + end +end +`, + }); + this.redis.defineCommand("updateEnvironmentConcurrencyLimits", { numberOfKeys: 2, lua: ` @@ -4154,6 +5114,48 @@ redis.call('SREM', queueCurrentConcurrencyKey, messageId) redis.call('SREM', envCurrentConcurrencyKey, messageId) redis.call('SREM', queueCurrentDequeuedKey, messageId) redis.call('SREM', envCurrentDequeuedKey, messageId) +`, + }); + + // Tracked variant of clearMessageFromConcurrencySets — see releaseConcurrencyTracked + // for the contract. Only invoke for CK queues. + this.redis.defineCommand("clearMessageFromConcurrencySetsTracked", { + numberOfKeys: 6, + lua: ` +-- Keys: +local queueCurrentConcurrencyKey = KEYS[1] +local envCurrentConcurrencyKey = KEYS[2] +local queueCurrentDequeuedKey = KEYS[3] +local envCurrentDequeuedKey = KEYS[4] +local runningCounterKey = KEYS[5] +local ckIndexKey = KEYS[6] + +-- Args: +local messageId = ARGV[1] +local keyPrefix = ARGV[2] +-- TTL (seconds) applied to counter lazy-init SETs +local counterTtl = ARGV[3] + +-- Lazy-init runningCounter if missing — see releaseConcurrencyTracked for rationale. +if redis.call('EXISTS', runningCounterKey) == 0 then + local total = 0 + local variants = redis.call('ZRANGE', ckIndexKey, 0, -1) + for _, v in ipairs(variants) do + total = total + tonumber(redis.call('SCARD', keyPrefix .. v .. ':currentDequeued') or '0') + end + redis.call('SET', runningCounterKey, total, 'EX', counterTtl) +end + +redis.call('SREM', queueCurrentConcurrencyKey, messageId) +redis.call('SREM', envCurrentConcurrencyKey, messageId) +local removedFromDequeued = redis.call('SREM', queueCurrentDequeuedKey, messageId) +redis.call('SREM', envCurrentDequeuedKey, messageId) + +if removedFromDequeued == 1 then + if tonumber(redis.call('GET', runningCounterKey) or '0') > 0 then + redis.call('DECR', runningCounterKey) + end +end `, }); } @@ -4514,6 +5516,200 @@ declare module "@internal/redis" { ckWildcardName: string, callback?: Callback ): Result; + + // Tracked variants: maintain per-base-queue lengthCounter / runningCounter + // for CK queues. See defineCommand bodies for details. + enqueueMessageCkTracked( + masterQueueKey: string, + queue: string, + messageKey: string, + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, + queueCurrentDequeuedKey: string, + envCurrentDequeuedKey: string, + envQueueKey: string, + ckIndexKey: string, + workerQueueKey: string, + queueConcurrencyLimitKey: string, + envConcurrencyLimitKey: string, + envConcurrencyLimitBurstFactorKey: string, + lengthCounterKey: string, + baseQueueKey: string, + queueName: string, + messageId: string, + messageData: string, + messageScore: string, + ckWildcardName: string, + messageKeyValue: string, + defaultEnvConcurrencyLimit: string, + defaultEnvConcurrencyBurstFactor: string, + currentTime: string, + enableFastPath: string, + keyPrefix: string, + counterTtl: string, + callback?: Callback + ): Result; + + enqueueMessageWithTtlCkTracked( + masterQueueKey: string, + queue: string, + messageKey: string, + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, + queueCurrentDequeuedKey: string, + envCurrentDequeuedKey: string, + envQueueKey: string, + ttlQueueKey: string, + ckIndexKey: string, + workerQueueKey: string, + queueConcurrencyLimitKey: string, + envConcurrencyLimitKey: string, + envConcurrencyLimitBurstFactorKey: string, + lengthCounterKey: string, + baseQueueKey: string, + queueName: string, + messageId: string, + messageData: string, + messageScore: string, + ttlMember: string, + ttlScore: string, + ckWildcardName: string, + messageKeyValue: string, + defaultEnvConcurrencyLimit: string, + defaultEnvConcurrencyBurstFactor: string, + currentTime: string, + enableFastPath: string, + keyPrefix: string, + counterTtl: string, + callback?: Callback + ): Result; + + dequeueMessagesFromCkQueueTracked( + ckIndexKey: string, + queueConcurrencyLimitKey: string, + envConcurrencyLimitKey: string, + envConcurrencyLimitBurstFactorKey: string, + envCurrentConcurrencyKey: string, + messageKeyPrefix: string, + envQueueKey: string, + masterQueueKey: string, + ttlQueueKey: string, + lengthCounterKey: string, + ckWildcardName: string, + currentTime: string, + defaultEnvConcurrencyLimit: string, + defaultEnvConcurrencyBurstFactor: string, + keyPrefix: string, + maxCount: string, + callback?: Callback + ): Result; + + dequeueMessageFromKeyTracked( + messageKey: string, + keyPrefix: string, + counterTtl: string, + callback?: Callback + ): Result; + + acknowledgeMessageCkTracked( + masterQueueKey: string, + messageKey: string, + messageQueue: string, + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, + queueCurrentDequeuedKey: string, + envCurrentDequeuedKey: string, + envQueueKey: string, + workerQueueKey: string, + ckIndexKey: string, + lengthCounterKey: string, + runningCounterKey: string, + messageId: string, + messageQueueName: string, + messageKeyValue: string, + removeFromWorkerQueue: string, + ckWildcardName: string, + callback?: Callback + ): Result; + + nackMessageCkTracked( + masterQueueKey: string, + messageKey: string, + messageQueue: string, + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, + queueCurrentDequeuedKey: string, + envCurrentDequeuedKey: string, + envQueueKey: string, + ckIndexKey: string, + lengthCounterKey: string, + runningCounterKey: string, + messageId: string, + messageQueueName: string, + messageData: string, + messageScore: string, + ckWildcardName: string, + keyPrefix: string, + counterTtl: string, + callback?: Callback + ): Result; + + moveToDeadLetterQueueCkTracked( + masterQueueKey: string, + messageKey: string, + messageQueue: string, + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, + queueCurrentDequeuedKey: string, + envCurrentDequeuedKey: string, + envQueueKey: string, + deadLetterQueueKey: string, + ckIndexKey: string, + lengthCounterKey: string, + runningCounterKey: string, + messageId: string, + messageQueueName: string, + ckWildcardName: string, + callback?: Callback + ): Result; + + expireTtlRunsTracked( + ttlQueueKey: string, + keyPrefix: string, + currentTime: string, + batchSize: string, + shardCount: string, + workerQueueKey: string, + workerItemsKey: string, + visibilityTimeoutMs: string, + callback?: Callback + ): Result; + + releaseConcurrencyTracked( + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, + queueCurrentDequeuedKey: string, + envCurrentDequeuedKey: string, + runningCounterKey: string, + ckIndexKey: string, + messageId: string, + keyPrefix: string, + counterTtl: string, + callback?: Callback + ): Result; + + clearMessageFromConcurrencySetsTracked( + queueCurrentConcurrencyKey: string, + envCurrentConcurrencyKey: string, + queueCurrentDequeuedKey: string, + envCurrentDequeuedKey: string, + runningCounterKey: string, + ckIndexKey: string, + messageId: string, + keyPrefix: string, + counterTtl: string, + callback?: Callback + ): Result; } } diff --git a/internal-packages/run-engine/src/run-queue/keyProducer.ts b/internal-packages/run-engine/src/run-queue/keyProducer.ts index a85dfc393e9..dc6c9e7b77a 100644 --- a/internal-packages/run-engine/src/run-queue/keyProducer.ts +++ b/internal-packages/run-engine/src/run-queue/keyProducer.ts @@ -18,6 +18,8 @@ const constants = { MASTER_QUEUE_PART: "masterQueue", WORKER_QUEUE_PART: "workerQueue", CK_INDEX_PART: "ckIndex", + LENGTH_COUNTER_PART: "lengthCounter", + RUNNING_COUNTER_PART: "runningCounter", } as const; export class RunQueueFullKeyProducer implements RunQueueKeyProducer { @@ -315,6 +317,22 @@ export class RunQueueFullKeyProducer implements RunQueueKeyProducer { return queue.replace(/:ck:.+$/, ""); } + queueLengthCounterKey(env: RunQueueKeyProducerEnvironment, queue: string): string { + return `${this.queueKey(env, queue)}:${constants.LENGTH_COUNTER_PART}`; + } + + queueLengthCounterKeyFromQueue(queue: string): string { + return `${this.baseQueueKeyFromQueue(queue)}:${constants.LENGTH_COUNTER_PART}`; + } + + queueRunningCounterKey(env: RunQueueKeyProducerEnvironment, queue: string): string { + return `${this.queueKey(env, queue)}:${constants.RUNNING_COUNTER_PART}`; + } + + queueRunningCounterKeyFromQueue(queue: string): string { + return `${this.baseQueueKeyFromQueue(queue)}:${constants.RUNNING_COUNTER_PART}`; + } + isCkWildcard(queue: string): boolean { return queue.endsWith(":ck:*"); } diff --git a/internal-packages/run-engine/src/run-queue/tests/ckCounters.test.ts b/internal-packages/run-engine/src/run-queue/tests/ckCounters.test.ts new file mode 100644 index 00000000000..23213bfe15c --- /dev/null +++ b/internal-packages/run-engine/src/run-queue/tests/ckCounters.test.ts @@ -0,0 +1,643 @@ +import { redisTest } from "@internal/testcontainers"; +import { trace } from "@internal/tracing"; +import { Logger } from "@trigger.dev/core/logger"; +import { describe } from "vitest"; +import { FairQueueSelectionStrategy } from "../fairQueueSelectionStrategy.js"; +import { RunQueue } from "../index.js"; +import { RunQueueFullKeyProducer } from "../keyProducer.js"; +import { InputPayload } from "../types.js"; +import { Decimal } from "@trigger.dev/database"; + +// String form of the default counterTtlSeconds (86400). Tracked Lua scripts +// take TTL as an ARG; tests that invoke the scripts directly pass this. +const DEFAULT_COUNTER_TTL = "86400"; + +const testOptions = { + name: "rq", + tracer: trace.getTracer("rq"), + workers: 1, + defaultEnvConcurrency: 25, + logger: new Logger("RunQueue", "warn"), + retryOptions: { + maxAttempts: 5, + factor: 1.1, + minTimeoutInMs: 100, + maxTimeoutInMs: 1_000, + randomize: true, + }, + keys: new RunQueueFullKeyProducer(), +}; + +const authenticatedEnvDev = { + id: "e1234", + type: "DEVELOPMENT" as const, + maximumConcurrencyLimit: 10, + concurrencyLimitBurstFactor: new Decimal(2.0), + project: { id: "p1234" }, + organization: { id: "o1234" }, +}; + +function createQueue(redisContainer: any) { + return new RunQueue({ + ...testOptions, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); +} + +function makeMessage(overrides: Partial = {}): InputPayload { + return { + runId: "r1", + taskIdentifier: "task/my-task", + orgId: "o1234", + projectId: "p1234", + environmentId: "e1234", + environmentType: "DEVELOPMENT", + queue: "task/my-task", + timestamp: Date.now(), + attempt: 0, + ...overrides, + }; +} + +vi.setConfig({ testTimeout: 60_000 }); + +describe("CK base-queue counters", () => { + redisTest( + "lengthOfQueue returns aggregate across CK variants", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const now = Date.now(); + const messages = [ + makeMessage({ runId: "r1", concurrencyKey: "ck-a", timestamp: now }), + makeMessage({ runId: "r2", concurrencyKey: "ck-a", timestamp: now + 1 }), + makeMessage({ runId: "r3", concurrencyKey: "ck-b", timestamp: now + 2 }), + makeMessage({ runId: "r4", concurrencyKey: "ck-c", timestamp: now + 3 }), + ]; + + for (const msg of messages) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: msg, + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + // Aggregate (no CK arg) should sum all variants + expect(await queue.lengthOfQueue(authenticatedEnvDev, messages[0].queue)).toBe(4); + + // Per-variant still works + expect( + await queue.lengthOfQueue(authenticatedEnvDev, messages[0].queue, "ck-a") + ).toBe(2); + expect( + await queue.lengthOfQueue(authenticatedEnvDev, messages[0].queue, "ck-b") + ).toBe(1); + + // Plural lengthOfQueues should also see the aggregate + const lengths = await queue.lengthOfQueues(authenticatedEnvDev, [messages[0].queue]); + expect(lengths[messages[0].queue]).toBe(4); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "lazy init from pre-existing CK backlog", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const now = Date.now(); + const baseMsg = makeMessage({ runId: "seed", concurrencyKey: "ck-a", timestamp: now }); + + // Pre-populate two variants via direct ZADD to simulate pre-deploy backlog + // (no counter touched). ioredis auto-prefixes keys with `runqueue:test:`, + // so we pass un-prefixed keys. + const variantA = testOptions.keys.queueKey(authenticatedEnvDev, baseMsg.queue, "ck-a"); + const variantB = testOptions.keys.queueKey(authenticatedEnvDev, baseMsg.queue, "ck-b"); + const ckIndexKey = testOptions.keys.ckIndexKeyFromQueue(variantA); + for (let i = 0; i < 10; i++) { + await queue.redis.zadd(variantA, now + i, `old-a-${i}`); + } + for (let i = 0; i < 5; i++) { + await queue.redis.zadd(variantB, now + i, `old-b-${i}`); + } + await queue.redis.zadd(ckIndexKey, now, variantA); + await queue.redis.zadd(ckIndexKey, now, variantB); + + // Counter should not yet exist + const counterKey = testOptions.keys.queueLengthCounterKeyFromQueue(variantA); + expect(await queue.redis.exists(counterKey)).toBe(0); + + // First CK enqueue: lazy init should compute 15 (pre-state), then INCR to 16 + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "new-a", concurrencyKey: "ck-a", timestamp: now + 100 }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + + const counterVal = await queue.redis.get(counterKey); + expect(Number(counterVal)).toBe(16); + + // lengthOfQueue should also reflect 16 + expect(await queue.lengthOfQueue(authenticatedEnvDev, baseMsg.queue)).toBe(16); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "non-CK queue regression: counter never created", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + for (let i = 0; i < 5; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `r${i}`, timestamp: Date.now() + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + // Counter key should not exist for a pure non-CK queue + const counterKey = testOptions.keys.queueLengthCounterKey(authenticatedEnvDev, "task/my-task"); + expect(await queue.redis.exists(counterKey)).toBe(0); + + // But lengthOfQueue still returns 5 via base ZCARD + expect(await queue.lengthOfQueue(authenticatedEnvDev, "task/my-task")).toBe(5); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "mixed CK + non-CK on same base queue", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + // 3 non-CK + 2 CK on same base queue name + for (let i = 0; i < 3; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `nonck-${i}`, timestamp: Date.now() + i }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + for (let i = 0; i < 2; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ + runId: `ck-${i}`, + concurrencyKey: "ck-a", + timestamp: Date.now() + 100 + i, + }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + expect(await queue.lengthOfQueue(authenticatedEnvDev, "task/my-task")).toBe(5); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "length counter decrements on dequeue", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const now = Date.now() - 1000; + const msgs = [ + makeMessage({ runId: "r1", concurrencyKey: "ck-a", timestamp: now }), + makeMessage({ runId: "r2", concurrencyKey: "ck-b", timestamp: now + 1 }), + ]; + for (const msg of msgs) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: msg, + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + expect(await queue.lengthOfQueue(authenticatedEnvDev, msgs[0].queue)).toBe(2); + + const shard = testOptions.keys.masterQueueShardForEnvironment(msgs[0].environmentId, 2); + await queue.testDequeueFromMasterQueue(shard, msgs[0].environmentId, 10); + + // Both dequeued → counter should be 0 + expect(await queue.lengthOfQueue(authenticatedEnvDev, msgs[0].queue)).toBe(0); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "running counter bumps when dequeueMessageFromKey is called for a CK message", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + // Seed a message at its expected key and invoke the Tracked variant directly. + // This mirrors what dequeueMessageFromWorkerQueue would do once a worker + // pulls a message off the worker queue. + const msg = makeMessage({ runId: "r1", concurrencyKey: "ck-a" }); + const queueKey = testOptions.keys.queueKey(authenticatedEnvDev, msg.queue, "ck-a"); + const messageKey = testOptions.keys.messageKey(msg.orgId, msg.runId); + const runningCounterKey = + testOptions.keys.queueRunningCounterKeyFromQueue(queueKey); + + await queue.redis.set( + messageKey, + JSON.stringify({ ...msg, queue: queueKey, version: "2", workerQueue: "wq" }) + ); + + for (let i = 0; i < 3; i++) { + await queue.redis.set( + testOptions.keys.messageKey(msg.orgId, `r${i}`), + JSON.stringify({ + ...msg, + runId: `r${i}`, + queue: queueKey, + version: "2", + workerQueue: "wq", + }) + ); + await queue.redis.dequeueMessageFromKeyTracked( + testOptions.keys.messageKey(msg.orgId, `r${i}`), + "runqueue:test:", + DEFAULT_COUNTER_TTL + ); + } + + expect(Number(await queue.redis.get(runningCounterKey))).toBe(3); + + const running = await queue.currentConcurrencyOfQueues(authenticatedEnvDev, [ + msg.queue, + ]); + expect(running[msg.queue]).toBe(3); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "floor-at-zero protects against spurious decrements", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const variantA = testOptions.keys.queueKey( + authenticatedEnvDev, + "task/my-task", + "ck-a" + ); + const runningCounterKey = testOptions.keys.queueRunningCounterKeyFromQueue(variantA); + await queue.redis.set(runningCounterKey, "0"); + + // Call the Tracked release directly with un-prefixed keys (ioredis prepends the prefix) + await queue.redis.releaseConcurrencyTracked( + testOptions.keys.queueCurrentConcurrencyKeyFromQueue(variantA), + testOptions.keys.envCurrentConcurrencyKey(authenticatedEnvDev), + testOptions.keys.queueCurrentDequeuedKeyFromQueue(variantA), + testOptions.keys.envCurrentDequeuedKey(authenticatedEnvDev), + runningCounterKey, + testOptions.keys.ckIndexKeyFromQueue(variantA), + "phantom-message", + "runqueue:test:", + DEFAULT_COUNTER_TTL + ); + + expect(Number(await queue.redis.get(runningCounterKey))).toBe(0); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "lengthCounter has 24h TTL after lazy-init", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r1", concurrencyKey: "ck-a" }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + + const counterKey = testOptions.keys.queueLengthCounterKey( + authenticatedEnvDev, + "task/my-task" + ); + const ttl = await queue.redis.ttl(counterKey); + // Expect roughly 86400; allow only a few seconds of slack for test scheduling. + expect(ttl).toBeGreaterThanOrEqual(86390); + expect(ttl).toBeLessThanOrEqual(86400); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "duplicate CK enqueue (same runId) does not inflate lengthCounter", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const msg = makeMessage({ runId: "r1", concurrencyKey: "ck-a" }); + + // First enqueue: counter goes 0 -> 1 + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: msg, + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + + // Same runId again: ZADD returns 0 (already in zset), counter must stay at 1 + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: msg, + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + + expect(await queue.lengthOfQueue(authenticatedEnvDev, msg.queue)).toBe(1); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "duplicate nack (runId already in variant zset) does not inflate lengthCounter", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const msg = makeMessage({ runId: "r1", concurrencyKey: "ck-a" }); + + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: msg, + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + expect(await queue.lengthOfQueue(authenticatedEnvDev, msg.queue)).toBe(1); + + // Nack without dequeuing first — the message is still in the variant zset. + // ZADD returns 0 (already present), so lengthCounter must not bump. + await queue.nackMessage({ + orgId: msg.orgId, + messageId: msg.runId, + skipDequeueProcessing: true, + incrementAttemptCount: false, + }); + expect(await queue.lengthOfQueue(authenticatedEnvDev, msg.queue)).toBe(1); + + // A second nack on the same runId must still be a no-op for the counter. + await queue.nackMessage({ + orgId: msg.orgId, + messageId: msg.runId, + skipDequeueProcessing: true, + incrementAttemptCount: false, + }); + expect(await queue.lengthOfQueue(authenticatedEnvDev, msg.queue)).toBe(1); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "duplicate dequeueMessageFromKey (same runId) does not inflate runningCounter", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const msg = makeMessage({ runId: "r1", concurrencyKey: "ck-a" }); + const queueKey = testOptions.keys.queueKey(authenticatedEnvDev, msg.queue, "ck-a"); + const messageKey = testOptions.keys.messageKey(msg.orgId, msg.runId); + const runningCounterKey = + testOptions.keys.queueRunningCounterKeyFromQueue(queueKey); + + await queue.redis.set( + messageKey, + JSON.stringify({ ...msg, queue: queueKey, version: "2", workerQueue: "wq" }) + ); + + // First call: SADD returns 1, runningCounter goes 0 -> 1. + await queue.redis.dequeueMessageFromKeyTracked(messageKey, "runqueue:test:", DEFAULT_COUNTER_TTL); + expect(Number(await queue.redis.get(runningCounterKey))).toBe(1); + + // Second call on the same messageKey: SADD returns 0, runningCounter must stay at 1. + await queue.redis.dequeueMessageFromKeyTracked(messageKey, "runqueue:test:", DEFAULT_COUNTER_TTL); + expect(Number(await queue.redis.get(runningCounterKey))).toBe(1); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "nack lazy-inits lengthCounter when it expired", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const msg = makeMessage({ runId: "r1", concurrencyKey: "ck-a" }); + // Seed three messages on the CK variant so the lazy-init has a non-trivial floor. + for (let i = 0; i < 3; i++) { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: `seed-${i}`, concurrencyKey: "ck-a" }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + } + + // Simulate counter expiry (the 24h TTL kicked in). + const counterKey = testOptions.keys.queueLengthCounterKey( + authenticatedEnvDev, + "task/my-task" + ); + await queue.redis.del(counterKey); + expect(await queue.redis.exists(counterKey)).toBe(0); + + // Dequeue one to currentConcurrency so we have something to nack back. + const shard = testOptions.keys.masterQueueShardForEnvironment(msg.environmentId, 2); + await queue.testDequeueFromMasterQueue(shard, msg.environmentId, 1); + + // Nack a CK message. nackMessageCkTracked should lazy-init the counter + // (find 2 already in zset + 1 we're re-queuing) rather than starting from 1. + await queue.nackMessage({ + orgId: msg.orgId, + messageId: "seed-0", + skipDequeueProcessing: true, + }); + + // 3 originals, 1 was dequeued (still re-queued by nack), counter should now reflect all 3. + const observed = await queue.lengthOfQueue(authenticatedEnvDev, msg.queue); + expect(observed).toBe(3); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "fast-path-variant dequeue seeds runningCounter without missing its own SCARD", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + // Simulate: a CK variant has 3 running messages from prior fast-path enqueues + // (so the variant zset is empty and ckIndex does NOT include this variant). + // Another variant of the same base queue IS in ckIndex with 1 running message. + // Counter is missing (post-TTL-expiry). A new dequeue lands on the fast-path + // variant. The lazy-init must add the fast-path variant's SCARD even though + // it's not in ckIndex. + const msg = makeMessage({ runId: "fp-new", concurrencyKey: "ck-fastpath" }); + const fastVariant = testOptions.keys.queueKey( + authenticatedEnvDev, + msg.queue, + "ck-fastpath" + ); + const otherVariant = testOptions.keys.queueKey( + authenticatedEnvDev, + msg.queue, + "ck-other" + ); + const ckIndexKey = testOptions.keys.ckIndexKeyFromQueue(fastVariant); + const runningCounterKey = + testOptions.keys.queueRunningCounterKeyFromQueue(fastVariant); + + // Seed 3 prior fast-path runs into the fast variant's currentDequeued (no zset, no ckIndex). + for (let i = 0; i < 3; i++) { + await queue.redis.sadd(`${fastVariant}:currentDequeued`, `prior-${i}`); + } + + // Seed the other variant: 1 zset entry (so it's in ckIndex) + 1 currentDequeued. + await queue.redis.zadd(otherVariant, Date.now(), "other-q-1"); + await queue.redis.zadd(ckIndexKey, Date.now(), otherVariant); + await queue.redis.sadd(`${otherVariant}:currentDequeued`, "other-r-1"); + + // Counter is intentionally missing — simulates post-TTL state. + expect(await queue.redis.exists(runningCounterKey)).toBe(0); + + // New fast-path dequeue lands on the fast variant. + const messageKey = testOptions.keys.messageKey(msg.orgId, msg.runId); + await queue.redis.set( + messageKey, + JSON.stringify({ ...msg, queue: fastVariant, version: "2", workerQueue: "wq" }) + ); + await queue.redis.dequeueMessageFromKeyTracked(messageKey, "runqueue:test:", DEFAULT_COUNTER_TTL); + + // True running across all variants: 3 (fast prior) + 1 (fast new) + 1 (other) = 5. + // Without the ownVariantSeen fix, the seed would miss the fast variant entirely + // and counter would end at 1 (other variant SCARD = 1, INCR for new dequeue) — off by 4. + const counterVal = Number(await queue.redis.get(runningCounterKey)); + expect(counterVal).toBe(5); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "release lazy-inits runningCounter when missing", + async ({ redisContainer }) => { + const queue = createQueue(redisContainer); + try { + const msg = makeMessage({ runId: "r1", concurrencyKey: "ck-a" }); + const variant = testOptions.keys.queueKey(authenticatedEnvDev, msg.queue, "ck-a"); + const runningCounterKey = testOptions.keys.queueRunningCounterKeyFromQueue(variant); + + // Enqueue so ckIndex picks up the variant. + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: msg, + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + // Seed two running messages directly into currentDequeued so release has something to remove. + await queue.redis.sadd(`${variant}:currentDequeued`, msg.runId); + await queue.redis.sadd(`${variant}:currentDequeued`, "r2"); + + // Counter is missing — simulates post-TTL state without waiting. + expect(await queue.redis.exists(runningCounterKey)).toBe(0); + + // releaseAllConcurrency calls releaseConcurrencyTracked under the hood for CK queues. + await queue.releaseAllConcurrency(msg.orgId, msg.runId); + + // Pre-release truth was 2 in flight; after release one remains. Counter should be 1. + const counterVal = Number(await queue.redis.get(runningCounterKey)); + expect(counterVal).toBe(1); + } finally { + await queue.quit(); + } + } + ); + + redisTest( + "counterTtlSeconds option is honored on lazy-init", + async ({ redisContainer }) => { + // Build a queue with a short TTL and verify the counter is SET with it. + const queue = new RunQueue({ + ...testOptions, + counterTtlSeconds: 60, + queueSelectionStrategy: new FairQueueSelectionStrategy({ + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + keys: testOptions.keys, + }), + redis: { + keyPrefix: "runqueue:test:", + host: redisContainer.getHost(), + port: redisContainer.getPort(), + }, + }); + try { + await queue.enqueueMessage({ + env: authenticatedEnvDev, + message: makeMessage({ runId: "r1", concurrencyKey: "ck-a" }), + workerQueue: authenticatedEnvDev.id, + skipDequeueProcessing: true, + }); + + const counterKey = testOptions.keys.queueLengthCounterKey( + authenticatedEnvDev, + "task/my-task" + ); + const ttl = await queue.redis.ttl(counterKey); + // Generous lower bound — CI workers can occasionally stall multiple + // seconds between the lazy-init SET and the TTL read. + expect(ttl).toBeGreaterThanOrEqual(50); + expect(ttl).toBeLessThanOrEqual(60); + } finally { + await queue.quit(); + } + } + ); +}); diff --git a/internal-packages/run-engine/src/run-queue/types.ts b/internal-packages/run-engine/src/run-queue/types.ts index 0e6b7437d51..0905f3971de 100644 --- a/internal-packages/run-engine/src/run-queue/types.ts +++ b/internal-packages/run-engine/src/run-queue/types.ts @@ -132,6 +132,12 @@ export interface RunQueueKeyProducer { baseQueueKeyFromQueue(queue: string): string; isCkWildcard(queue: string): boolean; toCkWildcard(queue: string): string; + + // Per-base-queue counters for CK queues + queueLengthCounterKey(env: RunQueueKeyProducerEnvironment, queue: string): string; + queueLengthCounterKeyFromQueue(queue: string): string; + queueRunningCounterKey(env: RunQueueKeyProducerEnvironment, queue: string): string; + queueRunningCounterKeyFromQueue(queue: string): string; } export type EnvQueues = { diff --git a/references/hello-world/src/trigger/ckCounters.ts b/references/hello-world/src/trigger/ckCounters.ts new file mode 100644 index 00000000000..b6a01cf0013 --- /dev/null +++ b/references/hello-world/src/trigger/ckCounters.ts @@ -0,0 +1,101 @@ +import { logger, queue, task } from "@trigger.dev/sdk"; +import { setTimeout } from "node:timers/promises"; + +async function tryCatch( + promise: Promise +): Promise<[Error, undefined] | [undefined, T]> { + try { + return [undefined, await promise]; + } catch (err) { + return [err instanceof Error ? err : new Error(String(err)), undefined]; + } +} + +// Slow CK queue: concurrency=2 so only 2 run at a time, the rest pile up in the +// per-CK-variant zsets. The Tracked Lua scripts should keep the per-base-queue +// lengthCounter in sync; before the fix, the dashboard's "Queued" column and the +// `validateQueueLimits` cap would read 0 from the empty base zset. +const ckCountersQueue = queue({ + name: "ck-counters-test-queue", + concurrencyLimit: 2, +}); + +// Slow worker — sleeps long enough to leave a backlog visible during inspection. +export const ckCountersWorker = task({ + id: "ck-counters-worker", + queue: ckCountersQueue, + retry: { maxAttempts: 1 }, + run: async (payload: { id: string; waitMs: number }) => { + logger.info(`ck-counters-worker ${payload.id} started`); + await setTimeout(payload.waitMs); + logger.info(`ck-counters-worker ${payload.id} finished`); + return { id: payload.id }; + }, +}); + +// Drives a deterministic backlog: +// 2 CKs (alpha, beta), 5 runs per CK, waitMs configurable. +// With concurrency=2, 2 of them run while ~8 sit in the CK-variant zsets. +// Inspect Redis during the wait to verify counters. +export const ckCountersBacklog = task({ + id: "ck-counters-backlog", + retry: { maxAttempts: 1 }, + maxDuration: 600, + run: async (payload: { + ckCount?: number; + perCk?: number; + waitMs?: number; + inspectSeconds?: number; + }) => { + const ckCount = payload.ckCount ?? 2; + const perCk = payload.perCk ?? 5; + const waitMs = payload.waitMs ?? 30_000; + const inspectSeconds = payload.inspectSeconds ?? 0; + + const total = ckCount * perCk; + logger.info("ck-counters-backlog triggering child runs", { + ckCount, + perCk, + waitMs, + total, + }); + + let triggered = 0; + let rejected = 0; + const rejectionMessages: string[] = []; + + for (let c = 0; c < ckCount; c++) { + const ck = `ck-${String.fromCharCode(97 + c)}`; + for (let i = 0; i < perCk; i++) { + const [err, handle] = await tryCatch( + ckCountersWorker.trigger( + { id: `${ck}-${i}`, waitMs }, + { concurrencyKey: ck } + ) + ); + + if (err) { + rejected += 1; + rejectionMessages.push(`${ck}-${i}: ${err.message}`); + logger.warn(`Trigger rejected for ${ck}-${i}`, { error: err.message }); + } else { + triggered += 1; + logger.info(`Triggered ${ck}-${i}`, { runId: handle?.id }); + } + } + } + + logger.info("ck-counters-backlog done triggering", { + triggered, + rejected, + rejectionMessages, + }); + + if (inspectSeconds > 0) { + logger.info(`Holding parent open for ${inspectSeconds}s for inspection…`); + await setTimeout(inspectSeconds * 1000); + } + + return { triggered, rejected, rejectionMessages }; + }, +}); From 5c4e06479d2fade041be65b996a5b6d2bfe20d05 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 12 May 2026 19:01:38 +0100 Subject: [PATCH 005/238] chore(docker): disable ClickHouse system log tables in local dev (#3565) ## Summary Local ClickHouse was burning ~325% CPU endlessly merging its own telemetry tables (`metric_log`, `asynchronous_metric_log`, `part_log`, `trace_log`) after the container had been running long enough to accumulate hundreds of GB of system-log data. OrbStack Helper reflected this on the host (~400% CPU). These tables are not used by anything in the dev stack. They only exist for ClickHouse to log itself, so disabling them eliminates the merge churn entirely. ## Changes - Adds `docker/config/clickhouse-disable-system-logs.xml`, mounted into `/etc/clickhouse-server/config.d/`, that removes the noisy system log tables via ``. - Mounts the override file in `docker/docker-compose.yml`. After applying, idle CPU dropped from 325% to ~12% on my machine. ## Test plan - [ ] `pnpm run docker` brings up the stack cleanly - [ ] `docker stats clickhouse` shows low idle CPU - [ ] App functionality unaffected (system log tables are not queried by the webapp) --- docker/config/clickhouse-disable-system-logs.xml | 13 +++++++++++++ docker/docker-compose.yml | 1 + 2 files changed, 14 insertions(+) create mode 100644 docker/config/clickhouse-disable-system-logs.xml diff --git a/docker/config/clickhouse-disable-system-logs.xml b/docker/config/clickhouse-disable-system-logs.xml new file mode 100644 index 00000000000..440df4c07ed --- /dev/null +++ b/docker/config/clickhouse-disable-system-logs.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 6b64e901bd0..547c64d819f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -137,6 +137,7 @@ services: volumes: - clickhouse-data:/var/lib/clickhouse - clickhouse-logs:/var/log/clickhouse-server + - ./config/clickhouse-disable-system-logs.xml:/etc/clickhouse-server/config.d/disable-system-logs.xml:ro networks: - app_network healthcheck: From 6b0e78f1db651afd25940673b997a334e50e0d98 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Tue, 12 May 2026 21:24:09 +0100 Subject: [PATCH 006/238] chore: raise REVIEW.md drift-audit turn budget and steer selective sampling (#3567) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up to #3561. The drift-audit workflow timed out on PR #3542 (92 files, +5962 lines) by hitting `--max-turns 15` before reaching a verdict, leaving a red ❌ on that PR with no sticky comment. ## Changes - `--max-turns` bumped from 15 to 30. - Prompt now opens with an explicit "Strategy" section: read REVIEW.md once, scan the file-list only, open at most 5 files (3-5 on PRs >50 files), and bias toward finishing over exploring. - Final rule: *"when in doubt between one more file read and finish now — finish now."* The audit is allowed to miss things. It is not allowed to time out and leave a red X. ## Test plan - [ ] Verify this PR's audit posts `✅ REVIEW.md looks current for this PR.` (small diff) - [ ] After merge, retry the audit on #3542 or a similarly large PR and confirm it completes --- .github/workflows/check-review-md.yml | 34 +++++++++++++++++---------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/.github/workflows/check-review-md.yml b/.github/workflows/check-review-md.yml index ecf44a47b27..9b5b69d9d6c 100644 --- a/.github/workflows/check-review-md.yml +++ b/.github/workflows/check-review-md.yml @@ -39,7 +39,7 @@ jobs: allowed_bots: "devin-ai-integration[bot]" claude_args: | - --max-turns 15 + --max-turns 30 --allowedTools "Read,Glob,Grep,Bash(git diff:*)" prompt: | @@ -49,15 +49,24 @@ jobs: `.claude/REVIEW.md` is the repo's source of truth for what AI / agent code reviewers should treat as critical findings (rolling-deploy safety, hot-table indexes, recovery-path queries, testcontainers usage, Lua versioning, etc.). It is consumed by review agents to calibrate severity. If REVIEW.md goes stale, every future agent review degrades. - ## Your task + ## Strategy — read this first - 1. Read `.claude/REVIEW.md` in full. - 2. Run `git diff origin/main...HEAD --name-only` to see which files changed in this PR. - 3. Sample the diff itself for any of these four signals: - - **Stale references** — does any rule cite a file, directory, function, table, Prisma model, or package name that has been removed or renamed in this PR or already gone from `main`? - - **Contradictions** — does code in this PR violate a current REVIEW.md rule? (Only flag if one side is clearly wrong — do not re-review the PR.) - - **Missing rules** — does this PR introduce a new pattern future reviewers should know about? Examples: a new hot table, a new Lua-script versioning convention, a new safety wrapper, a new "must always check" invariant. - - **Obsolete rules** — has the repo moved past a constraint REVIEW.md still asserts (e.g. a deprecated path is gone, a pattern is now linted, V1 code is deleted)? + You have a hard turn budget. Spend it on signal, not coverage. The audit is allowed to miss things; it is NOT allowed to time out. + + 1. Read `.claude/REVIEW.md` once, in full. + 2. Run `git diff origin/main...HEAD --name-only` to get the list of changed files. Do NOT read the diff content yet. + 3. Scan the file-list for relevance to REVIEW.md scope. Relevance signals: changes to Prisma schema, Redis / queue / Lua code, hot tables, recovery / restart loops, new packages, deletions of paths REVIEW.md cites. Skim everything else. + 4. Open at most **5 files** total — only the ones most likely to surface a real signal. If nothing in the file-list looks relevant to any REVIEW.md rule, do NOT read any files; go straight to the verdict. + 5. Form a verdict and stop. Do not exhaust the turn budget exploring. + + Large PRs (>50 files changed) are a strong signal to be MORE selective, not more thorough. Pick 3-5 files at most. + + ## What to look for + + - **Stale references** — does any REVIEW.md rule cite a file, directory, function, table, Prisma model, or package name that has been removed or renamed in this PR (or is already gone from `main`)? + - **Contradictions** — does code in this PR clearly violate a current REVIEW.md rule? (Don't re-review the PR. Only flag if REVIEW.md and the PR plainly disagree.) + - **Missing rules** — does this PR introduce a new pattern future reviewers should know about? Examples: a new hot table, a new Lua-script versioning convention, a new safety wrapper, a new "must always check" invariant. + - **Obsolete rules** — has the repo moved past a constraint REVIEW.md still asserts? (e.g. a deprecated path is gone, a pattern is now linted, V1 code is deleted.) ## Response format @@ -76,8 +85,9 @@ jobs: ## Rules - - Keep it tight. Maximum 3 suggestions per audit. Pick the highest-signal ones. - - Only flag things that would actually mislead a future reviewer. Style nits and wording preferences do not count. + - Maximum 3 suggestions per audit. Pick the highest-signal ones. + - Only flag things that would actually mislead a future reviewer. Style and wording do not count. - Do NOT review the PR itself. Do NOT propose rules outside REVIEW.md's existing sections. - - Do NOT propose adding rules for one-off PR specifics that don't generalize to future PRs. + - Do NOT propose rules for one-off PR specifics that don't generalize to future PRs. - If REVIEW.md does not exist in the repo, respond with `(skip)` and stop. + - When in doubt between "one more file read" and "finish now" — finish now. From 759214eabdea8746d33f8ee475bddffcde0cbf85 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Wed, 13 May 2026 09:26:01 +0100 Subject: [PATCH 007/238] fix(webapp): Evict legacy resizable-panel localStorage on client boot (#3564) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Users on production are hitting `QuotaExceededError: Failed to execute 'setItem' on 'Storage'` when navigating runs, because their localStorage is full of orphaned `panel-group-react-aria-::` entries. - Each entry is a session-unique key written by the resizable panel library; they accumulated to thousands per user over the last two months and now block legitimate `setItem` calls (the run-view inspector can no longer persist its layout, and the page crashes mid-render). - This PR evicts the legacy entries once on client boot. The leak itself is already plugged by the v1.1.3 upgrade in #XXXX — this is the cleanup that recovers the wasted quota on existing users' machines. ## Root cause (already fixed, for context) In v0.4.1 of the underlying library, `PanelGroupImpl` defaulted `autosaveStrategy` to `"localStorage"` unconditionally — so *every* `PanelGroup` wrote to localStorage on every autosave trigger, including the four in `QueryEditor`, the one in `ReplayRunDialog`, the storybook routes, etc. Without an `autosaveId`, the key fell back to `panel-group-${useId()}`, and React Aria's `useId()` produces a new session-unique prefix each visit. Result: entries accumulated without bound across sessions. The condition was introduced when [#3282](https://github.com/triggerdotdev/trigger.dev/pull/3282) removed the wrapper's explicit `autosaveStrategy="cookie"` override (to fix HTTP 431 cookie-size errors). That worked, but the library default that took over silently caused this leak. The v1.1.3 upgrade in the resizable-panel PR changed the default to `autosaveStrategy = autosaveId ? "localStorage" : undefined`, so no new entries are being written. Existing residue still needs to be removed from users' browsers. ## Changes - New file [`apps/webapp/app/clientBeforeFirstRender.ts`](apps/webapp/app/clientBeforeFirstRender.ts) — exports a `clientBeforeFirstRender()` function that runs synchronously, before React hydrates. Encapsulates a small cleanup helper that scans `localStorage` and removes: - Every key starting with `panel-group-react-aria` (the legacy auto-generated keys). - The orphan `panel-run-parent-v2` key from before the autosaveId v2→v3 bump. - [`apps/webapp/app/entry.client.tsx`](apps/webapp/app/entry.client.tsx) — imports and invokes `clientBeforeFirstRender()` once, before `hydrateRoot()`. This guarantees the cleanup completes before any `ResizablePanelGroup` mounts and tries to write. The cleanup is wrapped in `try/catch` so private-browsing / disabled-storage scenarios fail silently. Idempotent: subsequent loads find no matching keys and exit immediately. ## Test plan - [x] Locally seed ~50 fake `panel-group-react-aria…` entries plus a `panel-run-parent-v2` entry via DevTools console, hard reload → legacy entries gone, real entries (`panel-run-parent-v3`, `panel-run-tree`) preserved. - [x] Idempotency: reload a second time, no errors, no state changes. - [x] Add a control entry (`panel-run-parent-v3-but-different-suffix`) — confirmed not over-matched. - [x] Simulate broken `Storage.setItem` throwing — page still renders, cleanup swallows the error. - [x] Typecheck clean. ## Notes - Customer report: `QuotaExceededError: Failed to execute 'setItem' on 'Storage': Setting the value of 'panel-run-parent-v3' exceeded the quota.` - The cleanup runs once per page load. Once a user has loaded the app after this deploys, their localStorage is clean and the function becomes a no-op forever. --- apps/webapp/app/clientBeforeFirstRender.ts | 38 ++++++++++++++++++++++ apps/webapp/app/entry.client.tsx | 3 ++ 2 files changed, 41 insertions(+) create mode 100644 apps/webapp/app/clientBeforeFirstRender.ts diff --git a/apps/webapp/app/clientBeforeFirstRender.ts b/apps/webapp/app/clientBeforeFirstRender.ts new file mode 100644 index 00000000000..3275c54423a --- /dev/null +++ b/apps/webapp/app/clientBeforeFirstRender.ts @@ -0,0 +1,38 @@ +/** + * Runs once on the client, synchronously, before React hydrates the app. + * Reserved for housekeeping that must happen before any component mounts. + */ +export function clientBeforeFirstRender() { + cleanupLegacyResizablePanelStorage(); +} + +/** + * Earlier versions of the resizable panel library wrote a per-session + * localStorage entry for every PanelGroup, including ones without an + * `autosaveId`. The keys look like `panel-group-react-aria-::` + * and accumulate without bound across sessions until they exhaust the + * ~5 MB origin quota and break subsequent `setItem` calls. + * + * The library no longer behaves this way, but existing users still carry + * the residue. Evict it (plus the orphaned `panel-run-parent-v2` key from + * the v2→v3 autosaveId bump) once on load. + */ +function cleanupLegacyResizablePanelStorage() { + try { + const toRemove: string[] = []; + for (let i = 0; i < window.localStorage.length; i++) { + const key = window.localStorage.key(i); + if ( + key && + (key.startsWith("panel-group-react-aria") || key === "panel-run-parent-v2") + ) { + toRemove.push(key); + } + } + for (const key of toRemove) { + window.localStorage.removeItem(key); + } + } catch { + // localStorage may be disabled (private browsing, security policy) + } +} diff --git a/apps/webapp/app/entry.client.tsx b/apps/webapp/app/entry.client.tsx index 46c2919b832..e0d0f0ac076 100644 --- a/apps/webapp/app/entry.client.tsx +++ b/apps/webapp/app/entry.client.tsx @@ -1,8 +1,11 @@ import { RemixBrowser } from "@remix-run/react"; import { hydrateRoot } from "react-dom/client"; +import { clientBeforeFirstRender } from "./clientBeforeFirstRender"; import { LocaleContextProvider } from "./components/primitives/LocaleProvider"; import { OperatingSystemContextProvider } from "./components/primitives/OperatingSystemProvider"; +clientBeforeFirstRender(); + hydrateRoot( document, Date: Wed, 13 May 2026 18:06:13 -0400 Subject: [PATCH 008/238] docs(self-hosting): NodeLocal DNS and ClickHouse task events (#3568) ## Summary - Recommend deploying NodeLocal DNS and lowering `ndots` to `1` in the Kubernetes self-hosting guide. - Recommend storing task events in ClickHouse (`EVENT_REPOSITORY_DEFAULT_STORE=clickhouse_v2`) in both the Docker and Kubernetes guides, plus a new row in the webapp env var reference. --- docs/self-hosting/docker.mdx | 17 ++++++++++++++++- docs/self-hosting/env/webapp.mdx | 2 ++ docs/self-hosting/kubernetes.mdx | 21 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/docs/self-hosting/docker.mdx b/docs/self-hosting/docker.mdx index df9ae246c2d..665d6fc5875 100644 --- a/docs/self-hosting/docker.mdx +++ b/docs/self-hosting/docker.mdx @@ -189,6 +189,7 @@ docker compose up -d To create additional worker groups beyond the bootstrap group, use the admin API endpoint. This requires admin privileges. **Making a user admin:** + - **New users**: Set `ADMIN_EMAILS` environment variable (regex pattern) before user creation. - **Existing users**: Set `admin = true` in the `user` table in your database. @@ -341,6 +342,18 @@ By default, the images will point at the latest versioned release via the `lates TRIGGER_IMAGE_TAG=v4.0.0 ``` +## Task events + +By default, task events (timeline, logs, spans) are stored in PostgreSQL. For production deployments we recommend storing them in ClickHouse instead, it scales to much higher volumes and avoids unbounded growth of the `TaskEvent` table. + +To enable, set on the webapp in your `.env`: + +```bash +EVENT_REPOSITORY_DEFAULT_STORE=clickhouse_v2 +``` + +This only affects new runs; existing runs continue to read from wherever their events were originally stored. + ## Troubleshooting - **Deployment fails at the push step.** The machine running `deploy` needs registry access. See the [registry setup](#registry-setup) section for more details. @@ -359,7 +372,9 @@ TRIGGER_IMAGE_TAG=v4.0.0 - **ClickHouse migrations say "no migrations to run" but schema is missing.** The goose migration tracker is out of sync. Exec into the webapp container, set the GOOSE env vars (from webapp startup logs), and run `goose reset && goose up`. - **Data Loss Warning:** The `goose reset` command is destructive and will drop the entire schema. Make sure to backup your data and confirm you are running this in a non-production environment before executing this command. + **Data Loss Warning:** The `goose reset` command is destructive and will drop the entire schema. + Make sure to backup your data and confirm you are running this in a non-production environment + before executing this command. ## CLI usage diff --git a/docs/self-hosting/env/webapp.mdx b/docs/self-hosting/env/webapp.mdx index 3a45bf7b04b..17ce851753b 100644 --- a/docs/self-hosting/env/webapp.mdx +++ b/docs/self-hosting/env/webapp.mdx @@ -123,6 +123,8 @@ mode: "wide" | `TRIGGER_OTEL_ATTRIBUTE_PER_LINK_COUNT_LIMIT` | No | 10 | OTel attribute per link count limit. | | `TRIGGER_OTEL_ATTRIBUTE_PER_EVENT_COUNT_LIMIT` | No | 10 | OTel attribute per event count limit. | | `SERVER_OTEL_SPAN_ATTRIBUTE_VALUE_LENGTH_LIMIT` | No | 8192 | OTel span attribute value length limit. | +| **Task events** | | | | +| `EVENT_REPOSITORY_DEFAULT_STORE` | No | postgres | Where to store task events. Set to `clickhouse_v2` to store in ClickHouse (recommended for production). | | **Realtime** | | | | | `REALTIME_STREAM_MAX_LENGTH` | No | 1000 | Realtime stream max length. | | `REALTIME_STREAM_TTL` | No | 86400 (1d) | Realtime stream TTL (s). | diff --git a/docs/self-hosting/kubernetes.mdx b/docs/self-hosting/kubernetes.mdx index eba66f4ed07..8fbf8aab90a 100644 --- a/docs/self-hosting/kubernetes.mdx +++ b/docs/self-hosting/kubernetes.mdx @@ -354,6 +354,27 @@ webapp: - Compatible with secret management tools (External Secrets Operator, etc.) - Follows Kubernetes security best practices +## DNS performance + +For production clusters we recommend deploying [NodeLocal DNSCache](https://kubernetes.io/docs/tasks/administer-cluster/nodelocaldns/). DNS queries — especially to managed Postgres or Redis endpoints — can be very slow under Kubernetes' default resolver, and a node-local cache typically gives a large step change in latency and throughput across the cluster. + +The default `ndots: 5` setting also forces every cluster search domain to be tried before resolving hostnames with fewer dots (the case for most external database hosts). Lowering `ndots` to `1` on the webapp and supervisor pods avoids those extra round-trips. + +## Task events + +By default, task events (timeline, logs, spans) are stored in PostgreSQL. For production deployments we recommend storing them in ClickHouse instead, it scales to much higher volumes and avoids unbounded growth of the `TaskEvent` table. + +ClickHouse is already deployed by the chart, so no extra services are required. To enable, set `EVENT_REPOSITORY_DEFAULT_STORE` on the webapp via `extraEnvVars`: + +```yaml +webapp: + extraEnvVars: + - name: EVENT_REPOSITORY_DEFAULT_STORE + value: "clickhouse_v2" +``` + +This only affects new runs; existing runs continue to read from wherever their events were originally stored. + ## Worker token When using the default bootstrap configuration, worker creation and authentication is handled automatically. The webapp generates a worker token and makes it available to the supervisor via a shared volume. From d1442201747ac8e22277c11bf14dc3af89294a4c Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 13 May 2026 23:37:20 +0100 Subject: [PATCH 009/238] ci: path-based skip and gate job in pr_checks (#3615) `pr_checks` runs the full matrix on every PR. #3609 touched only `apps/webapp/app/routes/admin.tsx` and still ran the 4-job CLI e2e matrix and 5-job sdk-compat suite. Adds a `changes` job using `dorny/paths-filter` and gates each tier: - webapp + e2e-webapp: `apps/webapp/**`, `packages/**`, `internal-packages/**` - packages: `packages/**` - internal: `internal-packages/**` + `packages/**` (cross-deps) - e2e (cli-v3): `packages/{cli-v3,build,core,schema-to-json}/**` - sdk-compat: `packages/{trigger-sdk,core}/**` `.configs/**`, `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `turbo.json` are also included in every filter since they affect the whole workspace. Inlines the `units` reusable-workflow children so each can be gated independently (status check names also flatten from `units / webapp / ...` to `webapp / ...`). `unit-tests.yml` is unaffected - still used by `publish.yml`. Adds an `all-checks` gate that always runs and short-circuits to success when every dependent is success-or-skipped. With this in place a single required status check (`All PR Checks`) is enough; before this, `paths-ignore` would have left required checks Pending on docs/changeset PRs ([gh docs](https://docs.github.com/en/actions/managing-workflow-runs/skipping-workflow-runs)). --- .github/workflows/pr_checks.yml | 143 ++++++++++++++++++++++++++++++-- 1 file changed, 136 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index 27aa6a61a5b..f49ed4f2c09 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -3,11 +3,6 @@ name: 🤖 PR Checks on: pull_request: types: [opened, synchronize, reopened] - paths-ignore: - - "docs/**" - - ".changeset/**" - - "hosting/**" - - ".github/workflows/helm-prerelease.yml" concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -15,21 +10,155 @@ concurrency: permissions: contents: read + pull-requests: read jobs: + changes: + name: Detect changes + runs-on: ubuntu-latest + outputs: + code: ${{ steps.filter.outputs.code }} + webapp: ${{ steps.filter.outputs.webapp }} + packages: ${{ steps.filter.outputs.packages }} + internal: ${{ steps.filter.outputs.internal }} + cli: ${{ steps.filter.outputs.cli }} + sdk: ${{ steps.filter.outputs.sdk }} + steps: + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + code: + - '**' + - '!docs/**' + - '!.changeset/**' + - '!hosting/**' + - '!.github/workflows/helm-prerelease.yml' + webapp: + - 'apps/webapp/**' + - 'packages/**' + - 'internal-packages/**' + - '.github/workflows/pr_checks.yml' + - '.github/workflows/unit-tests-webapp.yml' + - '.github/workflows/e2e-webapp.yml' + - '.configs/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'turbo.json' + packages: + - 'packages/**' + - '.github/workflows/pr_checks.yml' + - '.github/workflows/unit-tests-packages.yml' + - '.configs/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'turbo.json' + internal: + - 'internal-packages/**' + - 'packages/**' + - '.github/workflows/pr_checks.yml' + - '.github/workflows/unit-tests-internal.yml' + - '.configs/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'turbo.json' + cli: + - 'packages/cli-v3/**' + - 'packages/build/**' + - 'packages/core/**' + - 'packages/schema-to-json/**' + - '.github/workflows/pr_checks.yml' + - '.github/workflows/e2e.yml' + - '.configs/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'turbo.json' + sdk: + - 'packages/trigger-sdk/**' + - 'packages/core/**' + - '.github/workflows/pr_checks.yml' + - '.github/workflows/sdk-compat.yml' + - '.configs/**' + - 'package.json' + - 'pnpm-lock.yaml' + - 'pnpm-workspace.yaml' + - 'turbo.json' + typecheck: + needs: changes + if: needs.changes.outputs.code == 'true' uses: ./.github/workflows/typecheck.yml - units: - uses: ./.github/workflows/unit-tests.yml + webapp: + needs: changes + if: needs.changes.outputs.webapp == 'true' + uses: ./.github/workflows/unit-tests-webapp.yml + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + + e2e-webapp: + needs: changes + if: needs.changes.outputs.webapp == 'true' + uses: ./.github/workflows/e2e-webapp.yml + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + + packages: + needs: changes + if: needs.changes.outputs.packages == 'true' + uses: ./.github/workflows/unit-tests-packages.yml + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + + internal: + needs: changes + if: needs.changes.outputs.internal == 'true' + uses: ./.github/workflows/unit-tests-internal.yml secrets: DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} e2e: + needs: changes + if: needs.changes.outputs.cli == 'true' uses: ./.github/workflows/e2e.yml with: package: cli-v3 sdk-compat: + needs: changes + if: needs.changes.outputs.sdk == 'true' uses: ./.github/workflows/sdk-compat.yml + + all-checks: + name: All PR Checks + needs: + - changes + - typecheck + - webapp + - e2e-webapp + - packages + - internal + - e2e + - sdk-compat + if: always() + runs-on: ubuntu-latest + steps: + - name: Verify all checks + run: | + if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then + echo "One or more checks failed" + exit 1 + fi + if [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then + echo "One or more checks were cancelled" + exit 1 + fi + echo "All checks passed or were skipped due to path filters" From 8ba067d8b01b5e9673c7d33e4da00abce3910301 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Wed, 13 May 2026 23:43:44 +0100 Subject: [PATCH 010/238] feat(webapp): preserve admin tabs search query between Users and Organizations (#3609) Switching between the Users and Organizations tabs in the admin dashboard now keeps the current `?search=` value, so you can flip between the two without re-typing your filter. Other admin tabs don't take `search` and so don't carry it. --- .server-changes/admin-tabs-preserve-search.md | 6 ++++++ apps/webapp/app/routes/admin.tsx | 10 +++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 .server-changes/admin-tabs-preserve-search.md diff --git a/.server-changes/admin-tabs-preserve-search.md b/.server-changes/admin-tabs-preserve-search.md new file mode 100644 index 00000000000..7caaa642626 --- /dev/null +++ b/.server-changes/admin-tabs-preserve-search.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Preserve search string when switching between the Users and Organizations tabs in the admin dashboard. diff --git a/apps/webapp/app/routes/admin.tsx b/apps/webapp/app/routes/admin.tsx index 236c7f0580c..1ac09efc76c 100644 --- a/apps/webapp/app/routes/admin.tsx +++ b/apps/webapp/app/routes/admin.tsx @@ -1,4 +1,4 @@ -import { Outlet } from "@remix-run/react"; +import { Outlet, useSearchParams } from "@remix-run/react"; import { typedjson } from "remix-typedjson"; import { LinkButton } from "~/components/primitives/Buttons"; import { Tabs } from "~/components/primitives/Tabs"; @@ -10,6 +10,10 @@ export const loader = dashboardLoader( ); export default function Page() { + const [searchParams] = useSearchParams(); + const search = searchParams.get("search"); + const searchSuffix = search ? `?search=${encodeURIComponent(search)}` : ""; + return (
@@ -17,11 +21,11 @@ export default function Page() { tabs={[ { label: "Users", - to: "/admin", + to: `/admin${searchSuffix}`, }, { label: "Organizations", - to: "/admin/orgs", + to: `/admin/orgs${searchSuffix}`, }, { label: "Concurrency", From be1a6cf8deceefbd3bad71bb78cf307d0262bfa6 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sun, 10 May 2026 22:26:11 +0100 Subject: [PATCH 011/238] =?UTF-8?q?feat:=20Sessions=20primitive=20?= =?UTF-8?q?=E2=80=94=20durable=20run-aware=20streams=20+=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Sessions, a durable, run-aware stream primitive that scopes session.in / session.out records to a session (not a single run). Records survive run boundaries; reconnect-from-last-event-id is built in. Server foundation: - New /realtime/v1/sessions/:session/:io/append + /records routes - sessionRunManager + sessionsRepository + clickhouseSessionsRepository - mintRunToken for short-lived per-session tokens - s2Append retry-with-backoff + undici cause diagnostics - /api/v[12]/packets/* exempt from customer rate limits - BackgroundWorker schema gains taskKind enum (TASK, AGENT, SCHEDULED) - TaskRun.taskKind column + clickhouse 029_add_task_kind_to_task_runs_v2 Core types: - new sessionStreams, inputStreams, realtimeStreams packages in @trigger.dev/core - session-streams-api / realtime-streams-api surface Sessions dashboard UI (the primitive's own viewer): - /sessions index + detail routes - SessionsTable, SessionFilters, SessionStatus, CloseSessionDialog - AGENT/SCHEDULED filter in RunFilters + TaskTriggerSource Includes the sessions-primitive changeset. --- .changeset/chat-ready-core-additions.md | 5 + .changeset/sessions-primitive.md | 9 + .gitignore | 4 + ...ssions-dashboard-and-task-source-filter.md | 6 + CLAUDE.md | 2 + .../app/components/BlankStatePanels.tsx | 23 + .../components/BulkActionFilterSummary.tsx | 13 + .../app/components/runs/v3/RunFilters.tsx | 117 ++- .../app/components/runs/v3/TaskRunsTable.tsx | 6 + .../components/runs/v3/TaskTriggerSource.tsx | 14 +- .../sessions/v1/CloseSessionDialog.tsx | 72 ++ .../components/sessions/v1/SessionFilters.tsx | 764 ++++++++++++++++++ .../components/sessions/v1/SessionStatus.tsx | 89 ++ .../components/sessions/v1/SessionsTable.tsx | 224 +++++ .../app/presenters/RunFilters.server.ts | 2 + .../app/presenters/SessionFilters.server.ts | 18 + .../v3/ApiRunListPresenter.server.ts | 4 + .../v3/NextRunListPresenter.server.ts | 6 + .../v3/SessionListPresenter.server.ts | 227 ++++++ .../presenters/v3/TaskListPresenter.server.ts | 1 + .../app/presenters/v3/TestPresenter.server.ts | 21 +- .../presenters/v3/TestTaskPresenter.server.ts | 4 + .../route.tsx | 107 +++ .../route.tsx | 10 + .../app/routes/api.v1.deployments.current.ts | 54 ++ ...altime.v1.sessions.$session.$io.records.ts | 98 +++ .../realtime.v1.streams.$runId.$streamId.ts | 3 +- .../resources.sessions.$sessionParam.close.ts | 98 +++ apps/webapp/app/routes/runs.$runParam.ts | 12 +- .../app/runEngine/concerns/queues.server.ts | 64 +- .../runEngine/services/triggerTask.server.ts | 89 +- apps/webapp/app/runEngine/types.ts | 35 +- .../app/services/apiRateLimit.server.ts | 7 + .../services/realtime/mintRunToken.server.ts | 41 + .../realtime/s2realtimeStreams.server.ts | 107 ++- .../realtime/sessionRunManager.server.ts | 186 ++++- .../services/runsReplicationService.server.ts | 1 + .../clickhouseRunsRepository.server.ts | 19 + .../runsRepository/runsRepository.server.ts | 3 + .../clickhouseSessionsRepository.server.ts | 1 + .../sessionsRepository.server.ts | 1 + apps/webapp/app/utils/pathBuilder.ts | 42 + .../services/createBackgroundWorker.server.ts | 10 +- .../029_add_task_kind_to_task_runs_v2.sql | 7 + .../clickhouse/src/taskRuns.test.ts | 4 + internal-packages/clickhouse/src/taskRuns.ts | 4 + .../migration.sql | 5 + .../migration.sql | 34 + .../migration.sql | 3 + .../database/prisma/schema.prisma | 53 +- packages/core/src/v3/apiClient/errors.ts | 12 + packages/core/src/v3/apiClient/index.ts | 311 +++++++ .../core/src/v3/apiClient/runStream.test.ts | 444 ++++++++++ packages/core/src/v3/apiClient/runStream.ts | 199 ++++- packages/core/src/v3/errors.ts | 20 + packages/core/src/v3/index.ts | 2 + packages/core/src/v3/inputStreams/index.ts | 12 + packages/core/src/v3/inputStreams/manager.ts | 147 +++- .../core/src/v3/inputStreams/noopManager.ts | 6 + packages/core/src/v3/inputStreams/types.ts | 22 + packages/core/src/v3/realtime-streams-api.ts | 2 + packages/core/src/v3/realtimeStreams/index.ts | 6 + .../core/src/v3/realtimeStreams/manager.ts | 3 +- .../src/v3/realtimeStreams/noopManager.ts | 2 +- .../realtimeStreams/sessionStreamInstance.ts | 103 +++ .../src/v3/realtimeStreams/streamInstance.ts | 7 +- .../src/v3/realtimeStreams/streamsWriterV1.ts | 7 +- .../realtimeStreams/streamsWriterV2.test.ts | 77 ++ .../src/v3/realtimeStreams/streamsWriterV2.ts | 67 +- packages/core/src/v3/realtimeStreams/types.ts | 42 +- packages/core/src/v3/schemas/api.ts | 31 + packages/core/src/v3/schemas/build.ts | 11 +- packages/core/src/v3/schemas/resources.ts | 7 + packages/core/src/v3/schemas/runEngine.ts | 4 + packages/core/src/v3/schemas/schemas.ts | 27 + .../core/src/v3/semanticInternalAttributes.ts | 1 + packages/core/src/v3/session-streams-api.ts | 7 + packages/core/src/v3/sessionStreams/index.ts | 89 ++ .../src/v3/sessionStreams/manager.test.ts | 151 ++++ .../core/src/v3/sessionStreams/manager.ts | 478 +++++++++++ .../core/src/v3/sessionStreams/noopManager.ts | 51 ++ packages/core/src/v3/sessionStreams/types.ts | 76 ++ .../src/v3/test/test-input-stream-manager.ts | 219 +++++ .../v3/test/test-realtime-streams-manager.ts | 170 ++++ .../src/v3/test/test-run-metadata-manager.ts | 103 +++ .../v3/test/test-session-stream-manager.ts | 286 +++++++ packages/core/src/v3/types/tasks.ts | 40 + packages/core/src/v3/utils/globals.ts | 2 + .../src/v3/utils/reconnectBackoff.test.ts | 82 ++ .../core/src/v3/utils/reconnectBackoff.ts | 25 + packages/core/src/v3/workers/index.ts | 1 + packages/core/src/v3/workers/taskExecutor.ts | 2 + packages/core/test/runStream.test.ts | 44 +- packages/trigger-sdk/src/v3/shared.ts | 239 +++++- packages/trigger-sdk/src/v3/streams.ts | 130 ++- packages/trigger-sdk/src/v3/tasks.ts | 2 + 96 files changed, 6292 insertions(+), 206 deletions(-) create mode 100644 .changeset/chat-ready-core-additions.md create mode 100644 .changeset/sessions-primitive.md create mode 100644 .server-changes/sessions-dashboard-and-task-source-filter.md create mode 100644 apps/webapp/app/components/sessions/v1/CloseSessionDialog.tsx create mode 100644 apps/webapp/app/components/sessions/v1/SessionFilters.tsx create mode 100644 apps/webapp/app/components/sessions/v1/SessionStatus.tsx create mode 100644 apps/webapp/app/components/sessions/v1/SessionsTable.tsx create mode 100644 apps/webapp/app/presenters/SessionFilters.server.ts create mode 100644 apps/webapp/app/presenters/v3/SessionListPresenter.server.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions/route.tsx create mode 100644 apps/webapp/app/routes/api.v1.deployments.current.ts create mode 100644 apps/webapp/app/routes/realtime.v1.sessions.$session.$io.records.ts create mode 100644 apps/webapp/app/routes/resources.sessions.$sessionParam.close.ts create mode 100644 apps/webapp/app/services/realtime/mintRunToken.server.ts create mode 100644 internal-packages/clickhouse/schema/029_add_task_kind_to_task_runs_v2.sql create mode 100644 internal-packages/database/prisma/migrations/20260329100903_add_agent_trigger_source_and_task_config/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20260330113734_add_playground_conversation/migration.sql create mode 100644 internal-packages/database/prisma/migrations/20260330135232_add_messages_and_last_event_id_to_playground/migration.sql create mode 100644 packages/core/src/v3/apiClient/runStream.test.ts create mode 100644 packages/core/src/v3/realtimeStreams/sessionStreamInstance.ts create mode 100644 packages/core/src/v3/realtimeStreams/streamsWriterV2.test.ts create mode 100644 packages/core/src/v3/session-streams-api.ts create mode 100644 packages/core/src/v3/sessionStreams/index.ts create mode 100644 packages/core/src/v3/sessionStreams/manager.test.ts create mode 100644 packages/core/src/v3/sessionStreams/manager.ts create mode 100644 packages/core/src/v3/sessionStreams/noopManager.ts create mode 100644 packages/core/src/v3/sessionStreams/types.ts create mode 100644 packages/core/src/v3/test/test-input-stream-manager.ts create mode 100644 packages/core/src/v3/test/test-realtime-streams-manager.ts create mode 100644 packages/core/src/v3/test/test-run-metadata-manager.ts create mode 100644 packages/core/src/v3/test/test-session-stream-manager.ts create mode 100644 packages/core/src/v3/utils/reconnectBackoff.test.ts create mode 100644 packages/core/src/v3/utils/reconnectBackoff.ts diff --git a/.changeset/chat-ready-core-additions.md b/.changeset/chat-ready-core-additions.md new file mode 100644 index 00000000000..e06db5e2c9f --- /dev/null +++ b/.changeset/chat-ready-core-additions.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Add `ChatChunkTooLargeError` and ApiClient methods for subscribing to session streams. Lays the groundwork for the upcoming `chat.agent`. diff --git a/.changeset/sessions-primitive.md b/.changeset/sessions-primitive.md new file mode 100644 index 00000000000..20690235c9a --- /dev/null +++ b/.changeset/sessions-primitive.md @@ -0,0 +1,9 @@ +--- +"@trigger.dev/sdk": minor +"@trigger.dev/core": patch +--- + +Adds the Sessions primitive — a durable, run-aware stream channel keyed +on a stable `externalId`. Public SDK additions: `tasks.triggerAndSubscribe()` +and the `chat.agent` runtime built on top of Sessions. See +https://trigger.dev/docs/ai-chat/overview for the full feature surface. diff --git a/.gitignore b/.gitignore index 5f6adddba0a..d071d5ae4e3 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,10 @@ apps/**/public/build /packages/trigger-sdk/src/package.json /packages/python/src/package.json **/.claude/settings.local.json +.claude/architecture/ +.claude/docs-plans/ +.claude/review-guides/ +.claude/scheduled_tasks.lock .mcp.log .mcp.json .cursor/debug.log diff --git a/.server-changes/sessions-dashboard-and-task-source-filter.md b/.server-changes/sessions-dashboard-and-task-source-filter.md new file mode 100644 index 00000000000..c3a727c4325 --- /dev/null +++ b/.server-changes/sessions-dashboard-and-task-source-filter.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: feature +--- + +New Sessions page in the dashboard for inspecting `chat.agent` Session rows alongside their underlying runs, plus a "Task source" filter on the Runs list (Standard / Scheduled / Agent) so agent runs can be sliced out of mixed workloads at a glance. diff --git a/CLAUDE.md b/CLAUDE.md index 53348d012a2..28650fd08aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,6 +6,8 @@ This file provides guidance to Claude Code when working with this repository. Su This is a pnpm 10.33.2 monorepo using Turborepo. Run commands from root with `pnpm run`. +**Adding dependencies:** Edit `package.json` directly instead of using `pnpm add`, then run `pnpm i` from the repo root. See `.claude/rules/package-installation.md` for the full process. + ```bash pnpm run docker # Start Docker services (PostgreSQL, Redis, Electric) pnpm run db:migrate # Run database migrations diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx index fe39f6785c5..9a4884e09d3 100644 --- a/apps/webapp/app/components/BlankStatePanels.tsx +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -1,4 +1,5 @@ import { + ArrowsRightLeftIcon, BeakerIcon, BellAlertIcon, BookOpenIcon, @@ -189,6 +190,28 @@ export function BatchesNone() { ); } +export function SessionsNone() { + return ( + + Sessions docs + + } + > + + You have no sessions in this environment. Sessions are durable, typed, bidirectional I/O + primitives that outlive a single run — used by chat.agent and any + long-running task that needs streaming input and output. + + + ); +} + export function TestHasNoTasks() { const organization = useOrganization(); const project = useProject(); diff --git a/apps/webapp/app/components/BulkActionFilterSummary.tsx b/apps/webapp/app/components/BulkActionFilterSummary.tsx index a230e70b346..c5d1a2f48d7 100644 --- a/apps/webapp/app/components/BulkActionFilterSummary.tsx +++ b/apps/webapp/app/components/BulkActionFilterSummary.tsx @@ -240,6 +240,19 @@ export function BulkActionFilterSummary({ /> ); } + case "sources": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } default: { assertNever(typedKey); } diff --git a/apps/webapp/app/components/runs/v3/RunFilters.tsx b/apps/webapp/app/components/runs/v3/RunFilters.tsx index c02e93a5c6e..c27ac1bc187 100644 --- a/apps/webapp/app/components/runs/v3/RunFilters.tsx +++ b/apps/webapp/app/components/runs/v3/RunFilters.tsx @@ -2,6 +2,7 @@ import * as Ariakit from "@ariakit/react"; import { CalendarIcon, ClockIcon, + CpuChipIcon, FingerPrintIcon, PlusIcon, RectangleStackIcon, @@ -190,6 +191,9 @@ export const TaskRunListSearchFilters = z.object({ `Machine presets to filter by (${machines.join(", ")})` ), errorId: z.string().optional().describe("Error ID to filter runs by (e.g. error_abc123)"), + sources: StringOrStringArray.describe( + "Task trigger sources to filter by (STANDARD, SCHEDULED, AGENT)" + ), }); export type TaskRunListSearchFilters = z.infer; @@ -231,6 +235,8 @@ export function filterTitle(filterKey: string) { return "Version"; case "errorId": return "Error ID"; + case "sources": + return "Source"; default: return filterKey; } @@ -271,6 +277,8 @@ export function filterIcon(filterKey: string): ReactNode | undefined { return ; case "errorId": return ; + case "sources": + return ; default: return undefined; } @@ -318,6 +326,10 @@ export function getRunFiltersFromSearchParams( ? searchParams.getAll("versions") : undefined, errorId: searchParams.get("errorId") ?? undefined, + sources: + searchParams.getAll("sources").filter((v) => v.length > 0).length > 0 + ? searchParams.getAll("sources") + : undefined, }; const parsed = TaskRunListSearchFilters.safeParse(params); @@ -359,7 +371,8 @@ export function RunsFilters(props: RunFiltersProps) { searchParams.has("queues") || searchParams.has("machines") || searchParams.has("versions") || - searchParams.has("errorId"); + searchParams.has("errorId") || + searchParams.has("sources"); return (
@@ -395,6 +408,7 @@ const filterTypes = [ { name: "schedule", title: "Schedule ID", icon: }, { name: "bulk", title: "Bulk action", icon: }, { name: "error", title: "Error ID", icon: }, + { name: "source", title: "Source", icon: }, ] as const; type FilterType = (typeof filterTypes)[number]["name"]; @@ -448,6 +462,7 @@ function AppliedFilters({ bulkActions }: RunFiltersProps) { + ); } @@ -482,6 +497,8 @@ function Menu(props: MenuProps) { return props.setFilterType(undefined)} {...props} />; case "error": return props.setFilterType(undefined)} {...props} />; + case "source": + return props.setFilterType(undefined)} {...props} />; } } @@ -1739,3 +1756,101 @@ function AppliedErrorIdFilter() { ); } + +const sourceOptions: { value: TaskTriggerSource; title: string }[] = [ + { value: "STANDARD", title: "Standard" }, + { value: "SCHEDULED", title: "Scheduled" }, + { value: "AGENT", title: "Agent" }, +]; + +function SourceDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (values: string[]) => { + clearSearchValue(); + replace({ sources: values, cursor: undefined, direction: undefined }); + }; + + const filtered = useMemo(() => { + return sourceOptions.filter((item) => + item.title.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + > + + + {filtered.map((item, index) => ( + + } + shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })} + > + {item.title} + + ))} + + + + ); +} + +function AppliedSourceFilter() { + const { values, del } = useSearchParams(); + const sources = values("sources"); + + if (sources.length === 0 || sources.every((v) => v === "")) { + return null; + } + + return ( + + {(search, setSearch) => ( + }> + } + value={appliedSummary( + sources.map( + (v) => sourceOptions.find((o) => o.value === v)?.title ?? v + ) + )} + onRemove={() => del(["sources", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} diff --git a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx index 346fd25eee2..bf8337baa10 100644 --- a/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx +++ b/apps/webapp/app/components/runs/v3/TaskRunsTable.tsx @@ -55,8 +55,10 @@ import { filterableTaskRunStatuses, TaskRunStatusCombo, } from "./TaskRunStatus"; +import { TaskTriggerSourceIcon } from "./TaskTriggerSource"; import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; import { useSearchParams } from "~/hooks/useSearchParam"; +import type { TaskTriggerSource } from "@trigger.dev/database"; type RunsTableProps = { total: number; @@ -352,6 +354,10 @@ export function TaskRunsTable({ + {run.taskIdentifier} {run.rootTaskRunId === null ? Root : null} diff --git a/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx b/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx index 8d81e2f36c3..cb06c8e2a92 100644 --- a/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx +++ b/apps/webapp/app/components/runs/v3/TaskTriggerSource.tsx @@ -1,4 +1,4 @@ -import { ClockIcon } from "@heroicons/react/20/solid"; +import { ClockIcon, CpuChipIcon } from "@heroicons/react/20/solid"; import type { TaskTriggerSource } from "@trigger.dev/database"; import { TaskIconSmall } from "~/assets/icons/TaskIcon"; import { cn } from "~/utils/cn"; @@ -12,13 +12,20 @@ export function TaskTriggerSourceIcon({ }) { switch (source) { case "STANDARD": { - return ; + return ( + + ); } case "SCHEDULED": { return ( ); } + case "AGENT": { + return ( + + ); + } } } @@ -30,5 +37,8 @@ export function taskTriggerSourceDescription(source: TaskTriggerSource) { case "SCHEDULED": { return "Scheduled task"; } + case "AGENT": { + return "Agent"; + } } } diff --git a/apps/webapp/app/components/sessions/v1/CloseSessionDialog.tsx b/apps/webapp/app/components/sessions/v1/CloseSessionDialog.tsx new file mode 100644 index 00000000000..7feba8e6db5 --- /dev/null +++ b/apps/webapp/app/components/sessions/v1/CloseSessionDialog.tsx @@ -0,0 +1,72 @@ +import { XCircleIcon } from "@heroicons/react/24/solid"; +import { DialogClose } from "@radix-ui/react-dialog"; +import { Form, useNavigation } from "@remix-run/react"; +import { Button } from "~/components/primitives/Buttons"; +import { DialogContent, DialogHeader } from "~/components/primitives/Dialog"; +import { FormButtons } from "~/components/primitives/FormButtons"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { SpinnerWhite } from "~/components/primitives/Spinner"; + +type CloseSessionDialogProps = { + sessionParam: string; + environmentId: string; + redirectPath: string; +}; + +export function CloseSessionDialog({ + sessionParam, + environmentId, + redirectPath, +}: CloseSessionDialogProps) { + const navigation = useNavigation(); + + const formAction = `/resources/sessions/${encodeURIComponent(sessionParam)}/close`; + const isLoading = navigation.formAction === formAction; + + return ( + + Close this session? +
+ + Closing a session is permanent. The session will no longer accept new input or trigger + new runs. Any in-flight run continues until it finishes on its own. + +
+ + +
+ + +
+ + {isLoading ? "Closing..." : "Close session"} + + } + cancelButton={ + + + + } + /> + +
+
+ ); +} diff --git a/apps/webapp/app/components/sessions/v1/SessionFilters.tsx b/apps/webapp/app/components/sessions/v1/SessionFilters.tsx new file mode 100644 index 00000000000..9c13b7b4b3f --- /dev/null +++ b/apps/webapp/app/components/sessions/v1/SessionFilters.tsx @@ -0,0 +1,764 @@ +import * as Ariakit from "@ariakit/react"; +import { + CpuChipIcon, + FingerPrintIcon, + TagIcon, + XMarkIcon, +} from "@heroicons/react/20/solid"; +import { Form } from "@remix-run/react"; +import { ListFilterIcon } from "lucide-react"; +import { type ReactNode, useCallback, useMemo, useState } from "react"; +import { z } from "zod"; +import { StatusIcon } from "~/assets/icons/StatusIcon"; +import { TaskIcon } from "~/assets/icons/TaskIcon"; +import { AppliedFilter } from "~/components/primitives/AppliedFilter"; +import { Input } from "~/components/primitives/Input"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { + ComboBox, + SelectButtonItem, + SelectItem, + SelectList, + SelectPopover, + SelectProvider, + SelectTrigger, + shortcutFromIndex, +} from "~/components/primitives/Select"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "~/components/primitives/Tooltip"; +import { useOptimisticLocation } from "~/hooks/useOptimisticLocation"; +import { useSearchParams } from "~/hooks/useSearchParam"; +import { Button } from "../../primitives/Buttons"; +import { + appliedSummary, + FilterMenuProvider, + TimeFilter, +} from "../../runs/v3/SharedFilters"; +import { + allSessionStatuses, + descriptionForSessionStatus, + SessionStatusCombo, + sessionStatusTitle, +} from "./SessionStatus"; + +const StringOrStringArray = z.preprocess( + (value) => (typeof value === "string" ? [value] : value), + z.array(z.string()).optional() +); + +export const SessionStatus = z.enum(allSessionStatuses); + +export const SessionListSearchFilters = z.object({ + cursor: z.string().optional(), + direction: z.enum(["forward", "backward"]).optional(), + statuses: z.preprocess( + (value) => (typeof value === "string" ? [value] : value), + SessionStatus.array().optional() + ), + types: StringOrStringArray, + taskIdentifiers: StringOrStringArray, + externalId: z.string().optional(), + tags: StringOrStringArray, + period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()), + from: z.coerce.number().optional(), + to: z.coerce.number().optional(), +}); + +export type SessionListSearchFilters = z.infer; +export type SessionListSearchFilterKey = keyof SessionListSearchFilters; + +export function getSessionFiltersFromSearchParams( + searchParams: URLSearchParams +): SessionListSearchFilters { + function listOrUndefined(key: string) { + const values = searchParams.getAll(key).filter((v) => v.length > 0); + return values.length > 0 ? values : undefined; + } + + const params = { + cursor: searchParams.get("cursor") ?? undefined, + direction: searchParams.get("direction") ?? undefined, + statuses: listOrUndefined("statuses"), + types: listOrUndefined("types"), + taskIdentifiers: listOrUndefined("taskIdentifiers"), + externalId: searchParams.get("externalId") ?? undefined, + tags: listOrUndefined("tags"), + period: searchParams.get("period") ?? undefined, + from: searchParams.get("from") ?? undefined, + to: searchParams.get("to") ?? undefined, + }; + + const parsed = SessionListSearchFilters.safeParse(params); + if (!parsed.success) { + return {}; + } + return parsed.data; +} + +type SessionFiltersProps = { + hasFilters: boolean; + possibleTypes?: string[]; +}; + +export function SessionFilters(props: SessionFiltersProps) { + const location = useOptimisticLocation(); + const searchParams = new URLSearchParams(location.search); + const hasFilters = + searchParams.has("statuses") || + searchParams.has("types") || + searchParams.has("taskIdentifiers") || + searchParams.has("externalId") || + searchParams.has("tags"); + + return ( +
+ + + + {hasFilters && ( +
+
+ ); +} + +const filterTypes = [ + { + name: "statuses", + title: "Status", + icon: , + }, + { name: "types", title: "Type", icon: }, + { + name: "taskIdentifiers", + title: "Task", + icon: , + }, + { + name: "externalId", + title: "External ID", + icon: , + }, + { name: "tags", title: "Tags", icon: }, +] as const; + +type FilterType = (typeof filterTypes)[number]["name"]; + +const shortcut = { key: "f" }; + +function FilterMenu(props: SessionFiltersProps) { + const [filterType, setFilterType] = useState(); + + const filterTrigger = ( + + +
+ } + variant={"secondary/small"} + shortcut={shortcut} + tooltipTitle={"Filter sessions"} + > + Filter + + ); + + return ( + setFilterType(undefined)}> + {(search, setSearch) => ( + setSearch("")} + trigger={filterTrigger} + filterType={filterType} + setFilterType={setFilterType} + {...props} + /> + )} + + ); +} + +function AppliedFilters() { + return ( + <> + + + + + + + ); +} + +type MenuProps = { + searchValue: string; + clearSearchValue: () => void; + trigger: React.ReactNode; + filterType: FilterType | undefined; + setFilterType: (filterType: FilterType | undefined) => void; +} & SessionFiltersProps; + +function Menu(props: MenuProps) { + switch (props.filterType) { + case undefined: + return ; + case "statuses": + return props.setFilterType(undefined)} {...props} />; + case "types": + return props.setFilterType(undefined)} {...props} />; + case "taskIdentifiers": + return ( + props.setFilterType(undefined)} {...props} /> + ); + case "externalId": + return props.setFilterType(undefined)} {...props} />; + case "tags": + return props.setFilterType(undefined)} {...props} />; + } +} + +function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) { + const filtered = useMemo(() => { + return filterTypes.filter((item) => + item.title.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue]); + + return ( + + {trigger} + + + + {filtered.map((type, index) => ( + { + clearSearchValue(); + setFilterType(type.name); + }} + icon={type.icon} + shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })} + > + {type.title} + + ))} + + + + ); +} + +const statusItems = allSessionStatuses.map((status) => ({ + title: sessionStatusTitle(status), + value: status, +})); + +function StatusDropdown({ + trigger, + clearSearchValue, + searchValue, + onClose, +}: { + trigger: ReactNode; + clearSearchValue: () => void; + searchValue: string; + onClose?: () => void; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (next: string[]) => { + clearSearchValue(); + replace({ statuses: next, cursor: undefined, direction: undefined }); + }; + + const filtered = useMemo(() => { + return statusItems.filter((item) => + item.title.toLowerCase().includes(searchValue.toLowerCase()) + ); + }, [searchValue]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + > + + + {filtered.map((item, index) => ( + + + + + + + + + {descriptionForSessionStatus(item.value)} + + + + + + ))} + + + + ); +} + +function AppliedStatusFilter() { + const { values, del } = useSearchParams(); + const statuses = values("statuses"); + + if (statuses.length === 0) return null; + + return ( + + {(search, setSearch) => ( + }> + } + value={appliedSummary( + statuses.map((v) => sessionStatusTitle(v as (typeof allSessionStatuses)[number])) + )} + onRemove={() => del(["statuses", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function TypeDropdown({ + trigger, + searchValue, + clearSearchValue, + possibleTypes, + onClose, +}: { + trigger: ReactNode; + searchValue: string; + clearSearchValue: () => void; + possibleTypes?: string[]; + onClose?: () => void; +}) { + const { values, replace } = useSearchParams(); + + const handleChange = (next: string[]) => { + clearSearchValue(); + replace({ types: next, cursor: undefined, direction: undefined }); + }; + + const items = useMemo(() => { + const all = possibleTypes && possibleTypes.length > 0 ? possibleTypes : ["chat"]; + const seen = new Set(all); + for (const v of values("types")) { + if (!seen.has(v)) { + all.push(v); + seen.add(v); + } + } + return all.filter((t) => t.toLowerCase().includes(searchValue.toLowerCase())); + }, [possibleTypes, searchValue, values]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + > + + + {items.map((value, index) => ( + + {value} + + ))} + + + + ); +} + +function AppliedTypeFilter() { + const { values, del } = useSearchParams(); + const types = values("types"); + if (types.length === 0) return null; + + return ( + + {(search, setSearch) => ( + }> + } + value={appliedSummary(types)} + onRemove={() => del(["types", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function TaskIdentifierDropdown({ + trigger, + searchValue, + clearSearchValue, + onClose, +}: { + trigger: ReactNode; + searchValue: string; + clearSearchValue: () => void; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + const { value, replace } = useSearchParams(); + const current = value("taskIdentifiers"); + const [draft, setDraft] = useState(current ?? ""); + + const apply = useCallback(() => { + clearSearchValue(); + replace({ + taskIdentifiers: draft.trim() === "" ? undefined : [draft.trim()], + cursor: undefined, + direction: undefined, + }); + setOpen(false); + }, [clearSearchValue, draft, replace]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + className="max-w-[min(32ch,var(--popover-available-width))]" + > +
+
+ + setDraft(e.target.value)} + variant="small" + className="w-[29ch] font-mono" + spellCheck={false} + /> +
+
+ + +
+
+
+
+ ); +} + +function AppliedTaskIdentifierFilter() { + const { values, del } = useSearchParams(); + const taskIdentifiers = values("taskIdentifiers"); + if (taskIdentifiers.length === 0) return null; + + return ( + + {(search, setSearch) => ( + }> + } + value={appliedSummary(taskIdentifiers)} + onRemove={() => del(["taskIdentifiers", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function ExternalIdDropdown({ + trigger, + searchValue, + clearSearchValue, + onClose, +}: { + trigger: ReactNode; + searchValue: string; + clearSearchValue: () => void; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + const { value, replace } = useSearchParams(); + const current = value("externalId"); + const [draft, setDraft] = useState(current ?? ""); + + const apply = useCallback(() => { + clearSearchValue(); + replace({ + externalId: draft.trim() === "" ? undefined : draft.trim(), + cursor: undefined, + direction: undefined, + }); + setOpen(false); + }, [clearSearchValue, draft, replace]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + className="max-w-[min(36ch,var(--popover-available-width))]" + > +
+
+ + setDraft(e.target.value)} + variant="small" + className="w-[33ch] font-mono" + spellCheck={false} + /> +
+
+ + +
+
+
+
+ ); +} + +function AppliedExternalIdFilter() { + const { value, del } = useSearchParams(); + const externalId = value("externalId"); + if (!externalId) return null; + + return ( + + {(search, setSearch) => ( + }> + } + value={externalId} + onRemove={() => del(["externalId", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + +function TagsDropdown({ + trigger, + searchValue, + clearSearchValue, + onClose, +}: { + trigger: ReactNode; + searchValue: string; + clearSearchValue: () => void; + onClose?: () => void; +}) { + const [open, setOpen] = useState(); + const { values, replace } = useSearchParams(); + const current = values("tags"); + const [draft, setDraft] = useState(current.join(", ")); + + const apply = useCallback(() => { + clearSearchValue(); + const next = draft + .split(/[,\n]/) + .map((t) => t.trim()) + .filter((t) => t.length > 0); + replace({ + tags: next.length === 0 ? undefined : next, + cursor: undefined, + direction: undefined, + }); + setOpen(false); + }, [clearSearchValue, draft, replace]); + + return ( + + {trigger} + { + if (onClose) { + onClose(); + return false; + } + return true; + }} + className="max-w-[min(40ch,var(--popover-available-width))]" + > +
+
+ + setDraft(e.target.value)} + variant="small" + className="w-[37ch] font-mono" + spellCheck={false} + /> + + Comma-separated. Matches sessions with any of these tags. + +
+
+ + +
+
+
+
+ ); +} + +function AppliedTagsFilter() { + const { values, del } = useSearchParams(); + const tags = values("tags"); + if (tags.length === 0) return null; + + return ( + + {(search, setSearch) => ( + }> + } + value={appliedSummary(tags)} + onRemove={() => del(["tags", "cursor", "direction"])} + variant="secondary/small" + /> + + } + searchValue={search} + clearSearchValue={() => setSearch("")} + /> + )} + + ); +} + diff --git a/apps/webapp/app/components/sessions/v1/SessionStatus.tsx b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx new file mode 100644 index 00000000000..a4e17affd83 --- /dev/null +++ b/apps/webapp/app/components/sessions/v1/SessionStatus.tsx @@ -0,0 +1,89 @@ +import { CheckCircleIcon, ClockIcon } from "@heroicons/react/20/solid"; +import assertNever from "assert-never"; +import { type SessionStatus } from "~/services/sessionsRepository/sessionsRepository.server"; +import { cn } from "~/utils/cn"; + +export const allSessionStatuses = ["ACTIVE", "CLOSED", "EXPIRED"] as const satisfies Readonly< + Array +>; + +const descriptions: Record = { + ACTIVE: "The session is open and can receive input or schedule new runs.", + CLOSED: "The session was closed; no further input or runs can be triggered against it.", + EXPIRED: "The session passed its expiry time without being closed explicitly.", +}; + +export function descriptionForSessionStatus(status: SessionStatus): string { + return descriptions[status]; +} + +export function sessionStatusTitle(status: SessionStatus): string { + switch (status) { + case "ACTIVE": + return "Active"; + case "CLOSED": + return "Closed"; + case "EXPIRED": + return "Expired"; + default: + assertNever(status); + } +} + +export function sessionStatusColor(status: SessionStatus): string { + switch (status) { + case "ACTIVE": + return "text-pending"; + case "CLOSED": + return "text-success"; + case "EXPIRED": + return "text-text-dimmed"; + default: + assertNever(status); + } +} + +export function SessionStatusIcon({ + status, + className, +}: { + status: SessionStatus; + className: string; +}) { + switch (status) { + case "ACTIVE": + return ( + + + + ); + case "CLOSED": + return ; + case "EXPIRED": + return ; + default: + assertNever(status); + } +} + +export function SessionStatusLabel({ status }: { status: SessionStatus }) { + return {sessionStatusTitle(status)}; +} + +export function SessionStatusCombo({ + status, + className, + iconClassName, +}: { + status: SessionStatus; + className?: string; + iconClassName?: string; +}) { + return ( + + + + + ); +} + diff --git a/apps/webapp/app/components/sessions/v1/SessionsTable.tsx b/apps/webapp/app/components/sessions/v1/SessionsTable.tsx new file mode 100644 index 00000000000..fb83f2d03eb --- /dev/null +++ b/apps/webapp/app/components/sessions/v1/SessionsTable.tsx @@ -0,0 +1,224 @@ +import { ArrowRightIcon } from "@heroicons/react/20/solid"; +import { useLocation, useNavigation } from "@remix-run/react"; +import { formatDuration } from "@trigger.dev/core/v3/utils/durations"; +import { ListBulletIcon } from "~/assets/icons/ListBulletIcon"; +import { MiddleTruncate } from "~/components/primitives/MiddleTruncate"; +import { DateTime } from "~/components/primitives/DateTime"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { PopoverMenuItem } from "~/components/primitives/Popover"; +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 { LiveTimer } from "~/components/runs/v3/LiveTimer"; +import { RunTag } from "~/components/runs/v3/RunTag"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { + type SessionListItem, + type SessionList, +} from "~/presenters/v3/SessionListPresenter.server"; +import { v3RunPath, v3RunsPath, v3SessionPath } from "~/utils/pathBuilder"; +import { + descriptionForSessionStatus, + SessionStatusCombo, + allSessionStatuses, +} from "./SessionStatus"; + +type SessionsTableProps = Pick; + +export function SessionsTable({ sessions, hasFilters }: SessionsTableProps) { + const navigation = useNavigation(); + const location = useLocation(); + const isLoading = + navigation.state !== "idle" && navigation.location?.pathname === location.pathname; + + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( +
+ + + ID + + {allSessionStatuses.map((status) => ( +
+
+ +
+ + {descriptionForSessionStatus(status)} + +
+ ))} + + } + > + Status +
+ Type + Task + Tags + Created + Duration + + Actions + +
+
+ + {sessions.length === 0 ? ( + +
+ + {hasFilters + ? "No sessions match these filters" + : "No sessions in this environment yet"} + +
+
+ ) : ( + sessions.map((session) => { + const runPath = session.currentRunFriendlyId + ? v3RunPath(organization, project, environment, { + friendlyId: session.currentRunFriendlyId, + }) + : undefined; + + const displayId = session.externalId ?? session.friendlyId; + const sessionPath = v3SessionPath(organization, project, environment, { + friendlyId: session.friendlyId, + }); + const allRunsPath = v3RunsPath(organization, project, environment, { + tags: [`chat:${displayId}`], + }); + + return ( + + +
+ +
+
+ + } + /> + + + {session.type} + + +
+ +
+
+ + {session.tags.length > 0 ? ( +
+ {session.tags.map((tag) => ( + + ))} +
+ ) : ( + + )} +
+ + + + + + + +
+ ); + }) + )} + {isLoading && ( + + Loading… + + )} +
+
+ ); +} + +function SessionDuration({ session }: { session: SessionListItem }) { + // Active sessions tick live; closed/expired sessions freeze at the + // moment they ended (closedAt for explicit closes, expiresAt when the + // TTL ran out without a close call). + const endedAt = + session.status === "CLOSED" + ? session.closedAt + : session.status === "EXPIRED" + ? session.expiresAt + : undefined; + + if (endedAt) { + return <>{formatDuration(new Date(session.createdAt), new Date(endedAt), { style: "short" })}; + } + + return ; +} + +function SessionActionsCell({ + runPath, + allRunsPath, +}: { + runPath?: string; + allRunsPath: string; +}) { + return ( + + {runPath && ( + + )} + + + } + /> + ); +} diff --git a/apps/webapp/app/presenters/RunFilters.server.ts b/apps/webapp/app/presenters/RunFilters.server.ts index ff9f53429eb..44bb4c01f50 100644 --- a/apps/webapp/app/presenters/RunFilters.server.ts +++ b/apps/webapp/app/presenters/RunFilters.server.ts @@ -36,6 +36,7 @@ export async function getRunFiltersFromRequest(request: Request): Promise 0; const hasFilters = + (sources !== undefined && sources.length > 0) || (tasks !== undefined && tasks.length > 0) || (versions !== undefined && versions.length > 0) || hasStatusFilters || @@ -186,6 +190,7 @@ export class NextRunListPresenter { queues, machines, errorId, + taskKinds: sources, page: { size: pageSize, cursor, @@ -250,6 +255,7 @@ export class NextRunListPresenter { name: run.queue.replace("task/", ""), type: run.queue.startsWith("task/") ? "task" : "custom", }, + taskKind: RunAnnotations.safeParse(run.annotations).data?.taskKind ?? "STANDARD", }; }), pagination: { diff --git a/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts new file mode 100644 index 00000000000..df68569c85d --- /dev/null +++ b/apps/webapp/app/presenters/v3/SessionListPresenter.server.ts @@ -0,0 +1,227 @@ +import { type Span } from "@opentelemetry/api"; +import { type ClickHouse } from "@internal/clickhouse"; +import { type PrismaClient, type PrismaClientOrTransaction } from "@trigger.dev/database"; +import { type Direction } from "~/components/ListPagination"; +import { timeFilters } from "~/components/runs/v3/SharedFilters"; +import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server"; +import { + type SessionStatus, + SessionsRepository, +} from "~/services/sessionsRepository/sessionsRepository.server"; +import { ServiceValidationError } from "~/v3/services/baseService.server"; +import { startActiveSpan } from "~/v3/tracer.server"; + +export type SessionListOptions = { + userId?: string; + projectId: string; + // filters + types?: string[]; + taskIdentifiers?: string[]; + externalId?: string; + tags?: string[]; + statuses?: SessionStatus[]; + period?: string; + from?: number; + to?: number; + // pagination + direction?: Direction; + cursor?: string; + pageSize?: number; +}; + +const DEFAULT_PAGE_SIZE = 25; + +export type SessionList = Awaited>; +export type SessionListItem = SessionList["sessions"][0]; +export type SessionListAppliedFilters = SessionList["filters"]; + +export class SessionListPresenter { + constructor( + private readonly replica: PrismaClientOrTransaction, + private readonly clickhouse: ClickHouse + ) {} + + public async call( + organizationId: string, + environmentId: string, + options: SessionListOptions + ) { + return startActiveSpan( + "SessionListPresenter.call", + (span) => this.#call(organizationId, environmentId, options, span), + { + attributes: { + organizationId, + environmentId, + projectId: options.projectId, + }, + } + ); + } + + async #call( + organizationId: string, + environmentId: string, + { + userId, + projectId, + types, + taskIdentifiers, + externalId, + tags, + statuses, + period, + from, + to, + direction = "forward", + cursor, + pageSize = DEFAULT_PAGE_SIZE, + }: SessionListOptions, + rootSpan: Span + ) { + const time = timeFilters({ period, from, to }); + + const hasFilters = + (types !== undefined && types.length > 0) || + (taskIdentifiers !== undefined && taskIdentifiers.length > 0) || + (externalId !== undefined && externalId !== "") || + (tags !== undefined && tags.length > 0) || + (statuses !== undefined && statuses.length > 0) || + !time.isDefault; + + rootSpan.setAttribute("filters.hasFilters", hasFilters); + rootSpan.setAttribute("page.size", pageSize); + if (cursor) rootSpan.setAttribute("page.cursor", cursor); + + const displayableEnvironment = await startActiveSpan( + "SessionListPresenter.findDisplayableEnvironment", + () => findDisplayableEnvironment(environmentId, userId) + ); + if (!displayableEnvironment) { + throw new ServiceValidationError("No environment found"); + } + + const sessionsRepository = new SessionsRepository({ + clickhouse: this.clickhouse, + prisma: this.replica as PrismaClient, + }); + + function clampToNow(date: Date): Date { + const now = new Date(); + return date > now ? now : date; + } + + const { sessions, pagination } = await sessionsRepository.listSessions({ + organizationId, + projectId, + environmentId, + types, + taskIdentifiers, + externalId, + tags, + statuses, + period, + from: time.from ? time.from.getTime() : undefined, + to: time.to ? clampToNow(time.to).getTime() : undefined, + page: { + size: pageSize, + cursor, + direction, + }, + }); + + rootSpan.setAttribute("page.count", sessions.length); + + let hasAnySessions = sessions.length > 0; + if (!hasAnySessions) { + const firstSession = await startActiveSpan( + "SessionListPresenter.hasAnySessions", + () => + this.replica.session.findFirst({ + where: { runtimeEnvironmentId: environmentId }, + select: { id: true }, + }) + ); + if (firstSession) { + hasAnySessions = true; + } + } + + // Resolve current-run friendlyIds in one query so each row can link to + // its live run. Status is intentionally not joined yet — that lives in + // ClickHouse and would mean a second query per page; the link itself + // is the value most viewers want first. + const currentRunIds = sessions + .map((s) => s.currentRunId) + .filter((id): id is string => Boolean(id)); + + const currentRuns = await startActiveSpan( + "SessionListPresenter.findCurrentRuns", + async (span) => { + span.setAttribute("currentRunIds.count", currentRunIds.length); + // Scope by projectId + runtimeEnvironmentId — Session.currentRunId + // is a plain string column without an FK, so a stale or corrupted + // pointer could surface another tenant's run. The list query above + // is already env-scoped; the run lookup needs the same fence. + return currentRunIds.length > 0 + ? this.replica.taskRun.findMany({ + where: { + id: { in: currentRunIds }, + projectId, + runtimeEnvironmentId: environmentId, + }, + select: { id: true, friendlyId: true }, + }) + : []; + } + ); + const runById = new Map(currentRuns.map((r) => [r.id, r] as const)); + + const now = Date.now(); + + return { + sessions: sessions.map((session) => { + const status: SessionStatus = + session.closedAt != null + ? "CLOSED" + : session.expiresAt != null && session.expiresAt.getTime() < now + ? "EXPIRED" + : "ACTIVE"; + + const currentRun = session.currentRunId ? runById.get(session.currentRunId) : undefined; + + 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)) : [], + status, + 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, + currentRunFriendlyId: currentRun?.friendlyId, + }; + }), + pagination: { + next: pagination.nextCursor ?? undefined, + previous: pagination.previousCursor ?? undefined, + }, + filters: { + types: types ?? [], + taskIdentifiers: taskIdentifiers ?? [], + externalId, + tags: tags ?? [], + statuses: statuses ?? [], + from: time.from, + to: time.to, + }, + hasFilters, + hasAnySessions, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts index f1635f23375..fc29f5510e8 100644 --- a/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TaskListPresenter.server.ts @@ -61,6 +61,7 @@ export class TaskListPresenter { const tasks = await this._replica.backgroundWorkerTask.findMany({ where: { workerId: currentWorker.id, + triggerSource: { not: "AGENT" }, }, select: { id: true, diff --git a/apps/webapp/app/presenters/v3/TestPresenter.server.ts b/apps/webapp/app/presenters/v3/TestPresenter.server.ts index af5bb93a7e7..b817bbf155e 100644 --- a/apps/webapp/app/presenters/v3/TestPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestPresenter.server.ts @@ -19,15 +19,13 @@ export class TestPresenter extends BasePresenter { const tasks = await this.#getTasks(environmentId, isDev); return { - tasks: tasks.map((task) => { - return { - id: task.id, - taskIdentifier: task.slug, - filePath: task.filePath, - friendlyId: task.friendlyId, - triggerSource: task.triggerSource, - }; - }), + tasks: tasks.map((task) => ({ + id: task.id, + taskIdentifier: task.slug, + filePath: task.filePath, + friendlyId: task.friendlyId, + triggerSource: task.triggerSource, + })), }; } @@ -54,10 +52,13 @@ export class TestPresenter extends BasePresenter { SELECT bwt.id, version, slug, "filePath", bwt."friendlyId", bwt."triggerSource" FROM latest_workers JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" bwt ON bwt."workerId" = latest_workers.id + WHERE bwt."triggerSource" != 'AGENT' ORDER BY slug ASC;`; } else { const currentDeployment = await findCurrentWorkerDeployment({ environmentId: envId }); - return currentDeployment?.worker?.tasks ?? []; + return (currentDeployment?.worker?.tasks ?? []).filter( + (t) => t.triggerSource !== "AGENT" + ); } } } diff --git a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts index 09abb22639e..d5360cd004a 100644 --- a/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/TestTaskPresenter.server.ts @@ -373,6 +373,10 @@ export class TestTaskPresenter { ), }; } + case "AGENT": { + // AGENT tasks are filtered out by TestPresenter and shouldn't reach here + return { foundTask: false }; + } default: { return task.triggerSource satisfies never; } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx new file mode 100644 index 00000000000..99b0a96b5d1 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions._index/route.tsx @@ -0,0 +1,107 @@ +import { BookOpenIcon } 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 { ListPagination } from "~/components/ListPagination"; +import { AdminDebugTooltip } from "~/components/admin/debugTooltip"; +import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout"; +import { LinkButton } from "~/components/primitives/Buttons"; +import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader"; +import { SessionFilters } from "~/components/sessions/v1/SessionFilters"; +import { SessionsTable } from "~/components/sessions/v1/SessionsTable"; +import { SessionsNone } from "~/components/BlankStatePanels"; +import { $replica } from "~/db.server"; +import { redirectWithErrorMessage } from "~/models/message.server"; +import { findProjectBySlug } from "~/models/project.server"; +import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server"; +import { getSessionFiltersFromRequest } from "~/presenters/SessionFilters.server"; +import { SessionListPresenter } from "~/presenters/v3/SessionListPresenter.server"; +import { clickhouseClient } from "~/services/clickhouseInstance.server"; +import { requireUserId } from "~/services/session.server"; +import { docsPath, EnvironmentParamSchema } from "~/utils/pathBuilder"; + +export const meta: MetaFunction = () => { + return [ + { + title: `Sessions | Trigger.dev`, + }, + ]; +}; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.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 filters = getSessionFiltersFromRequest(request); + + const presenter = new SessionListPresenter($replica, clickhouseClient); + const list = await presenter.call(project.organizationId, environment.id, { + userId, + projectId: project.id, + statuses: filters.statuses, + types: filters.types, + taskIdentifiers: filters.taskIdentifiers, + externalId: filters.externalId, + tags: filters.tags, + period: filters.period, + from: filters.from, + to: filters.to, + cursor: filters.cursor, + direction: filters.direction, + }); + + return typedjson(list); +}; + +export default function Page() { + const list = useTypedLoaderData(); + + return ( + <> + + + + + + Sessions docs + + + + + {!list.hasAnySessions ? ( + + + + ) : ( +
+
+ +
+ +
+
+ +
+ )} +
+ + ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions/route.tsx new file mode 100644 index 00000000000..f6723ddebaa --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions/route.tsx @@ -0,0 +1,10 @@ +import { Outlet } from "@remix-run/react"; +import { PageContainer } from "~/components/layout/AppLayout"; + +export default function Page() { + return ( + + + + ); +} diff --git a/apps/webapp/app/routes/api.v1.deployments.current.ts b/apps/webapp/app/routes/api.v1.deployments.current.ts new file mode 100644 index 00000000000..48170414ec2 --- /dev/null +++ b/apps/webapp/app/routes/api.v1.deployments.current.ts @@ -0,0 +1,54 @@ +import { json } from "@remix-run/server-runtime"; +import { $replica } from "~/db.server"; +import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +export const loader = createLoaderApiRoute( + { + allowJWT: true, + corsStrategy: "none", + authorization: { + action: "read", + resource: () => ({ type: "deployments", id: "current" }), + }, + findResource: async (_params, auth) => { + const promotion = await $replica.workerDeploymentPromotion.findFirst({ + where: { + environmentId: auth.environment.id, + label: "current", + }, + select: { + deployment: { + select: { + friendlyId: true, + createdAt: true, + shortCode: true, + version: true, + runtime: true, + runtimeVersion: true, + status: true, + deployedAt: true, + git: true, + errorData: true, + }, + }, + }, + }); + + return promotion?.deployment ?? undefined; + }, + }, + async ({ resource: deployment }) => { + return json({ + id: deployment.friendlyId, + createdAt: deployment.createdAt, + shortCode: deployment.shortCode, + version: deployment.version, + runtime: deployment.runtime, + runtimeVersion: deployment.runtimeVersion, + status: deployment.status, + deployedAt: deployment.deployedAt ?? undefined, + git: deployment.git ?? undefined, + error: deployment.errorData ?? undefined, + }); + } +); diff --git a/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.records.ts b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.records.ts new file mode 100644 index 00000000000..92d87fca760 --- /dev/null +++ b/apps/webapp/app/routes/realtime.v1.sessions.$session.$io.records.ts @@ -0,0 +1,98 @@ +import { json } from "@remix-run/server-runtime"; +import { z } from "zod"; +import { $replica } from "~/db.server"; +import { S2RealtimeStreams } from "~/services/realtime/s2realtimeStreams.server"; +import { + canonicalSessionAddressingKey, + isSessionFriendlyIdForm, + resolveSessionByIdOrExternalId, +} from "~/services/realtime/sessions.server"; +import { getRealtimeStreamInstance } from "~/services/realtime/v1StreamsGlobal.server"; +import { anyResource, createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; + +const ParamsSchema = z.object({ + session: z.string(), + io: z.enum(["out", "in"]), +}); + +const SearchSchema = z.object({ + // S2 sequence number — same cursor format as the SSE Last-Event-ID + // (the SSE `id:` field on session-channel events is the seq_num, + // stringified). Records returned have `seqNum > afterEventId`. + afterEventId: z.string().regex(/^\d+$/).optional(), +}); + +// GET: non-SSE, `wait=0` drain of a session channel. Returns a JSON body +// `{ records: StreamRecord[] }` with whatever records exist after +// `afterEventId` (or from the head if absent) and closes immediately. +// +// Used by the SDK's `replaySessionOutTail` at run boot — the SSE long-poll +// path costs ~1s per fresh chat (the timeout duration) regardless of stream +// content, which is unacceptable on the first-message TTFC budget. This +// route gives the agent a cheap "what's there right now" peek instead. +// +// Same row-optional addressing as the SSE GET route in `…$io.ts`: we +// resolve via `resolveSessionByIdOrExternalId` and only 404 for opaque +// `session_*` friendlyIds (which must reference a real row). External-id +// form falls through with `row: null` so the boot path doesn't 404 on a +// fresh chat that hasn't written its first chunk yet. +export const loader = createLoaderApiRoute( + { + params: ParamsSchema, + searchParams: SearchSchema, + allowJWT: true, + corsStrategy: "all", + findResource: async (params, auth) => { + const row = await resolveSessionByIdOrExternalId( + $replica, + auth.environment.id, + params.session + ); + if (!row && isSessionFriendlyIdForm(params.session)) { + return undefined; + } + return { + row, + addressingKey: canonicalSessionAddressingKey(row, params.session), + }; + }, + authorization: { + action: "read", + // Multi-key: the channel is addressable by the URL key, the row's + // friendlyId, and (if set) externalId. Type-level `read:sessions` + // matches any of them; `read:all` / `admin` bypass via the JWT + // ability's wildcard branches. + resource: ({ row, addressingKey }) => { + const ids = new Set([addressingKey]); + if (row) { + ids.add(row.friendlyId); + if (row.externalId) ids.add(row.externalId); + } + return anyResource([...ids].map((id) => ({ type: "sessions", id }))); + }, + }, + }, + async ({ params, authentication, resource, searchParams }) => { + const realtimeStream = getRealtimeStreamInstance(authentication.environment, "v2", { + session: resource.row, + organization: resource.row ? null : authentication.environment.organization, + }); + + if (!(realtimeStream instanceof S2RealtimeStreams)) { + return new Response("Session channels require the S2 realtime backend", { + status: 501, + }); + } + + const afterSeqNum = + searchParams.afterEventId !== undefined ? Number(searchParams.afterEventId) : undefined; + + const records = await realtimeStream.readSessionStreamRecords( + resource.addressingKey, + params.io, + afterSeqNum + ); + + return json({ records }); + } +); diff --git a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts index 39935b9de1f..d6470794a73 100644 --- a/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts +++ b/apps/webapp/app/routes/realtime.v1.streams.$runId.$streamId.ts @@ -87,7 +87,7 @@ export const loader = createLoaderApiRoute( allowJWT: true, corsStrategy: "all", findResource: async (params, auth) => { - return $replica.taskRun.findFirst({ + const run = await $replica.taskRun.findFirst({ where: { friendlyId: params.runId, runtimeEnvironmentId: auth.environment.id, @@ -106,6 +106,7 @@ export const loader = createLoaderApiRoute( }, }, }); + return run; }, authorization: { action: "read", diff --git a/apps/webapp/app/routes/resources.sessions.$sessionParam.close.ts b/apps/webapp/app/routes/resources.sessions.$sessionParam.close.ts new file mode 100644 index 00000000000..27ffec56716 --- /dev/null +++ b/apps/webapp/app/routes/resources.sessions.$sessionParam.close.ts @@ -0,0 +1,98 @@ +import { parse } from "@conform-to/zod"; +import { type ActionFunction, json } from "@remix-run/node"; +import { z } from "zod"; +import { $replica, prisma } from "~/db.server"; +import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server"; +import { resolveSessionByIdOrExternalId } from "~/services/realtime/sessions.server"; +import { logger } from "~/services/logger.server"; +import { requireUserId } from "~/services/session.server"; + +export const closeSessionSchema = z.object({ + redirectUrl: z.string(), + environmentId: z.string(), + reason: z.string().optional(), +}); + +const ParamSchema = z.object({ + sessionParam: z.string(), +}); + +export const action: ActionFunction = async ({ request, params }) => { + const userId = await requireUserId(request); + const { sessionParam } = ParamSchema.parse(params); + + const formData = await request.formData(); + const submission = parse(formData, { schema: closeSessionSchema }); + + if (!submission.value) { + return json(submission); + } + + const { redirectUrl, environmentId, reason } = submission.value; + const trimmedReason = reason?.trim(); + const closedReason = + trimmedReason && trimmedReason.length > 0 ? trimmedReason : "closed-from-dashboard"; + + try { + // Confirm the user belongs to the org that owns this environment, then + // resolve the session by friendlyId or externalId scoped to that env. + const environment = await $replica.runtimeEnvironment.findFirst({ + where: { + id: environmentId, + organization: { members: { some: { userId } } }, + }, + select: { id: true }, + }); + + if (!environment) { + submission.error = { environmentId: ["Environment not found"] }; + return json(submission); + } + + const session = await resolveSessionByIdOrExternalId( + $replica, + environment.id, + sessionParam + ); + + if (!session) { + submission.error = { sessionParam: ["Session not found"] }; + return json(submission); + } + + if (session.closedAt) { + // Already closed — no-op, but redirect with a friendly message so the + // UI doesn't look like it did nothing. + return redirectWithSuccessMessage(redirectUrl, request, `Session already closed`); + } + + // Conditional update mirrors the public API: two concurrent closes race + // through the read but only one wins this update. + await prisma.session.updateMany({ + where: { id: session.id, closedAt: null }, + data: { + closedAt: new Date(), + closedReason, + }, + }); + + return redirectWithSuccessMessage(redirectUrl, request, `Closed session`); + } catch (error) { + if (error instanceof Error) { + logger.error("Failed to close session", { + error: { name: error.name, message: error.message, stack: error.stack }, + }); + return redirectWithErrorMessage( + redirectUrl, + request, + `Failed to close session, ${error.message}` + ); + } + logger.error("Failed to close session", { error }); + return redirectWithErrorMessage( + redirectUrl, + request, + `Failed to close session, ${JSON.stringify(error)}` + ); + } +}; diff --git a/apps/webapp/app/routes/runs.$runParam.ts b/apps/webapp/app/routes/runs.$runParam.ts index 4a8d7a12d32..b472d7ae8f4 100644 --- a/apps/webapp/app/routes/runs.$runParam.ts +++ b/apps/webapp/app/routes/runs.$runParam.ts @@ -28,6 +28,7 @@ export async function loader({ params, request }: LoaderFunctionArgs) { }, }, select: { + spanId: true, runtimeEnvironment: { select: { slug: true, @@ -57,11 +58,20 @@ export async function loader({ params, request }: LoaderFunctionArgs) { ); } + // Preserve existing search params from the request, add span if not already set + const url = new URL(request.url); + const searchParams = url.searchParams; + + if (!searchParams.has("span") && run.spanId) { + searchParams.set("span", run.spanId); + } + const path = v3RunPath( { slug: run.project.organization.slug }, { slug: run.project.slug }, { slug: run.runtimeEnvironment.slug }, - { friendlyId: runParam } + { friendlyId: runParam }, + searchParams ); return redirect(path); diff --git a/apps/webapp/app/runEngine/concerns/queues.server.ts b/apps/webapp/app/runEngine/concerns/queues.server.ts index 136c3da3b9c..4b4298bc935 100644 --- a/apps/webapp/app/runEngine/concerns/queues.server.ts +++ b/apps/webapp/app/runEngine/concerns/queues.server.ts @@ -79,6 +79,7 @@ export class DefaultQueueManager implements QueueManager { let queueName: string; let lockedQueueId: string | undefined; let taskTtl: string | null | undefined; + let taskKind: string | undefined; // Determine queue name based on lockToVersion and provided options if (lockedBackgroundWorker) { @@ -106,19 +107,26 @@ export class DefaultQueueManager implements QueueManager { queueName = specifiedQueue.name; lockedQueueId = specifiedQueue.id; - // Only fetch task for TTL if caller didn't provide a per-trigger TTL - if (request.body.options?.ttl === undefined) { - const lockedTask = await this.replicaPrisma.backgroundWorkerTask.findFirst({ - where: { - workerId: lockedBackgroundWorker.id, - runtimeEnvironmentId: request.environment.id, - slug: request.taskId, - }, - select: { ttl: true }, - }); + // 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 }, + }); + if (request.body.options?.ttl === undefined) { taskTtl = lockedTask?.ttl; } + taskKind = lockedTask?.triggerSource; } else { // No queue override - fetch task with queue to get both default queue and TTL const lockedTask = await this.replicaPrisma.backgroundWorkerTask.findFirst({ @@ -158,6 +166,7 @@ export class DefaultQueueManager implements QueueManager { // Use the task's default queue name queueName = lockedTask.queue.name; lockedQueueId = lockedTask.queue.id; + taskKind = lockedTask.triggerSource; } } else { // Task is not locked to a specific version, use regular logic @@ -172,6 +181,7 @@ export class DefaultQueueManager implements QueueManager { const taskInfo = await this.getTaskQueueInfo(request); queueName = taskInfo.queueName; taskTtl = taskInfo.taskTtl; + taskKind = taskInfo.taskKind; } // Sanitize the final determined queue name once @@ -188,12 +198,13 @@ export class DefaultQueueManager implements QueueManager { queueName, lockedQueueId, taskTtl, + taskKind, }; } private async getTaskQueueInfo( request: TriggerTaskRequest - ): Promise<{ queueName: string; taskTtl?: string | null }> { + ): Promise<{ queueName: string; taskTtl?: string | null; taskKind?: string | undefined }> { const { taskId, environment, body } = request; const { queue } = body.options ?? {}; @@ -202,14 +213,21 @@ export class DefaultQueueManager implements QueueManager { const defaultQueueName = `task/${taskId}`; - // When caller provides both a queue override and a per-trigger TTL, - // we don't need any DB queries - the per-trigger TTL takes precedence - if (overriddenQueueName && body.options?.ttl !== undefined) { - return { queueName: overriddenQueueName, taskTtl: undefined }; - } - - // Find the current worker for the environment - const worker = await findCurrentWorkerFromEnvironment(environment, this.prisma); + // 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`. + + // 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", { @@ -228,10 +246,10 @@ export class DefaultQueueManager implements QueueManager { runtimeEnvironmentId: environment.id, slug: taskId, }, - select: { ttl: true }, + select: { ttl: true, triggerSource: true }, }); - return { queueName: overriddenQueueName, taskTtl: task?.ttl }; + return { queueName: overriddenQueueName, taskTtl: task?.ttl, taskKind: task?.triggerSource }; } const task = await this.replicaPrisma.backgroundWorkerTask.findFirst({ @@ -261,10 +279,10 @@ export class DefaultQueueManager implements QueueManager { queueConfig: task.queueConfig, }); - return { queueName: defaultQueueName, taskTtl: task.ttl }; + return { queueName: defaultQueueName, taskTtl: task.ttl, taskKind: task.triggerSource }; } - return { queueName: task.queue.name ?? defaultQueueName, taskTtl: task.ttl }; + return { queueName: task.queue.name ?? defaultQueueName, taskTtl: task.ttl, taskKind: task.triggerSource }; } async validateQueueLimits( diff --git a/apps/webapp/app/runEngine/services/triggerTask.server.ts b/apps/webapp/app/runEngine/services/triggerTask.server.ts index 445e0eb155a..bbfdc3956c2 100644 --- a/apps/webapp/app/runEngine/services/triggerTask.server.ts +++ b/apps/webapp/app/runEngine/services/triggerTask.server.ts @@ -185,7 +185,7 @@ export class RunEngineTriggerTaskService { if (debounceDelayError || !debounceDelayUntil) { throw new ServiceValidationError( `Invalid debounce delay: ${body.options.debounce.delay}. ` + - `Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w` + `Supported formats: {number}s, {number}m, {number}h, {number}d, {number}w` ); } } @@ -193,11 +193,11 @@ export class RunEngineTriggerTaskService { // Get parent run if specified const parentRun = body.options?.parentRunId ? await this.prisma.taskRun.findFirst({ - where: { - id: RunId.fromFriendlyId(body.options.parentRunId), - runtimeEnvironmentId: environment.id, - }, - }) + where: { + id: RunId.fromFriendlyId(body.options.parentRunId), + runtimeEnvironmentId: environment.id, + }, + }) : undefined; // Validate parent run @@ -231,21 +231,21 @@ export class RunEngineTriggerTaskService { const lockedToBackgroundWorker = body.options?.lockToVersion ? await this.prisma.backgroundWorker.findFirst({ - where: { - projectId: environment.projectId, - runtimeEnvironmentId: environment.id, - version: body.options?.lockToVersion, - }, - select: { - id: true, - version: true, - sdkVersion: true, - cliVersion: true, - }, - }) + where: { + projectId: environment.projectId, + runtimeEnvironmentId: environment.id, + version: body.options?.lockToVersion, + }, + select: { + id: true, + version: true, + sdkVersion: true, + cliVersion: true, + }, + }) : undefined; - const { queueName, lockedQueueId, taskTtl } = + const { queueName, lockedQueueId, taskTtl, taskKind } = await this.queueConcern.resolveQueueProperties( triggerRequest, lockedToBackgroundWorker ?? undefined @@ -281,10 +281,10 @@ export class RunEngineTriggerTaskService { const metadataPacket = body.options?.metadata ? handleMetadataPacket( - body.options?.metadata, - body.options?.metadataType ?? "application/json", - this.metadataMaximumSize - ) + body.options?.metadata, + body.options?.metadataType ?? "application/json", + this.metadataMaximumSize + ) : undefined; const tags = ( @@ -313,6 +313,7 @@ export class RunEngineTriggerTaskService { triggerAction, rootTriggerSource: parentAnnotations?.rootTriggerSource ?? triggerSource, rootScheduleId: parentAnnotations?.rootScheduleId || options.scheduleId || undefined, + taskKind: taskKind ?? "STANDARD", }; try { @@ -369,9 +370,9 @@ export class RunEngineTriggerTaskService { rootTaskRunId: parentRun?.rootTaskRunId ?? parentRun?.id, batch: options?.batchId ? { - id: options.batchId, - index: options.batchIndex ?? 0, - } + id: options.batchId, + index: options.batchIndex ?? 0, + } : undefined, resumeParentOnCompletion: body.options?.resumeParentOnCompletion, depth, @@ -402,26 +403,26 @@ export class RunEngineTriggerTaskService { onDebounced: body.options?.debounce && body.options?.resumeParentOnCompletion ? async ({ existingRun, waitpoint, debounceKey }) => { - return await this.traceEventConcern.traceDebouncedRun( - triggerRequest, - parentRun?.taskEventStore, - { - existingRun, - debounceKey, - incomplete: waitpoint.status === "PENDING", - isError: waitpoint.outputIsError, - }, - async (spanEvent) => { - const spanId = - options?.parentAsLinkType === "replay" - ? spanEvent.spanId - : spanEvent.traceparent?.spanId + return await this.traceEventConcern.traceDebouncedRun( + triggerRequest, + parentRun?.taskEventStore, + { + existingRun, + debounceKey, + incomplete: waitpoint.status === "PENDING", + isError: waitpoint.outputIsError, + }, + async (spanEvent) => { + const spanId = + options?.parentAsLinkType === "replay" + ? spanEvent.spanId + : spanEvent.traceparent?.spanId ? `${spanEvent.traceparent.spanId}:${spanEvent.spanId}` : spanEvent.spanId; - return spanId; - } - ); - } + return spanId; + } + ); + } : undefined, }, this.prisma diff --git a/apps/webapp/app/runEngine/types.ts b/apps/webapp/app/runEngine/types.ts index d5e61d01889..c0c5de1d2fd 100644 --- a/apps/webapp/app/runEngine/types.ts +++ b/apps/webapp/app/runEngine/types.ts @@ -37,18 +37,19 @@ export type TriggerTaskResult = { export type QueueValidationResult = | { - ok: true; - } + ok: true; + } | { - ok: false; - maximumSize: number; - queueSize: number; - }; + ok: false; + maximumSize: number; + queueSize: number; + }; export type QueueProperties = { queueName: string; lockedQueueId?: string; taskTtl?: string | null; + taskKind?: string; }; export type LockedBackgroundWorker = Pick< @@ -98,22 +99,22 @@ export interface ParentRunValidationParams { export type ValidationResult = | { - ok: true; - } + ok: true; + } | { - ok: false; - error: Error; - }; + ok: false; + error: Error; + }; export type EntitlementValidationResult = | { - ok: true; - plan?: ReportUsagePlan; - } + ok: true; + plan?: ReportUsagePlan; + } | { - ok: false; - error: Error; - }; + ok: false; + error: Error; + }; export interface TriggerTaskValidator { validateTags(params: TagValidationParams): ValidationResult; diff --git a/apps/webapp/app/services/apiRateLimit.server.ts b/apps/webapp/app/services/apiRateLimit.server.ts index 8f40da009a4..3618806fce7 100644 --- a/apps/webapp/app/services/apiRateLimit.server.ts +++ b/apps/webapp/app/services/apiRateLimit.server.ts @@ -63,6 +63,13 @@ export const apiRateLimiter = authorizationRateLimitMiddleware({ /^\/api\/v1\/runs\/[^\/]+\/attempts$/, // /api/v1/runs/$runFriendlyId/attempts /^\/api\/v1\/waitpoints\/tokens\/[^\/]+\/callback\/[^\/]+$/, // /api/v1/waitpoints/tokens/$waitpointFriendlyId/callback/$hash /^\/api\/v\d+\/deployments/, // /api/v{1,2,3,n}/deployments/* + // Internal SDK plumbing — packets are presigned-URL handshakes for + // payload uploads (v2 PUT) and downloads (v1 GET), authenticated via + // run-scoped JWT, called once per task/turn boundary by the runtime. + // Same shape as `/api/v1/runs/$runFriendlyId/attempts` above; not a + // customer-facing surface so customer rate limits shouldn't apply. + /^\/api\/v1\/packets\//, + /^\/api\/v2\/packets\//, ], log: { rejections: env.API_RATE_LIMIT_REJECTION_LOGS_ENABLED === "1", diff --git a/apps/webapp/app/services/realtime/mintRunToken.server.ts b/apps/webapp/app/services/realtime/mintRunToken.server.ts new file mode 100644 index 00000000000..2cdc4316e66 --- /dev/null +++ b/apps/webapp/app/services/realtime/mintRunToken.server.ts @@ -0,0 +1,41 @@ +import { generateJWT as internal_generateJWT } from "@trigger.dev/core/v3"; +import { extractJwtSigningSecretKey } from "./jwtAuth.server"; + +type Environment = Parameters[0]; + +export type MintRunTokenOptions = { + /** Include the input-stream write scope (needed for steering messages from the playground). */ + includeInputStreamWrite?: boolean; + /** Token expiration. Defaults to "1h". */ + expirationTime?: string; +}; + +/** + * Mint a run-scoped public access token (JWT) for browser subscription to a + * run's realtime streams. + * + * Used by: + * - The playground action to give a freshly triggered chat session a token. + * - The run details page to let the agent view subscribe to the chat stream + * of an existing run (read-only). + */ +export async function mintRunToken( + environment: Environment, + runFriendlyId: string, + options: MintRunTokenOptions = {} +): Promise { + const scopes = [`read:runs:${runFriendlyId}`]; + if (options.includeInputStreamWrite) { + scopes.push(`write:inputStreams:${runFriendlyId}`); + } + + return internal_generateJWT({ + secretKey: extractJwtSigningSecretKey(environment), + payload: { + sub: environment.id, + pub: true, + scopes, + }, + expirationTime: options.expirationTime ?? "1h", + }); +} diff --git a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts index 0295d5a58b6..4c735d21d46 100644 --- a/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts +++ b/apps/webapp/app/services/realtime/s2realtimeStreams.server.ts @@ -441,8 +441,16 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { // ---------- Internals: S2 REST ---------- private async s2Append(stream: string, body: S2AppendInput): Promise { - // POST /v1/streams/{stream}/records (JSON) - const res = await fetch(`${this.baseUrl}/streams/${encodeURIComponent(stream)}/records`, { + // POST /v1/streams/{stream}/records (JSON). + // + // Retries transient failures (network errors and 5xx) up to 3 times with + // exponential backoff. Undici's "fetch failed" errors observed locally + // are pre-connection (DNS/TCP) so the request never reaches S2, making + // retry safe — the alternative is a 500 surfacing to the SDK transport, + // which then retries the whole `/in/append` round-trip and pollutes + // logs. 4xx are not retried (genuine client errors). + const url = `${this.baseUrl}/streams/${encodeURIComponent(stream)}/records`; + const init: RequestInit = { method: "POST", headers: { Authorization: `Bearer ${this.token}`, @@ -451,12 +459,60 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { "S2-Basin": this.basin, }, body: JSON.stringify(body), - }); - if (!res.ok) { - const text = await res.text().catch(() => ""); - throw new Error(`S2 append failed: ${res.status} ${res.statusText} ${text}`); + }; + + const maxAttempts = 3; + const backoffsMs = [100, 250, 600]; + let lastError: unknown; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + // The `try` only wraps `fetch` — once we have a Response we handle status + // outside the catch, so a 4xx throw can't be swallowed and retried. + let res: Response | undefined; + try { + res = await fetch(url, init); + } catch (err) { + lastError = err; + } + + if (res) { + if (res.ok) { + return (await res.json()) as S2AppendAck; + } + const text = await res.text().catch(() => ""); + const httpError = new Error( + `S2 append failed: ${res.status} ${res.statusText} ${text}` + ); + if (res.status >= 400 && res.status < 500) { + // 4xx — caller-side problem (auth, malformed body, closed stream). + // Retrying won't help. + throw httpError; + } + // 5xx — retryable. + lastError = httpError; + } + + const isLastAttempt = attempt === maxAttempts - 1; + const diagnostics = describeFetchError(lastError); + if (isLastAttempt) { + this.logger.error("S2 append failed after retries", { + stream, + attempts: maxAttempts, + ...diagnostics, + }); + break; + } + + this.logger.warn("S2 append transient failure, retrying", { + stream, + attempt: attempt + 1, + nextDelayMs: backoffsMs[attempt], + ...diagnostics, + }); + await new Promise((resolve) => setTimeout(resolve, backoffsMs[attempt])); } - return (await res.json()) as S2AppendAck; + + throw lastError instanceof Error ? lastError : new Error(String(lastError)); } private async getS2AccessToken(id: string): Promise { @@ -560,3 +616,40 @@ export class S2RealtimeStreams implements StreamResponder, StreamIngestor { return Number.isFinite(n) && n >= 0 ? n + 1 : undefined; } } + +// Pulls the underlying network error out of undici's generic "fetch failed". +// undici sets `error.cause` to either a SystemError-shaped object with `code` +// (e.g. `ECONNRESET`, `UND_ERR_SOCKET`, `ETIMEDOUT`), `errno`, and `syscall`, +// or — for happy-eyeballs / multi-address connect attempts — an +// `AggregateError` whose `errors[]` each carry their own code. Surfacing +// those tells us whether failures are pre-connection (DNS / TCP), mid-stream +// socket resets, or genuine S2 server errors. +function describeFetchError(err: unknown): Record { + if (!(err instanceof Error)) { + return { error: String(err) }; + } + const out: Record = { + error: err.message, + name: err.name, + }; + const cause = (err as { cause?: unknown }).cause; + if (cause && typeof cause === "object") { + const c = cause as Record; + if (typeof c.code === "string") out.causeCode = c.code; + if (typeof c.errno === "number" || typeof c.errno === "string") out.causeErrno = c.errno; + if (typeof c.syscall === "string") out.causeSyscall = c.syscall; + if (typeof c.message === "string") out.causeMessage = c.message; + if (Array.isArray(c.errors)) { + out.causeErrors = c.errors + .filter((e: unknown): e is Error => e instanceof Error) + .map((e) => ({ + message: e.message, + code: (e as { code?: unknown }).code, + syscall: (e as { syscall?: unknown }).syscall, + address: (e as { address?: unknown }).address, + port: (e as { port?: unknown }).port, + })); + } + } + return out; +} diff --git a/apps/webapp/app/services/realtime/sessionRunManager.server.ts b/apps/webapp/app/services/realtime/sessionRunManager.server.ts index 58513460b14..95fc87e2018 100644 --- a/apps/webapp/app/services/realtime/sessionRunManager.server.ts +++ b/apps/webapp/app/services/realtime/sessionRunManager.server.ts @@ -40,10 +40,22 @@ type EnsureRunForSessionParams = { /** * Session row to operate on. Caller is responsible for the env match — * we don't re-check `runtimeEnvironmentId` against `environment.id`. + * + * `friendlyId` is used to pre-populate `payload.sessionId` on the new + * run so the agent's `chat.agent` boot path can attach to `session.in/.out` + * without a control-plane round-trip. `currentRunId` is also forwarded + * as `payload.previousRunId` (with `continuation: true`) when the prior + * run is dead, so the agent's boot gate triggers snapshot.read + replay + * instead of treating the run as a fresh chat. */ session: Pick< Session, - "id" | "taskIdentifier" | "triggerConfig" | "currentRunId" | "currentRunVersion" + | "id" + | "friendlyId" + | "taskIdentifier" + | "triggerConfig" + | "currentRunId" + | "currentRunVersion" >; environment: AuthenticatedEnvironment; reason: EnsureRunReason; @@ -97,20 +109,78 @@ export async function ensureRunForSession( } // 1. Probe currentRunId. + let priorDeadRunFriendlyId: string | undefined; if (session.currentRunId) { - const status = await getRunStatus(session.currentRunId); - if (status && !isFinalRunStatus(status)) { + const probe = await getRunStatusAndFriendlyId(session.currentRunId); + if (probe && !isFinalRunStatus(probe.status)) { return { runId: session.currentRunId, triggered: false }; } + // Either the row vanished (probe null) or its status is final. Either + // way the prior run isn't going to consume new appends — but the + // session may still hold conversation state on `session.out` and an + // S3 snapshot keyed on `session.friendlyId`. Forward the prior run's + // public-form id (friendlyId — same shape as `ctx.run.id`) to the + // agent as `previousRunId` so its boot gate flips + // `couldHavePriorState` and replays the persisted state instead of + // treating this as a fresh chat. See `chat.agent`'s boot orchestration + // in `packages/trigger-sdk/src/v3/ai.ts`. + if (probe?.friendlyId) { + priorDeadRunFriendlyId = probe.friendlyId; + } else { + // Replica miss on a row we just observed via `currentRunId`. Retry + // on the writer so the customer's `runs.retrieve(previousRunId)` + // gets the public `run_*` form rather than the internal cuid. + const writerProbe = await prisma.taskRun.findFirst({ + where: { id: session.currentRunId }, + select: { friendlyId: true }, + }); + priorDeadRunFriendlyId = writerProbe?.friendlyId ?? session.currentRunId; + } } - // 2. Validate config + trigger upfront. + // 2. Validate config + trigger upfront. Continuation overrides + // (`continuation`, `previousRunId`) are derived from session state above + // and merged AFTER caller-supplied overrides — caller can't accidentally + // unset them on a session that has had a prior run, but can still + // override `trigger`/`metadata` etc. `sessionId` is always set so the + // agent doesn't need a control-plane round-trip to look up the session + // friendlyId from `payload.chatId`. + // Continuation overrides strip the basePayload's first-run-only fields + // so a continuation run doesn't inherit a stale boot payload. The Session + // row's `triggerConfig.basePayload` is captured at create-time and used + // verbatim for every Run we trigger; if the customer included `message` + // / `messages` / `trigger: "submit-message"` to make the FIRST run boot + // straight into a first turn (via `chat.createStartSessionAction`), those + // values stick around and get replayed on every continuation. With + // `continuation: true` and `message`/`messages` cleared, the SDK boot + // path enters its continuation-wait branch and waits for the next + // session.in record before running a turn. + const continuationOverrides: Record = { + sessionId: session.friendlyId, + ...(priorDeadRunFriendlyId !== undefined + ? { + continuation: true, + previousRunId: priorDeadRunFriendlyId, + // Clear sticky boot-payload fields so the new run waits for the + // next session.in record instead of re-processing whatever was + // in the original `createStartSessionAction({ basePayload })`. + message: undefined, + messages: undefined, + trigger: undefined, + } + : {}), + }; + const mergedPayloadOverrides: Record = { + ...(payloadOverrides ?? {}), + ...continuationOverrides, + }; + const config = SessionTriggerConfigSchema.parse(session.triggerConfig); const triggered = await triggerSessionRun({ session, config, environment, - payloadOverrides, + payloadOverrides: mergedPayloadOverrides, }); // 3. Try to claim the slot atomically. @@ -161,6 +231,7 @@ export async function ensureRunForSession( where: { id: session.id }, select: { id: true, + friendlyId: true, taskIdentifier: true, triggerConfig: true, currentRunId: true, @@ -175,8 +246,15 @@ export async function ensureRunForSession( } if (fresh.currentRunId) { - const status = await getRunStatus(fresh.currentRunId); - if (status && !isFinalRunStatus(status)) { + // Same read-after-write reason as the `fresh` reload above: the winner + // just wrote `currentRunId` on the writer, so probe the writer too — + // the replica may not have the run row yet, and a missed probe forces + // another trigger+recurse until `ENSURE_RUN_FOR_SESSION_MAX_ATTEMPTS`. + const probe = await prisma.taskRun.findFirst({ + where: { id: fresh.currentRunId }, + select: { status: true, friendlyId: true }, + }); + if (probe && !isFinalRunStatus(probe.status)) { return { runId: fresh.currentRunId, triggered: false }; } } @@ -223,6 +301,9 @@ async function triggerSessionRun(params: { ...(config.queue ? { queue: { name: config.queue } } : {}), ...(config.tags ? { tags: config.tags } : {}), ...(config.maxAttempts !== undefined ? { maxAttempts: config.maxAttempts } : {}), + ...(config.maxDuration !== undefined ? { maxDuration: config.maxDuration } : {}), + ...(config.lockToVersion ? { lockToVersion: config.lockToVersion } : {}), + ...(config.region ? { region: config.region } : {}), }, }; @@ -242,15 +323,32 @@ async function triggerSessionRun(params: { } type SwapSessionRunParams = { + /** + * Session row to swap. `friendlyId` is forwarded as `payload.sessionId` + * on the new run so the agent attaches to `session.in/.out` without a + * control-plane round-trip (same convention as + * {@link EnsureRunForSessionParams}). + */ session: Pick< Session, - "id" | "taskIdentifier" | "triggerConfig" | "currentRunId" | "currentRunVersion" + | "id" + | "friendlyId" + | "taskIdentifier" + | "triggerConfig" + | "currentRunId" + | "currentRunVersion" >; /** * The run requesting the swap. Optimistic claim requires * `Session.currentRunId === callingRunId` so the swap can't clobber * a run triggered out-of-band (e.g. a parallel `.in/append` probe * that already replaced the dead run). + * + * Also forwarded as `payload.previousRunId` on the new run alongside + * `continuation: true` — every swap is a continuation by construction + * (`chat.requestUpgrade` / `chat.endRun` deliberately hand off prior + * conversation state to a new run), so the agent's boot gate flips + * `couldHavePriorState` and replays the snapshot + session.out tail. */ callingRunId: string; environment: AuthenticatedEnvironment; @@ -285,12 +383,39 @@ export async function swapSessionRun( ): Promise { const { session, callingRunId, environment, reason, payloadOverrides } = params; + // `callingRunId` is the internal cuid (`Session.currentRunId` stores + // cuid; the route handler resolves the wire's friendlyId before passing + // it here). The agent's `previousRunId` is customer-visible and must + // match the public `run_*` form exposed via `ctx.run.id` — resolve + // before forwarding. + const callingRunFriendlyId = await resolveRunFriendlyId(callingRunId); + + // Continuation overrides — unconditionally set on swap. Unlike + // `ensureRunForSession`, there's no dead-run-detection branch here: + // every swap is a deliberate handoff from `callingRunId` (which owned + // prior conversation state) to a fresh run. Merged AFTER caller-supplied + // overrides so a caller can't accidentally unset them. + // + // Sticky boot-payload fields (`message` / `messages` / `trigger`) are + // cleared here for the same reason as in `ensureRunForSession`: the + // Session's basePayload is captured at create-time and replays on every + // continuation if not stripped. See the comment in `ensureRunForSession`. + const mergedPayloadOverrides: Record = { + ...(payloadOverrides ?? {}), + sessionId: session.friendlyId, + continuation: true, + previousRunId: callingRunFriendlyId, + message: undefined, + messages: undefined, + trigger: undefined, + }; + const config = SessionTriggerConfigSchema.parse(session.triggerConfig); const triggered = await triggerSessionRun({ session, config, environment, - payloadOverrides, + payloadOverrides: mergedPayloadOverrides, }); const claim = await prisma.session.updateMany({ @@ -341,20 +466,55 @@ export async function swapSessionRun( select: { currentRunId: true }, }); + // Mirror `ensureRunForSession`'s "session vanished" branch: if we + // can't find the row (or it has no current run) on the writer right + // after losing the race, surface as an error rather than handing back + // `callingRunId` with `swapped: false` — that would tell the caller + // it's still the canonical run when in fact we don't know who is. + if (!fresh?.currentRunId) { + throw new SessionRunManagerError( + `Session ${session.id} has no currentRunId after preempted swap` + ); + } + return { - runId: fresh?.currentRunId ?? callingRunId, + runId: fresh.currentRunId, swapped: false, }; } -async function getRunStatus(runId: string): Promise { +async function getRunStatusAndFriendlyId( + runId: string +): Promise<{ status: TaskRunStatus; friendlyId: string } | null> { // Use the read replica — this is a hot-path probe and stale-by-ms is // fine. The append handler re-checks if it ends up reusing the runId. + // `friendlyId` is fetched alongside `status` so the dead-run-detection + // branch in `ensureRunForSession` can forward the public-form id as + // `payload.previousRunId` without a second read. `Session.currentRunId` + // stores the internal cuid; the agent's wire / customer hooks expose + // the friendlyId via `ctx.run.id`, so consistency matters. + const row = await $replica.taskRun.findFirst({ + where: { id: runId }, + select: { status: true, friendlyId: true }, + }); + return row ?? null; +} + +/** + * Resolve a TaskRun cuid to its friendlyId. Used by `swapSessionRun` to + * forward the calling run's public-form id as `payload.previousRunId` on + * the new run. Falls back to the cuid on lookup miss so the swap doesn't + * fail just because the read replica hasn't caught up — the agent only + * uses `previousRunId` for customer-visible bookkeeping (e.g. + * `runs.retrieve(previousRunId)`), so a stale-but-non-null value is + * acceptable degraded behavior. + */ +async function resolveRunFriendlyId(runId: string): Promise { const row = await $replica.taskRun.findFirst({ where: { id: runId }, - select: { status: true }, + select: { friendlyId: true }, }); - return row?.status ?? null; + return row?.friendlyId ?? runId; } async function cancelLostRaceRun( diff --git a/apps/webapp/app/services/runsReplicationService.server.ts b/apps/webapp/app/services/runsReplicationService.server.ts index 7930c05481f..167564572eb 100644 --- a/apps/webapp/app/services/runsReplicationService.server.ts +++ b/apps/webapp/app/services/runsReplicationService.server.ts @@ -921,6 +921,7 @@ export class RunsReplicationService { run.maxDurationInSeconds ?? null, // max_duration_in_seconds annotations?.triggerSource ?? "", // trigger_source annotations?.rootTriggerSource ?? "", // root_trigger_source + annotations?.taskKind ?? "", // task_kind run.isWarmStart ?? null, // is_warm_start ]; } diff --git a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts index 1368279e63d..49725d2cefb 100644 --- a/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/clickhouseRunsRepository.server.ts @@ -151,6 +151,7 @@ export class ClickHouseRunsRepository implements IRunsRepository { metadataType: true, machinePreset: true, queue: true, + annotations: true, }, }); @@ -334,4 +335,22 @@ function applyRunFiltersToQueryBuilder( errorFingerprint: ErrorId.toId(options.errorId), }); } + + if (options.taskKinds && options.taskKinds.length > 0) { + const includesStandard = options.taskKinds.includes("STANDARD"); + // Include empty string when filtering for STANDARD (default value for pre-existing runs) + const effectiveKinds = includesStandard + ? [...options.taskKinds, ""] + : options.taskKinds; + + if (effectiveKinds.length === 1) { + queryBuilder.where("task_kind = {taskKind: String}", { + taskKind: effectiveKinds[0]!, + }); + } else { + queryBuilder.where("task_kind IN {taskKinds: Array(String)}", { + taskKinds: effectiveKinds, + }); + } + } } diff --git a/apps/webapp/app/services/runsRepository/runsRepository.server.ts b/apps/webapp/app/services/runsRepository/runsRepository.server.ts index c8bb6264b4e..68c9da63098 100644 --- a/apps/webapp/app/services/runsRepository/runsRepository.server.ts +++ b/apps/webapp/app/services/runsRepository/runsRepository.server.ts @@ -42,6 +42,7 @@ const RunListInputOptionsSchema = z.object({ queues: z.array(z.string()).optional(), machines: MachinePresetName.array().optional(), errorId: z.string().optional(), + taskKinds: z.array(z.string()).optional(), }); export type RunListInputOptions = z.infer; @@ -53,6 +54,7 @@ export type RunListInputFilters = Omit< export type ParsedRunFilters = RunListInputFilters & { cursor?: string; direction?: "forward" | "backward"; + sources?: string[]; }; export type FilterRunsOptions = Omit & { @@ -102,6 +104,7 @@ export type ListedRun = Prisma.TaskRunGetPayload<{ metadataType: true; machinePreset: true; queue: true; + annotations: true; }; }>; diff --git a/apps/webapp/app/services/sessionsRepository/clickhouseSessionsRepository.server.ts b/apps/webapp/app/services/sessionsRepository/clickhouseSessionsRepository.server.ts index c810a0dfa1e..aebf61628fa 100644 --- a/apps/webapp/app/services/sessionsRepository/clickhouseSessionsRepository.server.ts +++ b/apps/webapp/app/services/sessionsRepository/clickhouseSessionsRepository.server.ts @@ -101,6 +101,7 @@ export class ClickHouseSessionsRepository implements ISessionsRepository { createdAt: true, updatedAt: true, runtimeEnvironmentId: true, + currentRunId: true, }, }); diff --git a/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts b/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts index 15566295e33..245f1df2295 100644 --- a/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts +++ b/apps/webapp/app/services/sessionsRepository/sessionsRepository.server.ts @@ -95,6 +95,7 @@ export type ListedSession = Prisma.SessionGetPayload<{ createdAt: true; updatedAt: true; runtimeEnvironmentId: true; + currentRunId: true; }; }>; diff --git a/apps/webapp/app/utils/pathBuilder.ts b/apps/webapp/app/utils/pathBuilder.ts index 8f94b302ef7..0ebae151d0e 100644 --- a/apps/webapp/app/utils/pathBuilder.ts +++ b/apps/webapp/app/utils/pathBuilder.ts @@ -318,6 +318,31 @@ export function v3TestTaskPath( )}`; } +export function v3PlaygroundPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/playground`; +} + +export function v3PlaygroundAgentPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + agentSlug: string +) { + return `${v3PlaygroundPath(organization, project, environment)}/${encodeURIComponent(agentSlug)}`; +} + +export function v3AgentsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/agents`; +} + export function v3RunsPath( organization: OrgForPath, project: ProjectForPath, @@ -486,6 +511,23 @@ export function v3BatchesPath( return `${v3EnvironmentPath(organization, project, environment)}/batches`; } +export function v3SessionsPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath +) { + return `${v3EnvironmentPath(organization, project, environment)}/sessions`; +} + +export function v3SessionPath( + organization: OrgForPath, + project: ProjectForPath, + environment: EnvironmentForPath, + session: { friendlyId: string } +) { + return `${v3SessionsPath(organization, project, environment)}/${session.friendlyId}`; +} + export function v3BatchPath( organization: OrgForPath, project: ProjectForPath, diff --git a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts index f1bcb8e3699..da79e386afb 100644 --- a/apps/webapp/app/v3/services/createBackgroundWorker.server.ts +++ b/apps/webapp/app/v3/services/createBackgroundWorker.server.ts @@ -324,6 +324,13 @@ async function createWorkerTask( ); } + const resolvedTriggerSource = + task.triggerSource === "schedule" + ? ("SCHEDULED" as const) + : task.triggerSource === "agent" + ? ("AGENT" as const) + : ("STANDARD" as const); + await prisma.backgroundWorkerTask.create({ data: { friendlyId: generateFriendlyId("task"), @@ -337,7 +344,8 @@ async function createWorkerTask( retryConfig: task.retry, queueConfig: task.queue, machineConfig: task.machine, - triggerSource: task.triggerSource === "schedule" ? "SCHEDULED" : "STANDARD", + triggerSource: resolvedTriggerSource, + config: task.agentConfig ? (task.agentConfig as any) : undefined, fileId: tasksToBackgroundFiles?.get(task.id) ?? null, maxDurationInSeconds: task.maxDuration ? clampMaxDuration(task.maxDuration) : null, ttl: diff --git a/internal-packages/clickhouse/schema/029_add_task_kind_to_task_runs_v2.sql b/internal-packages/clickhouse/schema/029_add_task_kind_to_task_runs_v2.sql new file mode 100644 index 00000000000..a88a7a46cef --- /dev/null +++ b/internal-packages/clickhouse/schema/029_add_task_kind_to_task_runs_v2.sql @@ -0,0 +1,7 @@ +-- +goose Up +ALTER TABLE trigger_dev.task_runs_v2 + ADD COLUMN task_kind LowCardinality(String) DEFAULT ''; + +-- +goose Down +ALTER TABLE trigger_dev.task_runs_v2 + DROP COLUMN task_kind; diff --git a/internal-packages/clickhouse/src/taskRuns.test.ts b/internal-packages/clickhouse/src/taskRuns.test.ts index 8bd403f14f0..2d35ab0d420 100644 --- a/internal-packages/clickhouse/src/taskRuns.test.ts +++ b/internal-packages/clickhouse/src/taskRuns.test.ts @@ -84,6 +84,7 @@ describe("Task Runs V2", () => { null, // max_duration_in_seconds "", // trigger_source "", // root_trigger_source + "", // task_kind null, // is_warm_start ]; @@ -215,6 +216,7 @@ describe("Task Runs V2", () => { null, // max_duration_in_seconds "", // trigger_source "", // root_trigger_source + "", // task_kind null, // is_warm_start ]; @@ -269,6 +271,7 @@ describe("Task Runs V2", () => { null, // max_duration_in_seconds "", // trigger_source "", // root_trigger_source + "", // task_kind null, // is_warm_start ]; @@ -370,6 +373,7 @@ describe("Task Runs V2", () => { null, // max_duration_in_seconds "", // trigger_source "", // root_trigger_source + "", // task_kind null, // is_warm_start ]; diff --git a/internal-packages/clickhouse/src/taskRuns.ts b/internal-packages/clickhouse/src/taskRuns.ts index 6a9f66d7844..f6427359772 100644 --- a/internal-packages/clickhouse/src/taskRuns.ts +++ b/internal-packages/clickhouse/src/taskRuns.ts @@ -51,6 +51,7 @@ export const TaskRunV2 = z.object({ max_duration_in_seconds: z.number().int().nullish(), trigger_source: z.string().default(""), root_trigger_source: z.string().default(""), + task_kind: z.string().default(""), is_warm_start: z.boolean().nullish(), _version: z.string(), _is_deleted: z.number().int().default(0), @@ -110,6 +111,7 @@ export const TASK_RUN_COLUMNS = [ "max_duration_in_seconds", "trigger_source", "root_trigger_source", + "task_kind", "is_warm_start", ] as const; @@ -176,6 +178,7 @@ export type TaskRunFieldTypes = { max_duration_in_seconds: number | null; trigger_source: string; root_trigger_source: string; + task_kind: string; is_warm_start: boolean | null; }; @@ -313,6 +316,7 @@ export type TaskRunInsertArray = [ max_duration_in_seconds: number | null, trigger_source: string, root_trigger_source: string, + task_kind: string, is_warm_start: boolean | null, ]; diff --git a/internal-packages/database/prisma/migrations/20260329100903_add_agent_trigger_source_and_task_config/migration.sql b/internal-packages/database/prisma/migrations/20260329100903_add_agent_trigger_source_and_task_config/migration.sql new file mode 100644 index 00000000000..29233ab2740 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260329100903_add_agent_trigger_source_and_task_config/migration.sql @@ -0,0 +1,5 @@ +-- AlterEnum +ALTER TYPE "public"."TaskTriggerSource" ADD VALUE 'AGENT'; + +-- AlterTable +ALTER TABLE "public"."BackgroundWorkerTask" ADD COLUMN "config" JSONB; diff --git a/internal-packages/database/prisma/migrations/20260330113734_add_playground_conversation/migration.sql b/internal-packages/database/prisma/migrations/20260330113734_add_playground_conversation/migration.sql new file mode 100644 index 00000000000..7d061a51395 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260330113734_add_playground_conversation/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "public"."PlaygroundConversation" ( + "id" TEXT NOT NULL, + "chatId" TEXT NOT NULL, + "title" TEXT NOT NULL DEFAULT 'New conversation', + "agentSlug" TEXT NOT NULL, + "runId" TEXT, + "clientData" JSONB, + "projectId" TEXT NOT NULL, + "runtimeEnvironmentId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PlaygroundConversation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "PlaygroundConversation_runtimeEnvironmentId_agentSlug_updat_idx" ON "public"."PlaygroundConversation"("runtimeEnvironmentId", "agentSlug", "updatedAt" DESC); + +-- CreateIndex +CREATE INDEX "PlaygroundConversation_userId_runtimeEnvironmentId_idx" ON "public"."PlaygroundConversation"("userId", "runtimeEnvironmentId"); + +-- CreateIndex +CREATE UNIQUE INDEX "PlaygroundConversation_chatId_runtimeEnvironmentId_key" ON "public"."PlaygroundConversation"("chatId", "runtimeEnvironmentId"); + +-- AddForeignKey +ALTER TABLE "public"."PlaygroundConversation" ADD CONSTRAINT "PlaygroundConversation_runId_fkey" FOREIGN KEY ("runId") REFERENCES "public"."TaskRun"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."PlaygroundConversation" ADD CONSTRAINT "PlaygroundConversation_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "public"."Project"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."PlaygroundConversation" ADD CONSTRAINT "PlaygroundConversation_runtimeEnvironmentId_fkey" FOREIGN KEY ("runtimeEnvironmentId") REFERENCES "public"."RuntimeEnvironment"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/internal-packages/database/prisma/migrations/20260330135232_add_messages_and_last_event_id_to_playground/migration.sql b/internal-packages/database/prisma/migrations/20260330135232_add_messages_and_last_event_id_to_playground/migration.sql new file mode 100644 index 00000000000..0793d411c38 --- /dev/null +++ b/internal-packages/database/prisma/migrations/20260330135232_add_messages_and_last_event_id_to_playground/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "public"."PlaygroundConversation" ADD COLUMN "lastEventId" TEXT, +ADD COLUMN "messages" JSONB; diff --git a/internal-packages/database/prisma/schema.prisma b/internal-packages/database/prisma/schema.prisma index f588bdfc453..7e32a96d805 100644 --- a/internal-packages/database/prisma/schema.prisma +++ b/internal-packages/database/prisma/schema.prisma @@ -386,7 +386,8 @@ model RuntimeEnvironment { waitpointTags WaitpointTag[] BulkActionGroup BulkActionGroup[] customerQueries CustomerQuery[] - prompts Prompt[] + prompts Prompt[] + playgroundConversations PlaygroundConversation[] errorGroupStates ErrorGroupState[] taskIdentifiers TaskIdentifier[] revokedApiKeys RevokedApiKey[] @@ -470,6 +471,7 @@ model Project { connectedGithubRepository ConnectedGithubRepository? organizationProjectIntegration OrganizationProjectIntegration[] customerQueries CustomerQuery[] + playgroundConversations PlaygroundConversation[] buildSettings Json? onboardingData Json? @@ -706,6 +708,10 @@ model BackgroundWorkerTask { triggerSource TaskTriggerSource @default(STANDARD) + /// Extra task configuration JSON. Shape depends on triggerSource. + /// AGENT: { type: "ai-sdk-chat" } + config Json? + payloadSchema Json? @@unique([workerId, slug]) @@ -718,6 +724,49 @@ model BackgroundWorkerTask { enum TaskTriggerSource { STANDARD SCHEDULED + AGENT +} + +model PlaygroundConversation { + id String @id @default(cuid()) + + /// The chat session ID used by the transport + chatId String + + /// User-editable conversation title (auto-generated from first message) + title String @default("New conversation") + + /// Which agent this conversation is with + agentSlug String + + /// The current active run backing this conversation (null if no run yet) + runId String? + run TaskRun? @relation(fields: [runId], references: [id], onDelete: SetNull, onUpdate: Cascade) + + /// The client data JSON used for this conversation + clientData Json? + + /// Accumulated UIMessages from completed turns (for resume without stream replay) + messages Json? + + /// Last SSE event ID — resume from this position to avoid replaying old turns + lastEventId String? + + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade, onUpdate: Cascade) + projectId String + + runtimeEnvironment RuntimeEnvironment @relation(fields: [runtimeEnvironmentId], references: [id], onDelete: Cascade, onUpdate: Cascade) + runtimeEnvironmentId String + + /// The user who started this conversation + userId String + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([chatId, runtimeEnvironmentId]) + @@index([runtimeEnvironmentId, agentSlug, updatedAt(sort: Desc)]) + @@index([userId, runtimeEnvironmentId]) } /// Durable, typed, bidirectional I/O primitive. Owns two S2 streams (.out / .in). @@ -1021,6 +1070,8 @@ model TaskRun { /// (OSS, or pre-backfill); reads fall back to the global basin. streamBasinName String? + playgroundConversations PlaygroundConversation[] + @@unique([oneTimeUseToken]) @@unique([runtimeEnvironmentId, taskIdentifier, idempotencyKey]) // Finding child runs diff --git a/packages/core/src/v3/apiClient/errors.ts b/packages/core/src/v3/apiClient/errors.ts index 14f69b31302..5f38a4947b8 100644 --- a/packages/core/src/v3/apiClient/errors.ts +++ b/packages/core/src/v3/apiClient/errors.ts @@ -128,6 +128,18 @@ export class PermissionDeniedError extends ApiError { override readonly status: 403 = 403; } +/** + * True when `error` is a 401/403 from the Trigger API (e.g. expired run-scoped PAT on realtime streams). + * Uses structural checks so it works even if multiple copies of `@trigger.dev/core` are bundled (subclass `instanceof` can fail). + */ +export function isTriggerRealtimeAuthError(error: unknown): boolean { + if (error === null || typeof error !== "object") { + return false; + } + const e = error as ApiError; + return e.name === "TriggerApiError" && (e.status === 401 || e.status === 403); +} + export class NotFoundError extends ApiError { override readonly status: 404 = 404; } diff --git a/packages/core/src/v3/apiClient/index.ts b/packages/core/src/v3/apiClient/index.ts index 89a551954f0..04a9009e356 100644 --- a/packages/core/src/v3/apiClient/index.ts +++ b/packages/core/src/v3/apiClient/index.ts @@ -6,19 +6,32 @@ import { ApiDeploymentListOptions, ApiDeploymentListResponseItem, ApiDeploymentListSearchParams, + RetrieveCurrentDeploymentResponseBody, AppendToStreamResponseBody, BatchItemNDJSON, BatchTaskRunExecutionResult, BatchTriggerTaskV3RequestBody, BatchTriggerTaskV3Response, CanceledRunResponse, + CloseSessionRequestBody, CompleteWaitpointTokenRequestBody, CompleteWaitpointTokenResponseBody, + CreatedSessionResponseBody, + CreateSessionRequestBody, + EndAndContinueSessionRequestBody, + EndAndContinueSessionResponseBody, + ListSessionsOptions, + ListSessionsResponseBody, + ListedSessionItem, + RetrieveSessionResponseBody, + UpdateSessionRequestBody, CreateBatchRequestBody, CreateBatchResponse, CreateEnvironmentVariableRequestBody, CreateInputStreamWaitpointRequestBody, CreateInputStreamWaitpointResponseBody, + CreateSessionStreamWaitpointRequestBody, + CreateSessionStreamWaitpointResponseBody, CreateScheduleOptions, CreateStreamResponseBody, CreateUploadPayloadUrlResponseBody, @@ -59,6 +72,7 @@ import { SendInputStreamResponseBody, StreamBatchItemsResponse, TaskRunExecutionResult, + ReadSessionStreamRecordsResponseBody, TriggerTaskRequestBody, TriggerTaskResponse, UpdateEnvironmentVariableRequestBody, @@ -1094,6 +1108,233 @@ export class ApiClient { ); } + // ======================================================================== + // Sessions + // ======================================================================== + + createSession(body: CreateSessionRequestBody, requestOptions?: ZodFetchOptions) { + return zodfetch( + CreatedSessionResponseBody, + `${this.baseUrl}/api/v1/sessions`, + { + method: "POST", + headers: this.#getHeaders(false), + body: JSON.stringify(body), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + + retrieveSession(sessionIdOrExternalId: string, requestOptions?: ZodFetchOptions) { + return zodfetch( + RetrieveSessionResponseBody, + `${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}`, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + + updateSession( + sessionIdOrExternalId: string, + body: UpdateSessionRequestBody, + requestOptions?: ZodFetchOptions + ) { + return zodfetch( + RetrieveSessionResponseBody, + `${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}`, + { + method: "PATCH", + headers: this.#getHeaders(false), + body: JSON.stringify(body), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + + closeSession( + sessionIdOrExternalId: string, + body?: CloseSessionRequestBody, + requestOptions?: ZodFetchOptions + ) { + return zodfetch( + RetrieveSessionResponseBody, + `${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/close`, + { + method: "POST", + headers: this.#getHeaders(false), + body: JSON.stringify(body ?? {}), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + + endAndContinueSession( + sessionIdOrExternalId: string, + body: EndAndContinueSessionRequestBody, + requestOptions?: ZodFetchOptions + ) { + return zodfetch( + EndAndContinueSessionResponseBody, + `${this.baseUrl}/api/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/end-and-continue`, + { + method: "POST", + headers: this.#getHeaders(false), + body: JSON.stringify(body), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + + listSessions( + options?: ListSessionsOptions, + requestOptions?: ZodFetchOptions + ): CursorPagePromise { + const searchParams = createSearchQueryForListSessions(options); + + return zodfetchCursorPage( + ListedSessionItem, + `${this.baseUrl}/api/v1/sessions`, + { + query: searchParams, + limit: options?.limit, + after: options?.after, + before: options?.before, + }, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + + // ======================================================================== + // Session realtime channels + // ======================================================================== + + async initializeSessionStream( + sessionIdOrExternalId: string, + io: "out" | "in", + requestOptions?: ZodFetchOptions + ) { + // The server returns S2 credentials in response headers alongside a tiny + // JSON body with the realtime version. Follow the same shape as + // `createStream` so downstream clients can feed them into + // `StreamsWriterV2`. + return zodfetch( + CreateStreamResponseBody, + `${this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/${io}`, + { + method: "PUT", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ) + .withResponse() + .then(({ data, response }) => ({ + ...data, + headers: Object.fromEntries(response.headers.entries()), + })); + } + + async appendToSessionStream( + sessionIdOrExternalId: string, + io: "out" | "in", + part: TBody, + requestOptions?: ZodFetchOptions + ) { + return zodfetch( + AppendToStreamResponseBody, + `${this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/${io}/append`, + { + method: "POST", + headers: this.#getHeaders(false), + body: part, + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + + /** + * Non-SSE drain of a Session channel's tail. Returns whatever records + * exist after `afterEventId` (or from the head of the stream) and closes + * — `wait=0` semantics, no long-poll. Used by `replaySessionOutTail` at + * run boot, where the SSE long-poll's ~1s tax on empty streams is the + * dominant cost on every fresh chat. + * + * `afterEventId` is the same cursor format as the SSE Last-Event-ID + * (the S2 sequence number, stringified) — pass `lastOutEventId` from a + * persisted snapshot to resume. + */ + async readSessionStreamRecords( + sessionIdOrExternalId: string, + io: "out" | "in", + options?: { afterEventId?: string; baseUrl?: string } + ) { + const qs = new URLSearchParams(); + if (options?.afterEventId !== undefined) { + qs.set("afterEventId", options.afterEventId); + } + const url = `${options?.baseUrl ?? this.baseUrl}/realtime/v1/sessions/${encodeURIComponent( + sessionIdOrExternalId + )}/${io}/records${qs.toString() ? `?${qs.toString()}` : ""}`; + return zodfetch( + ReadSessionStreamRecordsResponseBody, + url, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, undefined) + ); + } + + /** + * Subscribe to SSE records on a Session channel. Reuses the same + * {@link SSEStreamSubscription} plumbing as `readStream` for run-scoped + * realtime streams — auto-retry, Last-Event-ID resume, abort-on-cancel. + */ + async subscribeToSessionStream( + sessionIdOrExternalId: string, + io: "out" | "in", + options?: { + signal?: AbortSignal; + baseUrl?: string; + timeoutInSeconds?: number; + onComplete?: () => void; + onError?: (error: Error) => void; + lastEventId?: string; + onPart?: (part: SSEStreamPart) => void; + } + ): Promise> { + const url = `${options?.baseUrl ?? this.baseUrl}/realtime/v1/sessions/${encodeURIComponent(sessionIdOrExternalId)}/${io}`; + + const subscription = new SSEStreamSubscription(url, { + headers: this.getHeaders(), + signal: options?.signal, + onComplete: options?.onComplete, + onError: options?.onError, + timeoutInSeconds: options?.timeoutInSeconds, + lastEventId: options?.lastEventId, + }); + + const stream = await subscription.subscribe(); + const onPart = options?.onPart; + + return stream.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + const data = chunk.chunk as T; + onPart?.(chunk as SSEStreamPart); + controller.enqueue(data); + }, + }) + ); + } + async waitForDuration( runId: string, body: WaitForDurationRequestBody, @@ -1340,6 +1581,18 @@ export class ApiClient { ); } + retrieveCurrentDeployment(requestOptions?: ZodFetchOptions) { + return zodfetch( + RetrieveCurrentDeploymentResponseBody, + `${this.baseUrl}/api/v1/deployments/current`, + { + method: "GET", + headers: this.#getHeaders(false), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + async fetchStream( runId: string, streamKey: string, @@ -1459,6 +1712,23 @@ export class ApiClient { ); } + async createSessionStreamWaitpoint( + runFriendlyId: string, + body: CreateSessionStreamWaitpointRequestBody, + requestOptions?: ZodFetchOptions + ) { + return zodfetch( + CreateSessionStreamWaitpointResponseBody, + `${this.baseUrl}/api/v1/runs/${runFriendlyId}/session-streams/wait`, + { + method: "POST", + headers: this.#getHeaders(false), + body: JSON.stringify(body), + }, + mergeRequestOptions(this.defaultRequestOptions, requestOptions) + ); + } + async generateJWTClaims(requestOptions?: ZodFetchOptions): Promise> { return zodfetch( z.record(z.any()), @@ -1823,6 +2093,47 @@ function queueNameFromQueueTypeName(queue: QueueTypeName): string { return queue.name; } +function createSearchQueryForListSessions(options?: ListSessionsOptions): URLSearchParams { + const searchParams = new URLSearchParams(); + + if (!options) return searchParams; + + const appendMany = (name: string, value: string | string[] | undefined) => { + if (value === undefined) return; + searchParams.append(name, Array.isArray(value) ? value.join(",") : value); + }; + + appendMany("filter[type]", options.type); + appendMany("filter[tags]", options.tag); + appendMany("filter[taskIdentifier]", options.taskIdentifier); + + if (options.externalId) { + searchParams.append("filter[externalId]", options.externalId); + } + + appendMany("filter[status]", options.status as string | string[] | undefined); + + if (options.period) { + searchParams.append("filter[createdAt][period]", options.period); + } + + if (options.from !== undefined) { + searchParams.append( + "filter[createdAt][from]", + options.from instanceof Date ? options.from.getTime().toString() : options.from.toString() + ); + } + + if (options.to !== undefined) { + searchParams.append( + "filter[createdAt][to]", + options.to instanceof Date ? options.to.getTime().toString() : options.to.toString() + ); + } + + return searchParams; +} + function createSearchQueryForListWaitpointTokens( query?: ListWaitpointTokensQueryParams ): URLSearchParams { diff --git a/packages/core/src/v3/apiClient/runStream.test.ts b/packages/core/src/v3/apiClient/runStream.test.ts new file mode 100644 index 00000000000..a91e70c6e56 --- /dev/null +++ b/packages/core/src/v3/apiClient/runStream.test.ts @@ -0,0 +1,444 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SSEStreamSubscription } from "./runStream.js"; + +vi.setConfig({ testTimeout: 10_000 }); + +describe("SSEStreamSubscription retry behavior", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + // A response.body that emits one SSE event then closes, so each + // successful subscribe() exits cleanly via reader.read() done=true + // and the test doesn't hang reading from a long-lived stream. + function makeSSEResponse() { + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`id: 1\ndata: {"hello":1}\n\n`)); + controller.close(); + }, + }); + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v1" }, + }); + } + + // Drain a ReadableStream until it closes or errors. + // Returns received chunks plus terminal state. + async function drain(stream: ReadableStream<{ id: string; chunk: unknown }>) { + const reader = stream.getReader(); + const chunks: Array<{ id: string; chunk: unknown }> = []; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return { chunks, error: undefined as Error | undefined }; + chunks.push(value); + } + } catch (e) { + return { chunks, error: e as Error }; + } finally { + try { + reader.releaseLock(); + } catch { + /* already released */ + } + } + } + + it("retries past the legacy 5-attempt cap", async () => { + let attempts = 0; + globalThis.fetch = vi.fn().mockImplementation(async () => { + attempts++; + if (attempts < 8) { + throw new TypeError("fetch failed (simulated network drop)"); + } + return makeSSEResponse(); + }); + + const sub = new SSEStreamSubscription("http://example.test/sse", { + // Compress the timing for the test — defaults are 100ms initial, + // 5s cap, retry forever; here we want fast iteration. + retryDelayMs: 1, + maxRetryDelayMs: 5, + }); + + const stream = await sub.subscribe(); + const result = await drain(stream); + + expect(attempts).toBe(8); + expect(result.error).toBeUndefined(); + expect(result.chunks).toHaveLength(1); + }); + + it("caps the exponential backoff at maxRetryDelayMs", async () => { + let attempts = 0; + const callTimes: number[] = []; + globalThis.fetch = vi.fn().mockImplementation(async () => { + callTimes.push(Date.now()); + attempts++; + if (attempts < 6) { + throw new TypeError("fetch failed"); + } + return makeSSEResponse(); + }); + + const sub = new SSEStreamSubscription("http://example.test/sse", { + retryDelayMs: 10, + maxRetryDelayMs: 30, + }); + + const stream = await sub.subscribe(); + await drain(stream); + + expect(attempts).toBe(6); + + // Without the cap, backoff would be 10, 20, 40, 80, 160 (= 310ms total). + // With cap=30, it's 10, 20, 30, 30, 30 (= 120ms total). Allow generous + // slack for setTimeout jitter; the assertion is "well under uncapped". + const totalElapsed = callTimes.at(-1)! - callTimes[0]!; + expect(totalElapsed).toBeLessThan(250); + }); + + it("retryNow() wakes an in-flight backoff and reconnects immediately", async () => { + let attempts = 0; + globalThis.fetch = vi.fn().mockImplementation(async () => { + attempts++; + if (attempts === 1) { + throw new TypeError("fetch failed"); + } + return makeSSEResponse(); + }); + + const sub = new SSEStreamSubscription("http://example.test/sse", { + // Backoff is intentionally long. retryNow() should short-circuit it. + retryDelayMs: 5_000, + maxRetryDelayMs: 5_000, + }); + + const subscribePromise = sub.subscribe().then(drain); + + // Wait for the first attempt to fail and the backoff to start. + await new Promise((r) => setTimeout(r, 50)); + sub.retryNow(); + + const start = Date.now(); + const result = await subscribePromise; + const elapsed = Date.now() - start; + + expect(attempts).toBe(2); + expect(result.error).toBeUndefined(); + // Without retryNow this would have waited ~5000ms; with it, the + // second attempt fires nearly immediately after the first failure. + expect(elapsed).toBeLessThan(500); + }); + + it("respects abort signal during retry backoff", async () => { + let attempts = 0; + globalThis.fetch = vi.fn().mockImplementation(async () => { + attempts++; + throw new TypeError("fetch failed"); + }); + + const ac = new AbortController(); + const sub = new SSEStreamSubscription("http://example.test/sse", { + signal: ac.signal, + retryDelayMs: 1_000, + maxRetryDelayMs: 1_000, + }); + + const subscribePromise = sub.subscribe().then(drain); + + // Let the first attempt fail and enter backoff, then abort. + await new Promise((r) => setTimeout(r, 50)); + ac.abort(); + + const result = await subscribePromise; + expect(result.error).toBeUndefined(); + // Abort should stop retries; we should have made at most a couple + // of attempts before the abort took effect. + expect(attempts).toBeLessThanOrEqual(2); + }); + + it("forceReconnect mid-read drops the stream and resumes with Last-Event-ID", async () => { + let attempts = 0; + const seenLastEventIds: Array = []; + globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + attempts++; + const lastEventIdHeader = (init?.headers as Record | undefined)?.[ + "Last-Event-ID" + ]; + seenLastEventIds.push(lastEventIdHeader ?? null); + + if (attempts === 1) { + // Headers arrive immediately, body emits one chunk then hangs + // until aborted. The test calls forceReconnect after seeing + // the chunk, which should drop this stream and trigger a + // resume request with Last-Event-ID set. + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`id: 7\ndata: {"first":true}\n\n`)); + init?.signal?.addEventListener("abort", () => controller.error(new Error("aborted"))); + }, + }); + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v1" }, + }); + } + // Second attempt: emit a second chunk and close cleanly. + const body = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(`id: 8\ndata: {"second":true}\n\n`)); + controller.close(); + }, + }); + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v1" }, + }); + }); + + const sub = new SSEStreamSubscription("http://example.test/sse", { + retryDelayMs: 1, + maxRetryDelayMs: 5, + fetchTimeoutMs: 60_000, + }); + + const stream = await sub.subscribe(); + const reader = stream.getReader(); + + // Read the first chunk, then force-reconnect mid-stream. + const first = await reader.read(); + expect(first.done).toBe(false); + expect((first.value!.chunk as { first?: boolean }).first).toBe(true); + + sub.forceReconnect(); + + // Second chunk arrives from the resumed connection. + const second = await reader.read(); + expect(second.done).toBe(false); + expect((second.value!.chunk as { second?: boolean }).second).toBe(true); + + const tail = await reader.read(); + expect(tail.done).toBe(true); + + expect(attempts).toBe(2); + expect(seenLastEventIds[0]).toBeNull(); + // Resumed request includes the Last-Event-ID from the first chunk. + expect(seenLastEventIds[1]).toBe("7"); + }); + + it("forceReconnect aborts the in-flight fetch and retries", async () => { + let attempts = 0; + let firstResolve: (() => void) | undefined; + globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + attempts++; + if (attempts === 1) { + // Hang the first attempt forever (or until signal aborts). + // forceReconnect should make this attempt's signal abort and + // throw, taking us into the retry path. + return new Promise((resolve, reject) => { + firstResolve = () => resolve(makeSSEResponse()); + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("aborted", "AbortError")); + }); + }); + } + return makeSSEResponse(); + }); + + const sub = new SSEStreamSubscription("http://example.test/sse", { + retryDelayMs: 1, + maxRetryDelayMs: 5, + // Long fetch timeout so it doesn't fire instead of forceReconnect. + fetchTimeoutMs: 60_000, + }); + + const subscribePromise = sub.subscribe().then(drain); + + // Let the first fetch hang, then force reconnect. + await new Promise((r) => setTimeout(r, 50)); + sub.forceReconnect(); + + const result = await subscribePromise; + expect(attempts).toBe(2); + expect(result.error).toBeUndefined(); + expect(result.chunks).toHaveLength(1); + // Sanity: the hung first fetch was abandoned, never resolved. + expect(firstResolve).toBeDefined(); + }); + + it("aborts a slow fetch via fetchTimeoutMs and retries", async () => { + let attempts = 0; + globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + attempts++; + if (attempts === 1) { + // Hang until aborted. + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + reject(new DOMException("aborted", "AbortError")); + }); + }); + } + return makeSSEResponse(); + }); + + const sub = new SSEStreamSubscription("http://example.test/sse", { + retryDelayMs: 1, + maxRetryDelayMs: 5, + fetchTimeoutMs: 100, + }); + + const result = await sub.subscribe().then(drain); + expect(attempts).toBe(2); + expect(result.error).toBeUndefined(); + expect(result.chunks).toHaveLength(1); + }); + + it("aborts a silent reader via stallTimeoutMs and retries", async () => { + let attempts = 0; + globalThis.fetch = vi.fn().mockImplementation(async (_url: string, init?: RequestInit) => { + attempts++; + if (attempts === 1) { + // Headers arrive immediately, but the body stream emits no + // chunks until aborted. The stall timer should fire and + // force a reconnect. + const body = new ReadableStream({ + start(controller) { + init?.signal?.addEventListener("abort", () => controller.error(new Error("aborted"))); + }, + }); + return new Response(body, { + status: 200, + headers: { "Content-Type": "text/event-stream", "X-Stream-Version": "v1" }, + }); + } + return makeSSEResponse(); + }); + + const sub = new SSEStreamSubscription("http://example.test/sse", { + retryDelayMs: 1, + maxRetryDelayMs: 5, + stallTimeoutMs: 100, + }); + + const result = await sub.subscribe().then(drain); + expect(attempts).toBe(2); + expect(result.error).toBeUndefined(); + expect(result.chunks).toHaveLength(1); + }); + + it("does not retry on 404 (stream gone)", async () => { + let attempts = 0; + globalThis.fetch = vi.fn().mockImplementation(async () => { + attempts++; + return new Response("not found", { status: 404 }); + }); + + const errors: Error[] = []; + const sub = new SSEStreamSubscription("http://example.test/sse", { + retryDelayMs: 1, + maxRetryDelayMs: 5, + onError: (e) => errors.push(e), + }); + + const result = await sub.subscribe().then(drain); + expect(attempts).toBe(1); + expect(result.error).toBeDefined(); + expect(errors).toHaveLength(1); + }); + + it("does not retry on 410 (session closed)", async () => { + let attempts = 0; + globalThis.fetch = vi.fn().mockImplementation(async () => { + attempts++; + return new Response("gone", { status: 410 }); + }); + + const sub = new SSEStreamSubscription("http://example.test/sse", { + retryDelayMs: 1, + maxRetryDelayMs: 5, + }); + + const result = await sub.subscribe().then(drain); + expect(attempts).toBe(1); + expect(result.error).toBeDefined(); + }); + + it("respects custom nonRetryableStatuses", async () => { + let attempts = 0; + globalThis.fetch = vi.fn().mockImplementation(async () => { + attempts++; + return new Response("forbidden", { status: 403 }); + }); + + const sub = new SSEStreamSubscription("http://example.test/sse", { + retryDelayMs: 1, + maxRetryDelayMs: 5, + nonRetryableStatuses: [403], + }); + + const result = await sub.subscribe().then(drain); + expect(attempts).toBe(1); + expect(result.error).toBeDefined(); + }); + + it("retries on 503 (caller-tunable nonRetryableStatuses)", async () => { + let attempts = 0; + globalThis.fetch = vi.fn().mockImplementation(async () => { + attempts++; + if (attempts < 3) return new Response("unavailable", { status: 503 }); + return makeSSEResponse(); + }); + + const sub = new SSEStreamSubscription("http://example.test/sse", { + retryDelayMs: 1, + maxRetryDelayMs: 5, + // 503 is NOT in the default non-retryable set; it should retry. + }); + + const result = await sub.subscribe().then(drain); + expect(attempts).toBe(3); + expect(result.error).toBeUndefined(); + expect(result.chunks).toHaveLength(1); + }); + + it("applies jitter to backoff (delays vary across attempts)", async () => { + const callTimes: number[] = []; + globalThis.fetch = vi.fn().mockImplementation(async () => { + callTimes.push(performance.now()); + throw new TypeError("fetch failed"); + }); + + const ac = new AbortController(); + const sub = new SSEStreamSubscription("http://example.test/sse", { + signal: ac.signal, + retryDelayMs: 50, + maxRetryDelayMs: 50, + retryJitter: 0.5, // 50% — final delay in [25ms, 50ms] + }); + + const promise = sub.subscribe().then(drain); + await new Promise((r) => setTimeout(r, 600)); // allow ~10 attempts + ac.abort(); + await promise; + + expect(callTimes.length).toBeGreaterThanOrEqual(5); + + // Compute inter-attempt gaps (skip the first since it has no prior). + const gaps = callTimes.slice(1).map((t, i) => t - callTimes[i]!); + // Without jitter all gaps would be ~50ms. With 50% jitter they + // should land in [~25ms, ~50ms] and not all be identical. + const min = Math.min(...gaps); + const max = Math.max(...gaps); + expect(min).toBeGreaterThanOrEqual(20); // a little slack for timer scheduling + expect(max).toBeLessThanOrEqual(80); + // Variance check — at least one gap should differ from another by + // a measurable amount (rules out a deterministic-delay regression). + expect(max - min).toBeGreaterThan(2); + }); +}); diff --git a/packages/core/src/v3/apiClient/runStream.ts b/packages/core/src/v3/apiClient/runStream.ts index 520ecd8dc2b..2152c6c69ca 100644 --- a/packages/core/src/v3/apiClient/runStream.ts +++ b/packages/core/src/v3/apiClient/runStream.ts @@ -14,7 +14,7 @@ import { IOPacket, parsePacket, } from "../utils/ioSerialization.js"; -import { ApiError } from "./errors.js"; +import { ApiError, isTriggerRealtimeAuthError } from "./errors.js"; import { ApiClient } from "./index.js"; import { zodShapeStream } from "./stream.js"; @@ -182,8 +182,15 @@ export type SSEStreamPart = { export class SSEStreamSubscription implements StreamSubscription { private lastEventId: string | undefined; private retryCount = 0; - private maxRetries = 5; - private retryDelayMs = 1000; + private maxRetries: number; + private retryDelayMs: number; + private maxRetryDelayMs: number; + private retryJitter: number; + private fetchTimeoutMs: number; + private stallTimeoutMs: number; + private nonRetryableStatuses: ReadonlySet; + private retryNowController: AbortController | null = null; + private internalAbort: AbortController | null = null; constructor( private url: string, @@ -194,9 +201,69 @@ export class SSEStreamSubscription implements StreamSubscription { onError?: (error: Error) => void; timeoutInSeconds?: number; lastEventId?: string; + // Retry knobs. Defaults: retry forever, 100ms initial backoff, + // capped at 5s with 50% jitter. Keeps mobile clients reconnecting + // through transient drops without giving up after a fixed window + // and prevents thundering-herd when many clients reconnect after + // a brief server blip. + maxRetries?: number; + retryDelayMs?: number; + maxRetryDelayMs?: number; + retryJitter?: number; + // Per-attempt fetch timeout — aborts the connect attempt if + // response headers don't arrive in time. Catches stuck TCP + // sockets where `fetch()` blocks forever waiting on a dead + // server. Cleared once headers arrive; long-lived chunk reads + // are governed by `stallTimeoutMs` instead. + fetchTimeoutMs?: number; + // Stall detector — if no chunks arrive within this window after + // the connection is established, force a reconnect. Catches + // silent-dead-socket cases (mobile OS killed the TCP socket but + // the read just blocks). Disabled (`0`) by default; opt in + // explicitly. Servers that emit periodic keepalive comments + // reset the timer naturally. + stallTimeoutMs?: number; + // HTTP statuses that should NOT be retried — fail the stream + // permanently. `404` (stream gone) and `410` (session closed) + // are sensible defaults; tune per-caller for other 4xx. + nonRetryableStatuses?: readonly number[]; } ) { this.lastEventId = options.lastEventId; + this.maxRetries = options.maxRetries ?? Infinity; + this.retryDelayMs = options.retryDelayMs ?? 100; + this.maxRetryDelayMs = options.maxRetryDelayMs ?? 5000; + this.retryJitter = options.retryJitter ?? 0.5; + this.fetchTimeoutMs = options.fetchTimeoutMs ?? 30_000; + this.stallTimeoutMs = options.stallTimeoutMs ?? 0; + this.nonRetryableStatuses = new Set(options.nonRetryableStatuses ?? [404, 410]); + } + + /** + * Wake an in-flight retry backoff and reconnect immediately. + * + * No-op if no retry is currently waiting (i.e. we're already + * connected and reading). Use this for cheap "hint" wakeups like + * the `online` event or a short-hidden visibility return — + * `forceReconnect()` is the heavier hammer. + */ + retryNow(): void { + this.retryNowController?.abort(); + } + + /** + * Drop the current connection (or wake a pending backoff) and + * reconnect. + * + * Use when the existing TCP socket is suspected dead but the reader + * hasn't noticed yet — common after a mobile tab background-kill or + * a Safari bfcache restore. Aborts the in-flight fetch / read so + * the catch path takes us through `retryConnection` and re-fetches + * with `Last-Event-ID`. + */ + forceReconnect(): void { + this.internalAbort?.abort(); + this.retryNowController?.abort(); } async subscribe(): Promise> { @@ -206,7 +273,7 @@ export class SSEStreamSubscription implements StreamSubscription { async start(controller) { await self.connectStream(controller); }, - cancel(reason) { + cancel() { self.options.onComplete?.(); }, }); @@ -215,25 +282,51 @@ export class SSEStreamSubscription implements StreamSubscription { private async connectStream( controller: ReadableStreamDefaultController ): Promise { + // Two abort sources flow through `internalAbort.signal`: + // - this.options.signal: caller cancel — bypass retry, exit cleanly. + // - this.internalAbort: per-attempt force-reconnect / fetch-timeout + // / stall-timeout — treated as a transient error, retry path runs. + // Use `this.options.signal?.aborted` in the catch to distinguish. + this.internalAbort = new AbortController(); + const unlinkUserAbort = linkAbort(this.options.signal, this.internalAbort); + + // Per-attempt fetch timeout. Cleared once response headers arrive; + // chunk-read latency is governed by `stallTimeoutMs` instead. + const fetchTimer = setTimeout(() => this.internalAbort?.abort(), this.fetchTimeoutMs); + + let stallTimer: ReturnType | undefined; + const armStall = () => { + if (this.stallTimeoutMs <= 0) return; + clearTimeout(stallTimer); + stallTimer = setTimeout(() => this.internalAbort?.abort(), this.stallTimeoutMs); + }; + + // Idempotent — both the catch (before recursion) and the finally + // call this. Without the catch-side call, every retry leaks an + // abort listener on `this.options.signal` because the finally + // doesn't run until the entire recursion unwinds. + const cleanupAttempt = () => { + clearTimeout(fetchTimer); + clearTimeout(stallTimer); + unlinkUserAbort(); + this.internalAbort = null; + }; + try { const headers: Record = { Accept: "text/event-stream", ...this.options.headers, }; - - // Include Last-Event-ID header if we're resuming - if (this.lastEventId) { - headers["Last-Event-ID"] = this.lastEventId; - } - + if (this.lastEventId) headers["Last-Event-ID"] = this.lastEventId; if (this.options.timeoutInSeconds) { headers["Timeout-Seconds"] = this.options.timeoutInSeconds.toString(); } const response = await fetch(this.url, { headers, - signal: this.options.signal, + signal: this.internalAbort.signal, }); + clearTimeout(fetchTimer); if (!response.ok) { const error = ApiError.generate( @@ -242,22 +335,23 @@ export class SSEStreamSubscription implements StreamSubscription { "Could not subscribe to stream", Object.fromEntries(response.headers) ); - this.options.onError?.(error); + if (this.nonRetryableStatuses.has(response.status)) { + controller.error(error); + return; + } throw error; } if (!response.body) { const error = new Error("No response body"); - this.options.onError?.(error); throw error; } const streamVersion = response.headers.get("X-Stream-Version") ?? "v1"; - - // Reset retry count on successful connection - this.retryCount = 0; + this.retryCount = 0; // reset on success + armStall(); const seenIds = new Set(); @@ -268,13 +362,10 @@ export class SSEStreamSubscription implements StreamSubscription { new TransformStream({ transform: (chunk, chunkController) => { if (streamVersion === "v1") { - // Track the last event ID for resume support if (chunk.id) { this.lastEventId = chunk.id; } - const timestamp = parseRedisStreamIdTimestamp(chunk.id); - chunkController.enqueue({ id: chunk.id ?? "unknown", chunk: safeParseJSON(chunk.data), @@ -288,13 +379,9 @@ export class SSEStreamSubscription implements StreamSubscription { for (const record of data.records) { this.lastEventId = record.seq_num.toString(); - const parsedBody = safeParseJSON(record.body) as { data: unknown; id: string }; - if (seenIds.has(parsedBody.id)) { - continue; - } + if (seenIds.has(parsedBody.id)) continue; seenIds.add(parsedBody.id); - chunkController.enqueue({ id: record.seq_num.toString(), chunk: parsedBody.data, @@ -310,7 +397,6 @@ export class SSEStreamSubscription implements StreamSubscription { const reader = stream.getReader(); try { - let chunkCount = 0; while (true) { const { done, value } = await reader.read(); @@ -329,7 +415,7 @@ export class SSEStreamSubscription implements StreamSubscription { return; } - chunkCount++; + armStall(); // any chunk (including server keepalives) resets the silence timer controller.enqueue(value); } } catch (error) { @@ -338,14 +424,24 @@ export class SSEStreamSubscription implements StreamSubscription { } } catch (error) { if (this.options.signal?.aborted) { - // Don't retry if aborted + // User cancel — exit cleanly, don't retry. controller.close(); this.options.onComplete?.(); return; } - // Retry on error + if (isTriggerRealtimeAuthError(error)) { + // `onError` was already invoked in the `!response.ok` branch above + // (where the auth ApiError was originally constructed and thrown). + // Auth errors are non-retryable: terminate the stream cleanly. + controller.error(error as Error); + return; + } + + cleanupAttempt(); await this.retryConnection(controller, error as Error); + } finally { + cleanupAttempt(); } } @@ -367,10 +463,33 @@ export class SSEStreamSubscription implements StreamSubscription { } this.retryCount++; - const delay = this.retryDelayMs * Math.pow(2, this.retryCount - 1); - - // Wait before retrying - await new Promise((resolve) => setTimeout(resolve, delay)); + const baseDelay = Math.min( + this.retryDelayMs * Math.pow(2, this.retryCount - 1), + this.maxRetryDelayMs + ); + // Jitter scales the delay into [(1 - retryJitter) * base, base]. + // E.g. retryJitter=0.5 → final delay is in [50%, 100%] of base. + // Spreads simultaneous reconnect attempts so many clients don't + // dogpile on the server right after a brief outage. + const delay = baseDelay * (1 - this.retryJitter * Math.random()); + + // Wait before retrying. The wait is wakeable: `retryNow()` aborts + // `retryNowController` so the timer resolves immediately and the + // next connect attempt starts now (e.g. on tab focus / `online` + // event from the browser layer). + this.retryNowController = new AbortController(); + await new Promise((resolve) => { + const timer = setTimeout(() => { + this.retryNowController?.signal.removeEventListener("abort", onAbort); + resolve(); + }, delay); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + this.retryNowController!.signal.addEventListener("abort", onAbort, { once: true }); + }); + this.retryNowController = null; if (this.options.signal?.aborted) { controller.close(); @@ -383,6 +502,22 @@ export class SSEStreamSubscription implements StreamSubscription { } } +/** + * One-way abort link: when `parent` aborts, abort `child` too. Returns + * a cleanup that removes the listener so `parent` doesn't accumulate + * subscriptions across many connect attempts. + */ +function linkAbort(parent: AbortSignal | undefined, child: AbortController): () => void { + if (!parent) return () => {}; + if (parent.aborted) { + child.abort(); + return () => {}; + } + const onAbort = () => child.abort(); + parent.addEventListener("abort", onAbort, { once: true }); + return () => parent.removeEventListener("abort", onAbort); +} + export class SSEStreamSubscriptionFactory implements StreamSubscriptionFactory { constructor( private baseUrl: string, diff --git a/packages/core/src/v3/errors.ts b/packages/core/src/v3/errors.ts index 90650bbd18f..d32e32f91c9 100644 --- a/packages/core/src/v3/errors.ts +++ b/packages/core/src/v3/errors.ts @@ -631,6 +631,26 @@ export class GracefulExitTimeoutError extends Error { } } +export class ChatChunkTooLargeError extends Error { + constructor( + public readonly chunkSize: number, + public readonly maxSize: number, + public readonly chunkType?: string + ) { + super( + `chat.agent chunk${chunkType ? ` of type "${chunkType}"` : ""} is ${chunkSize} bytes, ` + + `over the realtime stream's per-record cap of ${maxSize} bytes. ` + + `For oversized payloads (e.g. large tool outputs), write the value to your own store and ` + + `emit only an id/url through the chat stream — see https://trigger.dev/docs/ai-chat/patterns/large-payloads.` + ); + this.name = "ChatChunkTooLargeError"; + } +} + +export function isChatChunkTooLargeError(error: unknown): error is ChatChunkTooLargeError { + return error instanceof Error && error.name === "ChatChunkTooLargeError"; +} + export class MaxDurationExceededError extends Error { constructor( public readonly maxDurationInSeconds: number, diff --git a/packages/core/src/v3/index.ts b/packages/core/src/v3/index.ts index 2757363f4be..72b91c46071 100644 --- a/packages/core/src/v3/index.ts +++ b/packages/core/src/v3/index.ts @@ -21,6 +21,7 @@ export * from "./locals-api.js"; export * from "./heartbeats-api.js"; export * from "./realtime-streams-api.js"; export * from "./input-streams-api.js"; +export * from "./session-streams-api.js"; export * from "./waitpoints/index.js"; export * from "./schemas/index.js"; export { SemanticInternalAttributes } from "./semanticInternalAttributes.js"; @@ -80,6 +81,7 @@ export { getSchemaParseFn, type AnySchemaParseFn, type SchemaParseFn, + type inferSchemaOut, isSchemaZodEsque, isSchemaValibotEsque, isSchemaArkTypeEsque, diff --git a/packages/core/src/v3/inputStreams/index.ts b/packages/core/src/v3/inputStreams/index.ts index 4a871d6bfcc..0b3c7af063f 100644 --- a/packages/core/src/v3/inputStreams/index.ts +++ b/packages/core/src/v3/inputStreams/index.ts @@ -51,6 +51,18 @@ export class InputStreamsAPI implements InputStreamManager { return this.#getManager().lastSeqNum(streamId); } + public setLastSeqNum(streamId: string, seqNum: number): void { + this.#getManager().setLastSeqNum(streamId, seqNum); + } + + public shiftBuffer(streamId: string): boolean { + return this.#getManager().shiftBuffer(streamId); + } + + public disconnectStream(streamId: string): void { + this.#getManager().disconnectStream(streamId); + } + public clearHandlers(): void { this.#getManager().clearHandlers(); } diff --git a/packages/core/src/v3/inputStreams/manager.ts b/packages/core/src/v3/inputStreams/manager.ts index f393f4a169a..51424df39f7 100644 --- a/packages/core/src/v3/inputStreams/manager.ts +++ b/packages/core/src/v3/inputStreams/manager.ts @@ -6,6 +6,7 @@ import { InputStreamTimeoutError, } from "./types.js"; import { InputStreamOnceOptions } from "../realtimeStreams/types.js"; +import { computeReconnectDelayMs } from "../utils/reconnectBackoff.js"; type InputStreamHandler = (data: unknown) => void | Promise; @@ -13,6 +14,13 @@ type OnceWaiter = { resolve: (result: InputStreamOnceResult) => void; reject: (error: Error) => void; timeoutHandle?: ReturnType; + // The abort signal and its handler are tracked on the waiter so any + // resolution path (dispatch / timeout / explicit removal) can detach + // the listener. Without this, a long-lived `AbortSignal` reused across + // many `once()` calls accumulates listeners — `{ once: true }` only + // self-clears if the signal actually aborts. + signal?: AbortSignal; + abortHandler?: () => void; }; @@ -29,6 +37,19 @@ export class StandardInputStreamManager implements InputStreamManager { private seqNums = new Map(); private currentRunId: string | null = null; private streamsVersion: string | undefined; + // Reconnect attempt counter per streamId. Drives the exponential + // backoff applied by `#ensureStreamTailConnected`'s `.finally` so a + // persistent backend failure (auth rejection, 5xx, DNS, etc.) doesn't + // reconnect in a tight loop. Reset to 0 by `#dispatch` whenever a + // record flows through. + private reconnectAttempts = new Map(); + // Stream IDs that were explicitly torn down by `disconnectStream`. The + // tail's `.finally` reconnect path consults this set so a deliberate + // teardown isn't immediately undone by the auto-reconnect when + // handlers or once-waiters are still registered. Cleared on the next + // explicit `on()` / `once()` (those are the only legitimate reasons to + // bring the tail back up). + private explicitlyDisconnected = new Set(); constructor( private apiClient: ApiClient, @@ -40,6 +61,26 @@ export class StandardInputStreamManager implements InputStreamManager { return this.seqNums.get(streamId); } + setLastSeqNum(streamId: string, seqNum: number): void { + const current = this.seqNums.get(streamId); + // Only advance forward, never backward + if (current === undefined || seqNum > current) { + this.seqNums.set(streamId, seqNum); + } + } + + shiftBuffer(streamId: string): boolean { + const buffered = this.buffer.get(streamId); + if (buffered && buffered.length > 0) { + buffered.shift(); + if (buffered.length === 0) { + this.buffer.delete(streamId); + } + return true; + } + return false; + } + setRunId(runId: string, streamsVersion?: string): void { this.currentRunId = runId; this.streamsVersion = streamsVersion; @@ -48,6 +89,10 @@ export class StandardInputStreamManager implements InputStreamManager { on(streamId: string, handler: InputStreamHandler): { off: () => void } { this.#requireV2Streams(); + // A fresh attach is a legitimate reason to bring the tail back up; + // clear any prior explicit-disconnect flag. + this.explicitlyDisconnected.delete(streamId); + let handlerSet = this.handlers.get(streamId); if (!handlerSet) { handlerSet = new Set(); @@ -80,6 +125,10 @@ export class StandardInputStreamManager implements InputStreamManager { once(streamId: string, options?: InputStreamOnceOptions): InputStreamOncePromise { this.#requireV2Streams(); + // A fresh waiter is a legitimate reason to bring the tail back up; + // clear any prior explicit-disconnect flag. + this.explicitlyDisconnected.delete(streamId); + // Lazily connect a tail for this stream this.#ensureStreamTailConnected(streamId); @@ -104,17 +153,16 @@ export class StandardInputStreamManager implements InputStreamManager { reject(new Error("Aborted")); return; } - options.signal.addEventListener( - "abort", - () => { - if (waiter.timeoutHandle) { - clearTimeout(waiter.timeoutHandle); - } - this.#removeOnceWaiter(streamId, waiter); - reject(new Error("Aborted")); - }, - { once: true } - ); + const abortHandler = () => { + if (waiter.timeoutHandle) { + clearTimeout(waiter.timeoutHandle); + } + this.#removeOnceWaiter(streamId, waiter); + reject(new Error("Aborted")); + }; + waiter.signal = options.signal; + waiter.abortHandler = abortHandler; + options.signal.addEventListener("abort", abortHandler, { once: true }); } // Handle timeout — resolve with error result instead of rejecting @@ -158,10 +206,40 @@ export class StandardInputStreamManager implements InputStreamManager { } } + disconnectStream(streamId: string): void { + // Mark as explicitly disconnected BEFORE we abort, so the tail's + // `.finally` reconnect path sees the flag when it runs (which can be + // synchronous in the AbortError catch). Without this, an in-flight + // `.on(...)` or pending `.once()` would immediately resurrect the + // tail and negate the disconnect — defeating the + // "drop-the-duplicate before .wait() suspends" contract. Cleared on + // the next explicit `on()` / `once()`. + this.explicitlyDisconnected.add(streamId); + const tail = this.tails.get(streamId); + if (tail) { + tail.abortController.abort(); + this.tails.delete(streamId); + } + this.buffer.delete(streamId); + // Reset the backoff counter so a future re-attach starts fresh — + // an explicit disconnect is a deliberate teardown, not evidence of + // a broken backend. + this.reconnectAttempts.delete(streamId); + } + connectTail(runId: string, _fromSeq?: number): void { // No-op: tails are now created per-stream lazily } + /** + * Tear down all active tails. Does NOT clear handlers or `onceWaiters`, + * so any registered listener will trigger an auto-reconnect (with + * backoff) the moment it sees no live tail — by design, so a transient + * network blip recovers without the caller re-subscribing. Use + * `reset()` if you want a full clean state with no resurrection, or + * `disconnectStream(streamId)` for a single stream that should stay + * down until a fresh `on()` / `once()` attaches. + */ disconnect(): void { for (const [, tail] of this.tails) { tail.abortController.abort(); @@ -175,6 +253,8 @@ export class StandardInputStreamManager implements InputStreamManager { this.streamsVersion = undefined; this.seqNums.clear(); this.handlers.clear(); + this.reconnectAttempts.clear(); + this.explicitlyDisconnected.clear(); // Reject all pending once waiters for (const [, waiters] of this.onceWaiters) { @@ -182,6 +262,9 @@ export class StandardInputStreamManager implements InputStreamManager { if (waiter.timeoutHandle) { clearTimeout(waiter.timeoutHandle); } + if (waiter.signal && waiter.abortHandler) { + waiter.signal.removeEventListener("abort", waiter.abortHandler); + } waiter.reject(new Error("Input stream manager reset")); } } @@ -209,13 +292,37 @@ export class StandardInputStreamManager implements InputStreamManager { .finally(() => { this.tails.delete(streamId); - // Auto-reconnect if there are still active handlers or waiters + // If the tail was torn down explicitly via `disconnectStream`, + // don't auto-reconnect — that's the whole point of the + // disconnect call. The next `on()` / `once()` clears the flag. + if (this.explicitlyDisconnected.has(streamId)) { + return; + } + + // Auto-reconnect with exponential backoff if there are still + // active handlers or waiters. Without backoff a persistent + // failure (auth rejected, 5xx, DNS) would reconnect in a tight + // loop because `#runTail`'s error path only logs. `#dispatch` + // resets the counter on every successful record. const hasHandlers = this.handlers.has(streamId) && this.handlers.get(streamId)!.size > 0; const hasWaiters = this.onceWaiters.has(streamId) && this.onceWaiters.get(streamId)!.length > 0; if (hasHandlers || hasWaiters) { - this.#ensureStreamTailConnected(streamId); + const attempt = this.reconnectAttempts.get(streamId) ?? 0; + this.reconnectAttempts.set(streamId, attempt + 1); + const delayMs = computeReconnectDelayMs(attempt); + setTimeout(() => { + if (this.explicitlyDisconnected.has(streamId)) return; + if (this.tails.has(streamId)) return; + const stillHasHandlers = + this.handlers.has(streamId) && this.handlers.get(streamId)!.size > 0; + const stillHasWaiters = + this.onceWaiters.has(streamId) && + this.onceWaiters.get(streamId)!.length > 0; + if (!stillHasHandlers && !stillHasWaiters) return; + this.#ensureStreamTailConnected(streamId); + }, delayMs); } }); this.tails.set(streamId, { abortController, promise }); @@ -281,6 +388,10 @@ export class StandardInputStreamManager implements InputStreamManager { } #dispatch(streamId: string, data: unknown): void { + // Any record flowing through = healthy connection; reset the backoff + // counter so the next disconnect starts fresh. + this.reconnectAttempts.delete(streamId); + // First try to resolve a once waiter const waiters = this.onceWaiters.get(streamId); if (waiters && waiters.length > 0) { @@ -291,6 +402,9 @@ export class StandardInputStreamManager implements InputStreamManager { if (waiter.timeoutHandle) { clearTimeout(waiter.timeoutHandle); } + if (waiter.signal && waiter.abortHandler) { + waiter.signal.removeEventListener("abort", waiter.abortHandler); + } waiter.resolve({ ok: true, output: data }); // Also invoke persistent handlers this.#invokeHandlers(streamId, data); @@ -340,6 +454,13 @@ export class StandardInputStreamManager implements InputStreamManager { } #removeOnceWaiter(streamId: string, waiter: OnceWaiter): void { + // Centralized cleanup — both timeout and explicit abort paths funnel + // through here, so detach the abort listener once instead of at every + // callsite. The dispatch path doesn't go through this method (the + // waiter is shifted off inline), so it detaches the listener there. + if (waiter.signal && waiter.abortHandler) { + waiter.signal.removeEventListener("abort", waiter.abortHandler); + } const waiters = this.onceWaiters.get(streamId); if (!waiters) return; const index = waiters.indexOf(waiter); diff --git a/packages/core/src/v3/inputStreams/noopManager.ts b/packages/core/src/v3/inputStreams/noopManager.ts index 6d72d9e2f76..612da832d7e 100644 --- a/packages/core/src/v3/inputStreams/noopManager.ts +++ b/packages/core/src/v3/inputStreams/noopManager.ts @@ -22,6 +22,12 @@ export class NoopInputStreamManager implements InputStreamManager { return undefined; } + setLastSeqNum(_streamId: string, _seqNum: number): void {} + + shiftBuffer(_streamId: string): boolean { return false; } + + disconnectStream(_streamId: string): void {} + clearHandlers(): void {} reset(): void {} disconnect(): void {} diff --git a/packages/core/src/v3/inputStreams/types.ts b/packages/core/src/v3/inputStreams/types.ts index 0816c06493f..c456bb61216 100644 --- a/packages/core/src/v3/inputStreams/types.ts +++ b/packages/core/src/v3/inputStreams/types.ts @@ -70,6 +70,28 @@ export interface InputStreamManager { */ lastSeqNum(streamId: string): number | undefined; + /** + * Advance the last-seen S2 sequence number for the given input stream. + * Used after `.wait()` resumes to prevent the SSE tail from replaying + * the record that was consumed via the waitpoint path. + */ + setLastSeqNum(streamId: string, seqNum: number): void; + + /** + * Remove and discard the first buffered item for the given input stream. + * Used after `.wait()` resumes to remove the duplicate that the SSE tail + * buffered while the waitpoint was being completed via a separate path. + * Returns true if an item was removed, false if the buffer was empty. + */ + shiftBuffer(streamId: string): boolean; + + /** + * Disconnect the SSE tail and clear the buffer for a specific input stream. + * Used before suspending via `.wait()` so the tail doesn't buffer duplicates + * of data that will be delivered through the waitpoint path. + */ + disconnectStream(streamId: string): void; + /** * Clear all persistent `.on()` handlers and abort tails that have no remaining once waiters. * Called automatically when a task run completes. diff --git a/packages/core/src/v3/realtime-streams-api.ts b/packages/core/src/v3/realtime-streams-api.ts index 0bc0665c052..e873413e2c3 100644 --- a/packages/core/src/v3/realtime-streams-api.ts +++ b/packages/core/src/v3/realtime-streams-api.ts @@ -5,3 +5,5 @@ import { RealtimeStreamsAPI } from "./realtimeStreams/index.js"; export const realtimeStreams = RealtimeStreamsAPI.getInstance(); export * from "./realtimeStreams/types.js"; +export { SessionStreamInstance } from "./realtimeStreams/sessionStreamInstance.js"; +export type { SessionStreamInstanceOptions } from "./realtimeStreams/sessionStreamInstance.js"; diff --git a/packages/core/src/v3/realtimeStreams/index.ts b/packages/core/src/v3/realtimeStreams/index.ts index 2a35b38befd..80c44f5a3db 100644 --- a/packages/core/src/v3/realtimeStreams/index.ts +++ b/packages/core/src/v3/realtimeStreams/index.ts @@ -6,6 +6,12 @@ import { RealtimeStreamsManager, } from "./types.js"; +// Re-export the session-scoped stream instance so the SDK's +// `SessionOutputChannel.pipe` / `.writer` can construct it without reaching +// into the core package's internals. +export { SessionStreamInstance } from "./sessionStreamInstance.js"; +export type { SessionStreamInstanceOptions } from "./sessionStreamInstance.js"; + const API_NAME = "realtime-streams"; const NOOP_MANAGER = new NoopRealtimeStreamsManager(); diff --git a/packages/core/src/v3/realtimeStreams/manager.ts b/packages/core/src/v3/realtimeStreams/manager.ts index 323735df106..beda3535fb4 100644 --- a/packages/core/src/v3/realtimeStreams/manager.ts +++ b/packages/core/src/v3/realtimeStreams/manager.ts @@ -6,6 +6,7 @@ import { RealtimeStreamInstance, RealtimeStreamOperationOptions, RealtimeStreamsManager, + StreamWriteResult, } from "./types.js"; export class StandardRealtimeStreamsManager implements RealtimeStreamsManager { @@ -16,7 +17,7 @@ export class StandardRealtimeStreamsManager implements RealtimeStreamsManager { ) {} // Track active streams - using a Set allows multiple streams for the same key to coexist private activeStreams = new Set<{ - wait: () => Promise; + wait: () => Promise; abortController: AbortController; }>(); diff --git a/packages/core/src/v3/realtimeStreams/noopManager.ts b/packages/core/src/v3/realtimeStreams/noopManager.ts index 542e66fd53a..881a82294e2 100644 --- a/packages/core/src/v3/realtimeStreams/noopManager.ts +++ b/packages/core/src/v3/realtimeStreams/noopManager.ts @@ -15,7 +15,7 @@ export class NoopRealtimeStreamsManager implements RealtimeStreamsManager { options?: RealtimeStreamOperationOptions ): RealtimeStreamInstance { return { - wait: () => Promise.resolve(), + wait: () => Promise.resolve({}), get stream(): AsyncIterableStream { return createAsyncIterableStreamFromAsyncIterable(source); }, diff --git a/packages/core/src/v3/realtimeStreams/sessionStreamInstance.ts b/packages/core/src/v3/realtimeStreams/sessionStreamInstance.ts new file mode 100644 index 00000000000..11eb7290edc --- /dev/null +++ b/packages/core/src/v3/realtimeStreams/sessionStreamInstance.ts @@ -0,0 +1,103 @@ +import { ApiClient } from "../apiClient/index.js"; +import { AsyncIterableStream } from "../streams/asyncIterableStream.js"; +import { AnyZodFetchOptions } from "../zodfetch.js"; +import { StreamsWriterV2 } from "./streamsWriterV2.js"; +import { StreamsWriter, StreamWriteResult } from "./types.js"; + +export type SessionStreamInstanceOptions = { + apiClient: ApiClient; + baseUrl: string; + sessionId: string; + io: "out" | "in"; + source: ReadableStream; + signal?: AbortSignal; + requestOptions?: AnyZodFetchOptions; + debug?: boolean; +}; + +/** + * Session-scoped parallel to {@link StreamInstance}. Calls + * `initializeSessionStream` to fetch S2 credentials for the session's + * channel, then pipes `source` directly to S2 via {@link StreamsWriterV2}. + * + * Sessions are S2-only — there's no v1 (Redis) fallback — so this + * skips the version-detection dance `StreamInstance` does. + */ +export class SessionStreamInstance implements StreamsWriter { + private streamPromise: Promise>; + + constructor(private options: SessionStreamInstanceOptions) { + this.streamPromise = this.initializeWriter(); + } + + private async initializeWriter(): Promise> { + const response = await this.options.apiClient.initializeSessionStream( + this.options.sessionId, + this.options.io, + this.options?.requestOptions + ); + + const headers = response.headers ?? {}; + const accessToken = headers["x-s2-access-token"]; + const basin = headers["x-s2-basin"]; + const streamName = headers["x-s2-stream-name"]; + const endpoint = headers["x-s2-endpoint"]; + const flushIntervalMs = headers["x-s2-flush-interval-ms"] + ? parseInt(headers["x-s2-flush-interval-ms"]) + : undefined; + const maxRetries = headers["x-s2-max-retries"] + ? parseInt(headers["x-s2-max-retries"]) + : undefined; + + if (!accessToken || !basin || !streamName) { + throw new Error( + "Session stream initialize did not return S2 credentials — server may be configured for v1 realtime streams, which sessions do not support." + ); + } + + return new StreamsWriterV2({ + basin, + stream: streamName, + accessToken, + endpoint, + source: this.options.source, + signal: this.options.signal, + debug: this.options.debug, + flushIntervalMs, + maxRetries, + }); + } + + public async wait(): Promise { + const writer = await this.streamPromise; + return writer.wait(); + } + + public get stream(): AsyncIterableStream { + const self = this; + + return new ReadableStream({ + async start(controller) { + const streamWriter = await self.streamPromise; + + const iterator = streamWriter[Symbol.asyncIterator](); + + while (true) { + if (self.options.signal?.aborted) { + controller.close(); + break; + } + + const { done, value } = await iterator.next(); + + if (done) { + controller.close(); + break; + } + + controller.enqueue(value); + } + }, + }); + } +} diff --git a/packages/core/src/v3/realtimeStreams/streamInstance.ts b/packages/core/src/v3/realtimeStreams/streamInstance.ts index 6d8106ffe6c..07ee0158bfb 100644 --- a/packages/core/src/v3/realtimeStreams/streamInstance.ts +++ b/packages/core/src/v3/realtimeStreams/streamInstance.ts @@ -3,7 +3,7 @@ import { AsyncIterableStream } from "../streams/asyncIterableStream.js"; import { AnyZodFetchOptions } from "../zodfetch.js"; import { StreamsWriterV1 } from "./streamsWriterV1.js"; import { StreamsWriterV2 } from "./streamsWriterV2.js"; -import { StreamsWriter } from "./types.js"; +import { StreamsWriter, StreamWriteResult } from "./types.js"; export type StreamInstanceOptions = { apiClient: ApiClient; @@ -63,8 +63,9 @@ export class StreamInstance implements StreamsWriter { return streamWriter; } - public async wait(): Promise { - return this.streamPromise.then((writer) => writer.wait()); + public async wait(): Promise { + const writer = await this.streamPromise; + return writer.wait(); } public get stream(): AsyncIterableStream { diff --git a/packages/core/src/v3/realtimeStreams/streamsWriterV1.ts b/packages/core/src/v3/realtimeStreams/streamsWriterV1.ts index 2f2b4af1682..c19faf6c2f8 100644 --- a/packages/core/src/v3/realtimeStreams/streamsWriterV1.ts +++ b/packages/core/src/v3/realtimeStreams/streamsWriterV1.ts @@ -2,7 +2,7 @@ import { request as httpsRequest } from "node:https"; import { request as httpRequest } from "node:http"; import { URL } from "node:url"; import { randomBytes } from "node:crypto"; -import { StreamsWriter } from "./types.js"; +import { StreamsWriter, StreamWriteResult } from "./types.js"; export type StreamsWriterV1Options = { baseUrl: string; @@ -258,8 +258,9 @@ export class StreamsWriterV1 implements StreamsWriter { await this.makeRequest(0); } - public async wait(): Promise { - return this.streamPromise; + public async wait(): Promise { + await this.streamPromise; + return {}; } public [Symbol.asyncIterator]() { diff --git a/packages/core/src/v3/realtimeStreams/streamsWriterV2.test.ts b/packages/core/src/v3/realtimeStreams/streamsWriterV2.test.ts new file mode 100644 index 00000000000..85a03973708 --- /dev/null +++ b/packages/core/src/v3/realtimeStreams/streamsWriterV2.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; + +import { ChatChunkTooLargeError, isChatChunkTooLargeError } from "../errors.js"; +import { encodeChunkOrError } from "./streamsWriterV2.js"; + +// The size cap and discriminant extraction are the only S2-independent bits +// of `StreamsWriterV2` that benefit from unit coverage. Both live in the +// `encodeChunkOrError` pure helper, so the tests exercise it directly — no +// `vi.mock("@s2-dev/streamstore", ...)` shim needed. + +describe("encodeChunkOrError", () => { + it("flags oversize chunks and carries the chunk's `type` discriminant", () => { + const oversized = { + type: "tool-output-available", + output: { text: "x".repeat(2_000_000) }, + }; + + const result = encodeChunkOrError(oversized); + + expect(result.ok).toBe(false); + if (result.ok) return; // type guard + expect(isChatChunkTooLargeError(result.error)).toBe(true); + expect(result.error.chunkType).toBe("tool-output-available"); + expect(result.error.chunkSize).toBeGreaterThan(1_000_000); + expect(result.error.maxSize).toBe(1024 * 1024 - 1024); + expect(result.error.message).toMatch(/tool-output-available/); + expect(result.error.message).toMatch(/chat\.agent chunk/); + }); + + it("falls back to chunk.kind when chunk.type is missing (ChatInputChunk-style)", () => { + const oversized = { kind: "action", payload: "x".repeat(2_000_000) }; + + const result = encodeChunkOrError(oversized); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.chunkType).toBe("action"); + }); + + it("omits chunkType when the chunk has no discriminant", () => { + const oversized = "x".repeat(2_000_000); + + const result = encodeChunkOrError(oversized); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.chunkType).toBeUndefined(); + }); + + it("returns the encoded body for chunks under the cap", () => { + const small = { type: "text-delta", delta: "hello" }; + + const result = encodeChunkOrError(small); + + expect(result.ok).toBe(true); + if (!result.ok) return; + const parsed = JSON.parse(result.body) as { data: unknown; id: string }; + expect(parsed.data).toEqual(small); + expect(parsed.id).toMatch(/^[A-Za-z0-9_-]{7}$/); // nanoid(7) + }); +}); + +// Cross-check the ChatChunkTooLargeError type-guard helper itself. Trivial, +// but keeps the test surface here exercising the public error helpers a +// consumer would import from the same module. +describe("isChatChunkTooLargeError", () => { + it("recognizes its own error class", () => { + const err = new ChatChunkTooLargeError(2_000_000, 1024 * 1024 - 1024, "x"); + expect(isChatChunkTooLargeError(err)).toBe(true); + }); + + it("rejects unrelated errors", () => { + expect(isChatChunkTooLargeError(new Error("nope"))).toBe(false); + expect(isChatChunkTooLargeError("string")).toBe(false); + expect(isChatChunkTooLargeError(undefined)).toBe(false); + }); +}); diff --git a/packages/core/src/v3/realtimeStreams/streamsWriterV2.ts b/packages/core/src/v3/realtimeStreams/streamsWriterV2.ts index 91713630dbe..223fd8d894e 100644 --- a/packages/core/src/v3/realtimeStreams/streamsWriterV2.ts +++ b/packages/core/src/v3/realtimeStreams/streamsWriterV2.ts @@ -1,7 +1,16 @@ import { S2, AppendRecord, BatchTransform } from "@s2-dev/streamstore"; -import { StreamsWriter } from "./types.js"; +import { ChatChunkTooLargeError } from "../errors.js"; +import { StreamsWriter, StreamWriteResult } from "./types.js"; import { nanoid } from "nanoid"; +// S2 caps a single record at 1 MiB of metered bytes (body + headers + 8 byte +// overhead). We give ourselves ~1 KiB of headroom for the JSON envelope and +// metering bytes so the check fires before the SDK's internal `BatchTransform` +// rejects the record with an opaque `S2Error`. +const RECORD_BODY_MAX_BYTES = 1024 * 1024 - 1024; + +const utf8Encoder = new TextEncoder(); + export type StreamsWriterV2Options = { basin: string; stream: string; @@ -54,6 +63,7 @@ export class StreamsWriterV2 implements StreamsWriter { private readonly maxInflightBytes: number; private aborted = false; private sessionWritable: WritableStream | null = null; + private lastSeqNum: number | undefined; constructor(private options: StreamsWriterV2Options) { this.debug = options.debug ?? false; @@ -151,8 +161,12 @@ export class StreamsWriterV2 implements StreamsWriter { controller.error(new Error("Stream aborted")); return; } - // Convert each chunk to JSON string and wrap in AppendRecord - controller.enqueue(AppendRecord.string({ body: JSON.stringify({ data: chunk, id: nanoid(7) }) })); + const encoded = encodeChunkOrError(chunk); + if (!encoded.ok) { + controller.error(encoded.error); + return; + } + controller.enqueue(AppendRecord.string({ body: encoded.body })); }, }) ) @@ -169,9 +183,9 @@ export class StreamsWriterV2 implements StreamsWriter { const lastAcked = session.lastAckedPosition(); if (lastAcked?.end) { - const recordsWritten = lastAcked.end.seqNum; + this.lastSeqNum = lastAcked.end.seqNum; this.log( - `[S2MetadataStream] Written ${recordsWritten} records, ending at seqNum=${lastAcked.end.seqNum}` + `[S2MetadataStream] Written ${this.lastSeqNum} records, ending at seqNum=${this.lastSeqNum}` ); } } catch (error) { @@ -184,8 +198,9 @@ export class StreamsWriterV2 implements StreamsWriter { } } - public async wait(): Promise { + public async wait(): Promise { await this.streamPromise; + return { lastEventId: this.lastSeqNum?.toString() }; } public [Symbol.asyncIterator]() { @@ -225,3 +240,43 @@ function safeReleaseLock(reader: ReadableStreamDefaultReader) { reader.releaseLock(); } catch (error) {} } + +// chat.agent emits two chunk shapes through this writer: +// - UIMessageChunks + custom data parts: `{ type: "tool-output-available" | "data-..." | ... }` +// - ChatInputChunks (mostly seen on `.in`, but reused as the discriminant +// elsewhere): `{ kind: "message" | "stop" | "action" }` +// Surfacing whichever discriminant exists turns "chunk too large" into +// "tool-output-available chunk too large", which is what users actually need. +function extractChunkType(chunk: unknown): string | undefined { + if (!chunk || typeof chunk !== "object") return undefined; + const c = chunk as { type?: unknown; kind?: unknown }; + if (typeof c.type === "string") return c.type; + if (typeof c.kind === "string") return c.kind; + return undefined; +} + +/** + * Encode a chunk as a JSON record body for S2, enforcing the per-record + * size cap. Exported so the size/discriminant logic can be unit-tested + * directly without spinning up an S2 client or mocking `@s2-dev/streamstore`. + * + * Returns `{ ok: true, body }` when the encoded chunk fits within + * `RECORD_BODY_MAX_BYTES`, or `{ ok: false, error }` carrying a + * `ChatChunkTooLargeError` annotated with the chunk's discriminant + * (`type` or `kind`, whichever is present) so the surfaced error is + * useful — "tool-output-available chunk too large" beats a bare + * "chunk too large" by a lot. + */ +export function encodeChunkOrError( + chunk: unknown +): { ok: true; body: string } | { ok: false; error: ChatChunkTooLargeError } { + const body = JSON.stringify({ data: chunk, id: nanoid(7) }); + const size = utf8Encoder.encode(body).length; + if (size > RECORD_BODY_MAX_BYTES) { + return { + ok: false, + error: new ChatChunkTooLargeError(size, RECORD_BODY_MAX_BYTES, extractChunkType(chunk)), + }; + } + return { ok: true, body }; +} diff --git a/packages/core/src/v3/realtimeStreams/types.ts b/packages/core/src/v3/realtimeStreams/types.ts index 174970c2830..5e537d991ff 100644 --- a/packages/core/src/v3/realtimeStreams/types.ts +++ b/packages/core/src/v3/realtimeStreams/types.ts @@ -26,13 +26,17 @@ export interface RealtimeStreamsManager { ): Promise; } +export type StreamWriteResult = { + lastEventId?: string; +}; + export interface RealtimeStreamInstance { - wait(): Promise; + wait(): Promise; get stream(): AsyncIterableStream; } export interface StreamsWriter { - wait(): Promise; + wait(): Promise; } export type RealtimeDefinedStream = { @@ -71,6 +75,10 @@ export type PipeStreamOptions = { * Additional request options for the API call. */ requestOptions?: ApiRequestOptions; + /** Override the default span name for this operation. */ + spanName?: string; + /** When true, the span will be collapsed in the dashboard. */ + collapsed?: boolean; }; /** @@ -89,7 +97,7 @@ export type PipeStreamResult = { * to the realtime stream. Use this to wait for the stream to complete before * finishing your task. */ - waitUntilComplete: () => Promise; + waitUntilComplete: () => Promise; }; /** @@ -185,6 +193,14 @@ export type RealtimeDefinedInputStream = { * Uses a waitpoint token internally. Can only be called inside a task.run(). */ wait: (options?: InputStreamWaitOptions) => ManualWaitpointPromise; + /** + * Wait for data with an idle phase before suspending. + * + * Keeps the task active (using compute) for `idleTimeoutInSeconds`, + * then suspends via `.wait()` if no data arrives. If data arrives during + * the idle phase the task responds instantly without suspending. + */ + waitWithIdleTimeout: (options: InputStreamWaitWithIdleTimeoutOptions) => Promise<{ ok: true; output: TData } | { ok: false; error?: any }>; /** * Send data to this input stream on a specific run. * This is used from outside the task (e.g., from your backend or another task). @@ -199,6 +215,8 @@ export type InputStreamSubscription = { export type InputStreamOnceOptions = { signal?: AbortSignal; timeoutMs?: number; + /** Override the default span name for this operation. */ + spanName?: string; }; export type SendInputStreamOptions = { @@ -234,6 +252,24 @@ export type InputStreamWaitOptions = { * and filtering waitpoints via `wait.listTokens()`. */ tags?: string[]; + + /** Override the default span name for this operation. */ + spanName?: string; +}; + +export type InputStreamWaitWithIdleTimeoutOptions = { + /** Seconds to keep the task idle (active, using compute) before suspending. */ + idleTimeoutInSeconds: number; + /** Maximum time to wait after suspending (duration string, e.g. "1h"). */ + timeout?: string; + /** Override the default span name for the outer operation. */ + spanName?: string; + /** Called right before suspending (after idle phase times out). Not called if data arrives during idle. */ + onSuspend?: () => void | Promise; + /** Called right after resuming from suspension with data. Not called if data arrived during idle or on timeout. */ + onResume?: () => void | Promise; + /** When true, skip the suspend phase entirely. If idle times out, return `{ ok: false }` immediately. */ + skipSuspend?: boolean; }; export type InferInputStreamType = T extends RealtimeDefinedInputStream diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 0db92a67c64..42dc1826977 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -1115,6 +1115,7 @@ const CommonRunFields = { baseCostInCents: z.number(), durationMs: z.number(), metadata: z.record(z.any()).optional(), + taskKind: z.string().optional(), }; const RetrieveRunCommandFields = { @@ -1494,6 +1495,12 @@ export const SessionTriggerConfig = z.object({ queue: z.string().max(128).optional(), tags: z.array(z.string().max(128)).max(5).optional(), maxAttempts: z.number().int().positive().max(10).optional(), + /** Per-run wall-clock cap (seconds). Forwarded to `TaskRunOptions.maxDuration`. */ + maxDuration: z.number().int().positive().optional(), + /** Pin every run to a specific worker version. Forwarded to `TaskRunOptions.lockToVersion`. */ + lockToVersion: z.string().optional(), + /** Region to schedule runs in. Forwarded to `TaskRunOptions.region`. */ + region: z.string().optional(), /** Convenience field surfaced to chat.agent via the wire payload. */ idleTimeoutInSeconds: z.number().int().positive().max(3600).optional(), }); @@ -1818,6 +1825,9 @@ export const ApiDeploymentListResponseItem = z.object({ export type ApiDeploymentListResponseItem = z.infer; +export const RetrieveCurrentDeploymentResponseBody = ApiDeploymentListResponseItem; +export type RetrieveCurrentDeploymentResponseBody = ApiDeploymentListResponseItem; + export const ApiBranchListResponseBody = z.object({ branches: z.array( z.object({ @@ -1938,6 +1948,27 @@ export const SendInputStreamResponseBody = z.object({ }); export type SendInputStreamResponseBody = z.infer; +/** + * Response body for `GET /realtime/v1/sessions/:id/:io/records`. A non-SSE, + * `wait=0` drain of a session channel — used at run boot for snapshot + * replay where the SSE long-poll tax (~1s on empty streams) was the + * dominant cost. The shape mirrors the webapp's internal `StreamRecord` + * type (`apps/webapp/app/services/realtime/types.ts`); each record's + * `data` is a JSON-encoded chunk body that callers parse client-side. + */ +export const ReadSessionStreamRecordsResponseBody = z.object({ + records: z.array( + z.object({ + data: z.string(), + id: z.string(), + seqNum: z.number(), + }) + ), +}); +export type ReadSessionStreamRecordsResponseBody = z.infer< + typeof ReadSessionStreamRecordsResponseBody +>; + export const ResolvePromptRequestBody = z.object({ variables: z.record(z.unknown()).default({}), label: z.string().optional(), diff --git a/packages/core/src/v3/schemas/build.ts b/packages/core/src/v3/schemas/build.ts index bda5efb0ad9..e1543529a48 100644 --- a/packages/core/src/v3/schemas/build.ts +++ b/packages/core/src/v3/schemas/build.ts @@ -1,6 +1,12 @@ import { z } from "zod"; import { ConfigManifest } from "./config.js"; -import { PromptManifest, QueueManifest, TaskFile, TaskManifest } from "./schemas.js"; +import { + PromptManifest, + QueueManifest, + SkillManifest, + TaskFile, + TaskManifest, +} from "./schemas.js"; export const BuildExternal = z.object({ name: z.string(), @@ -70,6 +76,8 @@ export const BuildManifest = z.object({ .optional(), /** Maps output file paths to their content hashes for deduplication during dev */ outputHashes: z.record(z.string()).optional(), + /** Skills discovered and bundled into `.trigger/skills/{id}/` under `outputPath`. */ + skills: SkillManifest.array().optional(), }); export type BuildManifest = z.infer; @@ -87,6 +95,7 @@ export const WorkerManifest = z.object({ configPath: z.string(), tasks: TaskManifest.array(), prompts: PromptManifest.array().optional(), + skills: SkillManifest.array().optional(), queues: QueueManifest.array().optional(), workerEntryPoint: z.string(), controllerEntryPoint: z.string().optional(), diff --git a/packages/core/src/v3/schemas/resources.ts b/packages/core/src/v3/schemas/resources.ts index e681c728416..753324d1257 100644 --- a/packages/core/src/v3/schemas/resources.ts +++ b/packages/core/src/v3/schemas/resources.ts @@ -2,6 +2,12 @@ import { z } from "zod"; import { QueueManifest, RetryOptions, ScheduleMetadata } from "./schemas.js"; import { MachineConfig } from "./common.js"; +export const AgentConfig = z.object({ + type: z.string(), // "ai-sdk-chat" initially, extensible for future agent types +}); + +export type AgentConfig = z.infer; + export const TaskResource = z.object({ id: z.string(), description: z.string().optional(), @@ -11,6 +17,7 @@ export const TaskResource = z.object({ retry: RetryOptions.optional(), machine: MachineConfig.optional(), triggerSource: z.string().optional(), + agentConfig: AgentConfig.optional(), schedule: ScheduleMetadata.optional(), maxDuration: z.number().optional(), ttl: z.string().or(z.number().nonnegative().int()).optional(), diff --git a/packages/core/src/v3/schemas/runEngine.ts b/packages/core/src/v3/schemas/runEngine.ts index b9e41c9a8d7..5ea22960bf2 100644 --- a/packages/core/src/v3/schemas/runEngine.ts +++ b/packages/core/src/v3/schemas/runEngine.ts @@ -15,11 +15,15 @@ export const TriggerAction = z.enum(["trigger", "replay", "test"]).or(anyString) export type TriggerAction = z.infer; +export const TaskKind = z.enum(["STANDARD", "SCHEDULED", "AGENT"]).or(anyString); +export type TaskKind = z.infer; + export const RunAnnotations = z.object({ triggerSource: TriggerSource, triggerAction: TriggerAction, rootTriggerSource: TriggerSource, rootScheduleId: z.string().optional(), + taskKind: TaskKind.optional(), }); export type RunAnnotations = z.infer; diff --git a/packages/core/src/v3/schemas/schemas.ts b/packages/core/src/v3/schemas/schemas.ts index 5fb85f80ae8..95564cb1efc 100644 --- a/packages/core/src/v3/schemas/schemas.ts +++ b/packages/core/src/v3/schemas/schemas.ts @@ -180,6 +180,10 @@ export const ScheduleMetadata = z.object({ environments: z.array(EnvironmentType).optional(), }); +const AgentConfig = z.object({ + type: z.string(), +}); + const taskMetadata = { id: z.string(), description: z.string().optional(), @@ -187,6 +191,7 @@ const taskMetadata = { retry: RetryOptions.optional(), machine: MachineConfig.optional(), triggerSource: z.string().optional(), + agentConfig: AgentConfig.optional(), schedule: ScheduleMetadata.optional(), maxDuration: z.number().optional(), ttl: z.string().or(z.number().nonnegative().int()).optional(), @@ -241,6 +246,28 @@ export const PromptManifest = z.object({ export type PromptManifest = z.infer; +// ── Skills ──────────────────────────────────────────────────────────────── +// +// A skill is a developer-authored folder (SKILL.md + scripts/references/assets) +// bundled into the deploy image. SkillMetadata is registered at module load +// by `ai.defineSkill({ id, path })`; the CLI's built-in bundler picks it up +// during deploy and copies the folder into the deploy image. + +const skillMetadata = { + id: z.string(), + /** Path to the skill's source folder, relative to the project root. */ + sourcePath: z.string(), +}; + +export const SkillMetadata = z.object(skillMetadata); +export type SkillMetadata = z.infer; + +export const SkillManifest = z.object({ + ...skillMetadata, + ...taskFileMetadata, +}); +export type SkillManifest = z.infer; + export const PostStartCauses = z.enum(["index", "create", "restore"]); export type PostStartCauses = z.infer; diff --git a/packages/core/src/v3/semanticInternalAttributes.ts b/packages/core/src/v3/semanticInternalAttributes.ts index 2c715a03ea1..e6e0160663d 100644 --- a/packages/core/src/v3/semanticInternalAttributes.ts +++ b/packages/core/src/v3/semanticInternalAttributes.ts @@ -13,6 +13,7 @@ export const SemanticInternalAttributes = { RUN_ID: "ctx.run.id", RUN_IS_TEST: "ctx.run.isTest", RUN_IS_REPLAY: "ctx.run.isReplay", + GEN_AI_CONVERSATION_ID: "gen_ai.conversation.id", ORIGINAL_RUN_ID: "$original_run_id", BATCH_ID: "ctx.batch.id", TASK_SLUG: "ctx.task.id", diff --git a/packages/core/src/v3/session-streams-api.ts b/packages/core/src/v3/session-streams-api.ts new file mode 100644 index 00000000000..afa417a6418 --- /dev/null +++ b/packages/core/src/v3/session-streams-api.ts @@ -0,0 +1,7 @@ +// Split module-level variable definition into separate files to allow +// tree-shaking on each api instance. +import { SessionStreamsAPI } from "./sessionStreams/index.js"; + +export const sessionStreams = SessionStreamsAPI.getInstance(); + +export * from "./sessionStreams/types.js"; diff --git a/packages/core/src/v3/sessionStreams/index.ts b/packages/core/src/v3/sessionStreams/index.ts new file mode 100644 index 00000000000..75b372c8314 --- /dev/null +++ b/packages/core/src/v3/sessionStreams/index.ts @@ -0,0 +1,89 @@ +import { getGlobal, registerGlobal } from "../utils/globals.js"; +import { NoopSessionStreamManager } from "./noopManager.js"; +import { + InputStreamOncePromise, + SessionChannelIO, + SessionStreamManager, +} from "./types.js"; +import { InputStreamOnceOptions } from "../realtimeStreams/types.js"; + +const API_NAME = "session-streams"; + +const NOOP_MANAGER = new NoopSessionStreamManager(); + +export class SessionStreamsAPI implements SessionStreamManager { + private static _instance?: SessionStreamsAPI; + + private constructor() {} + + public static getInstance(): SessionStreamsAPI { + if (!this._instance) { + this._instance = new SessionStreamsAPI(); + } + return this._instance; + } + + setGlobalManager(manager: SessionStreamManager): boolean { + return registerGlobal(API_NAME, manager); + } + + #getManager(): SessionStreamManager { + return getGlobal(API_NAME) ?? NOOP_MANAGER; + } + + public on( + sessionId: string, + io: SessionChannelIO, + handler: (data: unknown) => void | Promise + ): { off: () => void } { + return this.#getManager().on(sessionId, io, handler); + } + + public once( + sessionId: string, + io: SessionChannelIO, + options?: InputStreamOnceOptions + ): InputStreamOncePromise { + return this.#getManager().once(sessionId, io, options); + } + + public peek(sessionId: string, io: SessionChannelIO): unknown | undefined { + return this.#getManager().peek(sessionId, io); + } + + public lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined { + return this.#getManager().lastSeqNum(sessionId, io); + } + + public setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void { + this.#getManager().setLastSeqNum(sessionId, io, seqNum); + } + + public setMinTimestamp( + sessionId: string, + io: SessionChannelIO, + minTimestamp: number | undefined + ): void { + this.#getManager().setMinTimestamp(sessionId, io, minTimestamp); + } + + public shiftBuffer(sessionId: string, io: SessionChannelIO): boolean { + return this.#getManager().shiftBuffer(sessionId, io); + } + + public disconnectStream(sessionId: string, io: SessionChannelIO): void { + this.#getManager().disconnectStream(sessionId, io); + } + + public clearHandlers(): void { + this.#getManager().clearHandlers(); + } + + public reset(): void { + this.#getManager().reset(); + } + + public disconnect(): void { + this.#getManager().disconnect(); + } +} diff --git a/packages/core/src/v3/sessionStreams/manager.test.ts b/packages/core/src/v3/sessionStreams/manager.test.ts new file mode 100644 index 00000000000..6089d705783 --- /dev/null +++ b/packages/core/src/v3/sessionStreams/manager.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import { StandardSessionStreamManager } from "./manager.js"; +import type { ApiClient } from "../apiClient/index.js"; +import type { SSEStreamPart } from "../apiClient/runStream.js"; + +// Single-shot mock that mimics S2's long-poll: delivers `records` once via +// `onPart` on the first subscribe call, then keeps the returned async +// iterable OPEN until the abort signal fires. Real S2 keeps the SSE +// connection alive on a long-poll; the manager's `runTail` finally / +// reconnect path only fires when the connection actually closes. Returning +// an empty stream synchronously triggers a tight reconnect loop, so the +// mock parks indefinitely instead. +function singleShotApiClient( + records: Array<{ id: string; chunk: unknown; timestamp: number }> +): ApiClient { + let delivered = false; + return { + async subscribeToSessionStream( + _sessionIdOrExternalId: string, + _io: "out" | "in", + options?: { onPart?: (part: SSEStreamPart) => void; signal?: AbortSignal } + ) { + if (!delivered) { + delivered = true; + for (const record of records) { + options?.onPart?.(record as SSEStreamPart); + } + } + const signal = options?.signal; + return (async function* () { + if (signal?.aborted) return; + await new Promise((resolve) => { + if (!signal) { + // No signal — block the stream forever; tests must + // explicitly call `disconnectStream` / `disconnect` to + // unblock. + return; + } + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + })() as unknown as Awaited>; + }, + } as unknown as ApiClient; +} + +describe("StandardSessionStreamManager — minTimestamp filter", () => { + const sessionId = "session-1"; + const io = "in" as const; + + it("dispatches records when no filter is set", async () => { + const records = [ + { id: "0", chunk: { kind: "message", payload: { id: "u1" } }, timestamp: 1000 }, + { id: "1", chunk: { kind: "message", payload: { id: "u2" } }, timestamp: 2000 }, + ]; + const manager = new StandardSessionStreamManager(singleShotApiClient(records), "http://localhost"); + + const first = await manager.once(sessionId, io); + expect(first).toEqual({ ok: true, output: { kind: "message", payload: { id: "u1" } } }); + + const second = await manager.once(sessionId, io); + expect(second).toEqual({ ok: true, output: { kind: "message", payload: { id: "u2" } } }); + + manager.disconnectStream(sessionId, io); // stop reconnect loop + manager.disconnect(); + }); + + it("drops records whose timestamp is <= minTimestamp", async () => { + const records = [ + { id: "0", chunk: { kind: "message", payload: { id: "u1" } }, timestamp: 1000 }, + { id: "1", chunk: { kind: "message", payload: { id: "u2" } }, timestamp: 2000 }, + { id: "2", chunk: { kind: "message", payload: { id: "u3" } }, timestamp: 3000 }, + ]; + const manager = new StandardSessionStreamManager(singleShotApiClient(records), "http://localhost"); + + // Cutoff at 2000 (inclusive: `<=` is dropped). Only u3 should pass. + manager.setMinTimestamp(sessionId, io, 2000); + + const passed = await manager.once(sessionId, io, { timeoutMs: 200 }); + expect(passed).toEqual({ ok: true, output: { kind: "message", payload: { id: "u3" } } }); + + manager.disconnectStream(sessionId, io); + manager.disconnect(); + }); + + it("clears the filter when set to undefined", async () => { + const records = [ + { id: "0", chunk: { kind: "message", payload: { id: "u1" } }, timestamp: 1000 }, + ]; + const manager = new StandardSessionStreamManager(singleShotApiClient(records), "http://localhost"); + + manager.setMinTimestamp(sessionId, io, 5000); + manager.setMinTimestamp(sessionId, io, undefined); + + const passed = await manager.once(sessionId, io, { timeoutMs: 200 }); + expect(passed).toEqual({ ok: true, output: { kind: "message", payload: { id: "u1" } } }); + + manager.disconnectStream(sessionId, io); + manager.disconnect(); + }); + + it("filter is per-(sessionId, io) and doesn't bleed across streams", async () => { + const inApi = singleShotApiClient([ + { id: "0", chunk: { kind: "in-record" }, timestamp: 1000 }, + ]); + const manager = new StandardSessionStreamManager(inApi, "http://localhost"); + + manager.setMinTimestamp(sessionId, "in", 5000); + + // The "out" stream uses the same singleShotApiClient instance — its + // single-shot delivers the same fixture, but the filter doesn't apply + // to "out" so the record passes. + const outResult = await manager.once(sessionId, "out", { timeoutMs: 200 }); + expect(outResult).toEqual({ ok: true, output: { kind: "in-record" } }); + + // The "in" stream is filtered (minTimestamp=5000, record ts=1000): the + // once() call should idle-timeout instead of resolving with the record. + // But the singleShot instance has already delivered to the "out" tail, + // so the "in" tail will get nothing on first connect anyway. Use a + // separate manager+api to keep the assertion crisp. + const inApi2 = singleShotApiClient([ + { id: "0", chunk: { kind: "in-record-2" }, timestamp: 1000 }, + ]); + const manager2 = new StandardSessionStreamManager(inApi2, "http://localhost"); + manager2.setMinTimestamp(sessionId, "in", 5000); + + const inResult = await manager2.once(sessionId, "in", { timeoutMs: 100 }); + expect(inResult.ok).toBe(false); // filter-dropped → idle timeout + + manager.disconnectStream(sessionId, "in"); + manager.disconnectStream(sessionId, "out"); + manager.disconnect(); + manager2.disconnectStream(sessionId, "in"); + manager2.disconnect(); + }); + + it("reset() clears all per-stream timestamp filters", async () => { + const records = [ + { id: "0", chunk: { kind: "message", payload: { id: "u1" } }, timestamp: 1000 }, + ]; + const manager = new StandardSessionStreamManager(singleShotApiClient(records), "http://localhost"); + + manager.setMinTimestamp(sessionId, io, 5000); + manager.reset(); + + const passed = await manager.once(sessionId, io, { timeoutMs: 200 }); + expect(passed).toEqual({ ok: true, output: { kind: "message", payload: { id: "u1" } } }); + + manager.disconnectStream(sessionId, io); + manager.disconnect(); + }); +}); diff --git a/packages/core/src/v3/sessionStreams/manager.ts b/packages/core/src/v3/sessionStreams/manager.ts new file mode 100644 index 00000000000..0463cb3fb71 --- /dev/null +++ b/packages/core/src/v3/sessionStreams/manager.ts @@ -0,0 +1,478 @@ +import { ApiClient } from "../apiClient/index.js"; +import { + InputStreamOncePromise, + InputStreamOnceResult, + InputStreamTimeoutError, +} from "../inputStreams/types.js"; +import { InputStreamOnceOptions } from "../realtimeStreams/types.js"; +import { computeReconnectDelayMs } from "../utils/reconnectBackoff.js"; +import { SessionChannelIO, SessionStreamManager } from "./types.js"; + +type SessionStreamHandler = (data: unknown) => void | Promise; + +type OnceWaiter = { + resolve: (result: InputStreamOnceResult) => void; + reject: (error: Error) => void; + timeoutHandle?: ReturnType; + // The abort signal and its handler are tracked on the waiter so any + // resolution path (dispatch / timeout / explicit removal) can detach + // the listener. Without this, a long-lived `AbortSignal` reused across + // many `once()` calls accumulates listeners — `{ once: true }` only + // self-clears if the signal actually aborts. + signal?: AbortSignal; + abortHandler?: () => void; +}; + +type TailState = { + abortController: AbortController; + promise: Promise; +}; + +function keyFor(sessionId: string, io: SessionChannelIO): string { + return `${sessionId}:${io}`; +} + +/** + * Session-scoped parallel to {@link StandardInputStreamManager}. Keeps the + * same buffer / once-waiter / tail lifecycle, but keyed on + * `(sessionId, io)` and subscribing via + * {@link ApiClient.subscribeToSessionStream} instead of the run input + * stream SSE. + */ +export class StandardSessionStreamManager implements SessionStreamManager { + private handlers = new Map>(); + private onceWaiters = new Map(); + private buffer = new Map(); + private tails = new Map(); + // Per-stream lower-bound timestamp filter. When set, records whose + // SSE timestamp is <= the bound are dropped before dispatch — used by + // chat.agent on OOM-retry boot to skip session.in records belonging + // to turns that already completed on the prior attempt. The filter + // is consulted in `runTail`'s `onPart` so the buffer never sees the + // dropped records. + private minTimestamps = new Map(); + // Keys that were explicitly torn down by `disconnectStream`. The tail's + // `.finally` reconnect path checks this so a long-lived persistent handler + // (e.g. `chat.agent`'s run-level `stopInput.on(...)`) doesn't silently + // resurrect the tail mid-`session.in.wait()` and re-deliver the record + // that's already being delivered out-of-band via the waitpoint. + private explicitlyDisconnected = new Set(); + private seqNums = new Map(); + // Reconnect attempt counter per key. Drives the exponential backoff + // applied by `#ensureTailConnected`'s `.finally` so a persistent + // backend failure (auth rejection, 5xx, DNS, etc.) doesn't reconnect + // in a tight loop. Reset to 0 by `#dispatch` whenever a real record + // flows through — any successful traffic is taken as a healthy + // connection. + private reconnectAttempts = new Map(); + + constructor( + private apiClient: ApiClient, + private baseUrl: string, + private debug: boolean = false + ) {} + + on( + sessionId: string, + io: SessionChannelIO, + handler: SessionStreamHandler + ): { off: () => void } { + const key = keyFor(sessionId, io); + + let handlerSet = this.handlers.get(key); + if (!handlerSet) { + handlerSet = new Set(); + this.handlers.set(key, handlerSet); + } + handlerSet.add(handler); + + // Explicit re-attach clears the "explicitly disconnected" suppression + // so the tail can subscribe again now that callers want delivery back. + this.explicitlyDisconnected.delete(key); + this.#ensureTailConnected(sessionId, io); + + const buffered = this.buffer.get(key); + if (buffered && buffered.length > 0) { + for (const data of buffered) { + this.#invokeHandler(handler, data); + } + this.buffer.delete(key); + } + + return { + off: () => { + handlerSet?.delete(handler); + if (handlerSet?.size === 0) { + this.handlers.delete(key); + } + }, + }; + } + + once( + sessionId: string, + io: SessionChannelIO, + options?: InputStreamOnceOptions + ): InputStreamOncePromise { + const key = keyFor(sessionId, io); + + this.explicitlyDisconnected.delete(key); + this.#ensureTailConnected(sessionId, io); + + const buffered = this.buffer.get(key); + if (buffered && buffered.length > 0) { + const data = buffered.shift()!; + if (buffered.length === 0) { + this.buffer.delete(key); + } + return new InputStreamOncePromise((resolve) => { + resolve({ ok: true, output: data }); + }); + } + + return new InputStreamOncePromise((resolve, reject) => { + const waiter: OnceWaiter = { resolve, reject }; + + if (options?.signal) { + if (options.signal.aborted) { + reject(new Error("Aborted")); + return; + } + const abortHandler = () => { + if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle); + this.#removeOnceWaiter(key, waiter); + reject(new Error("Aborted")); + }; + waiter.signal = options.signal; + waiter.abortHandler = abortHandler; + options.signal.addEventListener("abort", abortHandler, { once: true }); + } + + if (options?.timeoutMs) { + waiter.timeoutHandle = setTimeout(() => { + this.#removeOnceWaiter(key, waiter); + resolve({ + ok: false, + error: new InputStreamTimeoutError(key, options.timeoutMs!), + }); + }, options.timeoutMs); + } + + let waiters = this.onceWaiters.get(key); + if (!waiters) { + waiters = []; + this.onceWaiters.set(key, waiters); + } + waiters.push(waiter); + }); + } + + peek(sessionId: string, io: SessionChannelIO): unknown | undefined { + const buffered = this.buffer.get(keyFor(sessionId, io)); + if (buffered && buffered.length > 0) return buffered[0]; + return undefined; + } + + lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined { + return this.seqNums.get(keyFor(sessionId, io)); + } + + setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void { + const key = keyFor(sessionId, io); + const current = this.seqNums.get(key); + if (current === undefined || seqNum > current) { + this.seqNums.set(key, seqNum); + } + } + + setMinTimestamp( + sessionId: string, + io: SessionChannelIO, + minTimestamp: number | undefined + ): void { + const key = keyFor(sessionId, io); + if (minTimestamp === undefined) { + this.minTimestamps.delete(key); + } else { + this.minTimestamps.set(key, minTimestamp); + } + } + + shiftBuffer(sessionId: string, io: SessionChannelIO): boolean { + const key = keyFor(sessionId, io); + const buffered = this.buffer.get(key); + if (buffered && buffered.length > 0) { + buffered.shift(); + if (buffered.length === 0) this.buffer.delete(key); + return true; + } + return false; + } + + disconnectStream(sessionId: string, io: SessionChannelIO): void { + const key = keyFor(sessionId, io); + const tail = this.tails.get(key); + const bufferedSize = this.buffer.get(key)?.length ?? 0; + // Mark as explicitly disconnected BEFORE we abort, so the tail's + // `.finally` reconnect path sees the flag when it runs (which can be + // synchronous in the AbortError catch). Cleared on the next explicit + // `on()`/`once()`. + this.explicitlyDisconnected.add(key); + if (tail) { + tail.abortController.abort(); + this.tails.delete(key); + } + this.buffer.delete(key); + // Reset the backoff counter so a future re-attach starts fresh — + // an explicit disconnect is a deliberate teardown, not evidence of + // a broken backend. + this.reconnectAttempts.delete(key); + } + + clearHandlers(): void { + this.handlers.clear(); + + for (const [key, tail] of this.tails) { + const hasWaiters = this.onceWaiters.has(key) && this.onceWaiters.get(key)!.length > 0; + if (!hasWaiters) { + tail.abortController.abort(); + this.tails.delete(key); + } + } + } + + /** + * Tear down all active tails. Does NOT clear handlers or `onceWaiters`, + * so any registered listener will trigger an auto-reconnect (with + * backoff) the moment it sees no live tail — by design, so a transient + * network blip recovers without the caller re-subscribing. Use + * `reset()` if you want a full clean state with no resurrection, or + * `disconnectStream(sessionId, io)` for a single channel that should + * stay down until a fresh `on()` / `once()` attaches. + */ + disconnect(): void { + for (const [, tail] of this.tails) { + tail.abortController.abort(); + } + this.tails.clear(); + } + + reset(): void { + this.disconnect(); + this.seqNums.clear(); + this.minTimestamps.clear(); + this.handlers.clear(); + this.reconnectAttempts.clear(); + + for (const [, waiters] of this.onceWaiters) { + for (const waiter of waiters) { + if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle); + if (waiter.signal && waiter.abortHandler) { + waiter.signal.removeEventListener("abort", waiter.abortHandler); + } + waiter.reject(new Error("Session stream manager reset")); + } + } + this.onceWaiters.clear(); + this.buffer.clear(); + } + + #ensureTailConnected(sessionId: string, io: SessionChannelIO): void { + const key = keyFor(sessionId, io); + if (this.tails.has(key)) return; + + const abortController = new AbortController(); + const promise = this.#runTail(sessionId, io, abortController.signal) + .catch((error) => { + if (this.debug) { + console.error(`[SessionStreamManager] Tail error for "${key}":`, error); + } + }) + .finally(() => { + this.tails.delete(key); + + // If the tail was torn down explicitly via `disconnectStream`, + // honor that — the caller (typically `session.in.wait()`) is + // suspending the run and expects no records to be buffered or + // delivered until a fresh `on()` / `once()` re-attaches. Without + // this guard a run-level persistent handler (e.g. `chat.agent`'s + // `stopInput.on(...)`) would auto-reconnect during the suspend + // window, the resurrected tail would receive the same record the + // waitpoint just delivered, and that record would land in the + // buffer where the next turn's `messagesInput.on(...)` drains it + // and runs a duplicate turn. + if (this.explicitlyDisconnected.has(key)) { + return; + } + + const hasHandlers = this.handlers.has(key) && this.handlers.get(key)!.size > 0; + const hasWaiters = + this.onceWaiters.has(key) && this.onceWaiters.get(key)!.length > 0; + if (hasHandlers || hasWaiters) { + // Exponential backoff with jitter. 1s base, doubling each + // attempt, capped at 30s. Without this, a persistent backend + // failure (auth rejected, 5xx, DNS) reconnects in a tight loop + // because `#runTail`'s error path only logs. `#dispatch` resets + // the counter on every successful record, so transient blips + // don't accumulate. + const attempt = this.reconnectAttempts.get(key) ?? 0; + this.reconnectAttempts.set(key, attempt + 1); + const delayMs = computeReconnectDelayMs(attempt); + setTimeout(() => { + // Guards: a fresh `on()` during the wait may already have + // re-attached the tail; explicit disconnect or absence of + // handlers/waiters means we should stay quiet. + if (this.tails.has(key)) return; + if (this.explicitlyDisconnected.has(key)) return; + const stillHasHandlers = + this.handlers.has(key) && this.handlers.get(key)!.size > 0; + const stillHasWaiters = + this.onceWaiters.has(key) && this.onceWaiters.get(key)!.length > 0; + if (!stillHasHandlers && !stillHasWaiters) return; + this.#ensureTailConnected(sessionId, io); + }, delayMs); + } + }); + this.tails.set(key, { abortController, promise }); + } + + async #runTail( + sessionId: string, + io: SessionChannelIO, + signal: AbortSignal + ): Promise { + const key = keyFor(sessionId, io); + try { + const lastSeq = this.seqNums.get(key); + // Dispatch is driven from `onPart` (not the for-await loop) so each + // record reaches dispatch with its full SSE metadata in scope — + // specifically the timestamp, which we need for the per-stream + // min-timestamp filter. The for-await loop below just drains the + // pipeThrough output to keep the source flowing. + const stream = await this.apiClient.subscribeToSessionStream(sessionId, io, { + signal, + baseUrl: this.baseUrl, + timeoutInSeconds: 600, + lastEventId: lastSeq !== undefined ? String(lastSeq) : undefined, + onPart: (part) => { + if (signal.aborted) return; + const seqNum = parseInt(part.id, 10); + if (Number.isFinite(seqNum)) { + this.seqNums.set(key, seqNum); + } + + // Min-timestamp filter: drop records older than (or at) the + // bound. Used to skip already-processed records on OOM-retry + // boot. + const minTs = this.minTimestamps.get(key); + if (minTs !== undefined && part.timestamp <= minTs) { + return; + } + + let data: unknown = part.chunk; + if (typeof data === "string") { + try { + data = JSON.parse(data); + } catch { + // keep as string + } + } + this.#dispatch(key, data); + }, + onComplete: () => { + if (this.debug) { + console.log(`[SessionStreamManager] Tail completed for "${key}"`); + } + }, + onError: (error) => { + if (this.debug) { + console.error(`[SessionStreamManager] Tail error for "${key}":`, error); + } + }, + }); + + // Drain to keep the pipeThrough flowing. Records were already + // dispatched in `onPart`, so the body here is a no-op. + for await (const _record of stream) { + if (signal.aborted) break; + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") return; + throw error; + } + } + + #dispatch(key: string, data: unknown): void { + // Any record flowing through = healthy connection; reset the backoff + // counter so the next disconnect starts fresh. + this.reconnectAttempts.delete(key); + + const waiters = this.onceWaiters.get(key); + if (waiters && waiters.length > 0) { + const waiter = waiters.shift()!; + if (waiters.length === 0) this.onceWaiters.delete(key); + if (waiter.timeoutHandle) clearTimeout(waiter.timeoutHandle); + if (waiter.signal && waiter.abortHandler) { + waiter.signal.removeEventListener("abort", waiter.abortHandler); + } + waiter.resolve({ ok: true, output: data }); + this.#invokeHandlers(key, data); + return; + } + + // Persistent handlers (e.g. `stopInput.on(...)`) get a copy of the chunk, + // but they don't "consume" it — handlers usually filter by `kind` and + // ignore chunks they don't care about. Buffer the chunk regardless so a + // subsequent `once()` (e.g. `messagesInput.waitWithIdleTimeout` in + // chat.agent's preload) can still pick up the same chunk that arrived + // before its waiter was registered. + this.#invokeHandlers(key, data); + + let buffered = this.buffer.get(key); + if (!buffered) { + buffered = []; + this.buffer.set(key, buffered); + } + buffered.push(data); + } + + #invokeHandlers(key: string, data: unknown): void { + const handlers = this.handlers.get(key); + if (!handlers) return; + for (const handler of handlers) { + this.#invokeHandler(handler, data); + } + } + + #invokeHandler(handler: SessionStreamHandler, data: unknown): void { + try { + const result = handler(data); + if (result && typeof result === "object" && "catch" in result) { + (result as Promise).catch((error) => { + if (this.debug) { + console.error("[SessionStreamManager] Handler error:", error); + } + }); + } + } catch (error) { + if (this.debug) { + console.error("[SessionStreamManager] Handler error:", error); + } + } + } + + #removeOnceWaiter(key: string, waiter: OnceWaiter): void { + // Centralized cleanup — both timeout and explicit abort paths funnel + // through here, so detach the abort listener once instead of at every + // callsite. The dispatch path doesn't go through this method (the + // waiter is shifted off inline), so it detaches the listener there. + if (waiter.signal && waiter.abortHandler) { + waiter.signal.removeEventListener("abort", waiter.abortHandler); + } + const waiters = this.onceWaiters.get(key); + if (!waiters) return; + const index = waiters.indexOf(waiter); + if (index !== -1) waiters.splice(index, 1); + if (waiters.length === 0) this.onceWaiters.delete(key); + } +} diff --git a/packages/core/src/v3/sessionStreams/noopManager.ts b/packages/core/src/v3/sessionStreams/noopManager.ts new file mode 100644 index 00000000000..c1c3c38dcdf --- /dev/null +++ b/packages/core/src/v3/sessionStreams/noopManager.ts @@ -0,0 +1,51 @@ +import { InputStreamOnceOptions } from "../realtimeStreams/types.js"; +import { InputStreamOncePromise } from "../inputStreams/types.js"; +import { SessionChannelIO, SessionStreamManager } from "./types.js"; + +export class NoopSessionStreamManager implements SessionStreamManager { + on( + _sessionId: string, + _io: SessionChannelIO, + _handler: (data: unknown) => void | Promise + ): { off: () => void } { + return { off: () => {} }; + } + + once( + _sessionId: string, + _io: SessionChannelIO, + _options?: InputStreamOnceOptions + ): InputStreamOncePromise { + return new InputStreamOncePromise(() => { + // Never resolves in noop mode. + }); + } + + peek(_sessionId: string, _io: SessionChannelIO): unknown | undefined { + return undefined; + } + + lastSeqNum(_sessionId: string, _io: SessionChannelIO): number | undefined { + return undefined; + } + + setLastSeqNum(_sessionId: string, _io: SessionChannelIO, _seqNum: number): void {} + + setMinTimestamp( + _sessionId: string, + _io: SessionChannelIO, + _minTimestamp: number | undefined + ): void {} + + shiftBuffer(_sessionId: string, _io: SessionChannelIO): boolean { + return false; + } + + disconnectStream(_sessionId: string, _io: SessionChannelIO): void {} + + clearHandlers(): void {} + + reset(): void {} + + disconnect(): void {} +} diff --git a/packages/core/src/v3/sessionStreams/types.ts b/packages/core/src/v3/sessionStreams/types.ts new file mode 100644 index 00000000000..2310fabae25 --- /dev/null +++ b/packages/core/src/v3/sessionStreams/types.ts @@ -0,0 +1,76 @@ +import { InputStreamOnceOptions } from "../realtimeStreams/types.js"; +import { + InputStreamOncePromise, + InputStreamOnceResult, + InputStreamTimeoutError, +} from "../inputStreams/types.js"; + +/** + * Re-export the run-scoped input stream once-promise machinery so callers + * depending on sessionStreams don't also need to import from inputStreams. + * Both APIs return the same shape. + */ +export { InputStreamOncePromise, InputStreamTimeoutError }; +export type { InputStreamOnceResult }; + +export type SessionChannelIO = "out" | "in"; + +/** + * Manager for Session channel reads: a session-scoped parallel to + * {@link InputStreamManager} keyed on `(sessionId, io)` instead of + * `(runId, streamId)`. Used by {@link SessionChannel} to implement + * `.on` / `.once` / `.peek` / `.wait` / `.waitWithIdleTimeout`. + */ +export interface SessionStreamManager { + /** Register a handler that fires every time data arrives on the given channel. */ + on( + sessionId: string, + io: SessionChannelIO, + handler: (data: unknown) => void | Promise + ): { off: () => void }; + + /** Wait for the next record on the given channel (buffered or live). */ + once( + sessionId: string, + io: SessionChannelIO, + options?: InputStreamOnceOptions + ): InputStreamOncePromise; + + /** Non-blocking peek at the head of the channel buffer. */ + peek(sessionId: string, io: SessionChannelIO): unknown | undefined; + + /** Last S2 sequence number seen on the given channel. */ + lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined; + + /** Advance the last-seen sequence number (prevents SSE replay after `.wait` resume). */ + setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void; + + /** + * Set a per-stream lower-bound SSE timestamp. Records whose timestamp + * is `<= minTimestamp` are dropped before dispatch. Used by chat.agent + * on OOM-retry boot to skip session.in records belonging to turns + * that already completed on the prior attempt. + * + * Pass `undefined` to clear the filter. + */ + setMinTimestamp( + sessionId: string, + io: SessionChannelIO, + minTimestamp: number | undefined + ): void; + + /** Remove and discard the first buffered record. Returns true if one was removed. */ + shiftBuffer(sessionId: string, io: SessionChannelIO): boolean; + + /** Abort the SSE tail and clear the buffer. Called before `.wait` suspends. */ + disconnectStream(sessionId: string, io: SessionChannelIO): void; + + /** Clear all `.on` handlers; abort tails without pending once-waiters. */ + clearHandlers(): void; + + /** Reset state between task executions. */ + reset(): void; + + /** Disconnect every tail. */ + disconnect(): void; +} diff --git a/packages/core/src/v3/test/test-input-stream-manager.ts b/packages/core/src/v3/test/test-input-stream-manager.ts new file mode 100644 index 00000000000..933b92d07c6 --- /dev/null +++ b/packages/core/src/v3/test/test-input-stream-manager.ts @@ -0,0 +1,219 @@ +import type { InputStreamManager, InputStreamOnceResult } from "../inputStreams/types.js"; +import { InputStreamOncePromise, InputStreamTimeoutError } from "../inputStreams/types.js"; +import type { InputStreamOnceOptions } from "../realtimeStreams/types.js"; + +type OnceWaiter = { + resolve: (value: InputStreamOnceResult) => void; + timer?: ReturnType; + signal?: AbortSignal; + abortHandler?: () => void; +}; + +type Handler = (data: unknown) => void | Promise; + +/** + * In-memory implementation of `InputStreamManager` for unit tests. + * + * Tests push data via the driver's `.send(streamId, data)` method. Any + * pending `.once()` waiters resolve immediately, and all `.on()` handlers + * fire synchronously (awaited if they return a promise). + * + * Use this alongside {@link runInMockTaskContext} — not directly. + */ +export class TestInputStreamManager implements InputStreamManager { + private handlers = new Map>(); + private onceWaiters = new Map(); + private latest = new Map(); + private lastSeqNums = new Map(); + // Buffered sends that arrived before a `.once()` waiter was registered. + // `.once()` semantically means "wait for NEXT value" but tests often + // send data before the task has had a chance to reach the wait point. + // Buffering closes that race so the waiter picks up the pending send. + private pendingSends = new Map(); + + setRunId(_runId: string, _streamsVersion?: string): void { + // No-op — the test driver tracks nothing about runs + } + + on(streamId: string, handler: Handler): { off: () => void } { + if (!this.handlers.has(streamId)) { + this.handlers.set(streamId, new Set()); + } + this.handlers.get(streamId)!.add(handler); + + return { + off: () => { + this.handlers.get(streamId)?.delete(handler); + }, + }; + } + + once(streamId: string, options?: InputStreamOnceOptions): InputStreamOncePromise { + return new InputStreamOncePromise((resolve) => { + if (options?.signal?.aborted) { + resolve({ + ok: false, + error: new InputStreamTimeoutError(streamId, options.timeoutMs ?? 0), + }); + return; + } + + // Pick up any buffered send that arrived before this waiter. + const buffered = this.pendingSends.get(streamId); + if (buffered && buffered.length > 0) { + const next = buffered.shift(); + if (buffered.length === 0) this.pendingSends.delete(streamId); + resolve({ ok: true, output: next }); + return; + } + + const waiter: OnceWaiter = { + resolve, + signal: options?.signal, + }; + + if (options?.timeoutMs !== undefined) { + waiter.timer = setTimeout(() => { + this.removeWaiter(streamId, waiter); + resolve({ + ok: false, + error: new InputStreamTimeoutError(streamId, options.timeoutMs!), + }); + }, options.timeoutMs); + } + + if (options?.signal) { + const abortHandler = () => { + this.removeWaiter(streamId, waiter); + if (waiter.timer) clearTimeout(waiter.timer); + resolve({ + ok: false, + error: new InputStreamTimeoutError(streamId, options.timeoutMs ?? 0), + }); + }; + waiter.abortHandler = abortHandler; + options.signal.addEventListener("abort", abortHandler, { once: true }); + } + + if (!this.onceWaiters.has(streamId)) { + this.onceWaiters.set(streamId, []); + } + this.onceWaiters.get(streamId)!.push(waiter); + }); + } + + peek(streamId: string): unknown | undefined { + return this.latest.get(streamId); + } + + lastSeqNum(streamId: string): number | undefined { + return this.lastSeqNums.get(streamId); + } + + setLastSeqNum(streamId: string, seqNum: number): void { + this.lastSeqNums.set(streamId, seqNum); + } + + shiftBuffer(_streamId: string): boolean { + return false; + } + + disconnectStream(_streamId: string): void {} + + clearHandlers(): void { + this.handlers.clear(); + } + + reset(): void { + // Cancel any pending waiters to avoid dangling promises leaking between tests + for (const waiters of this.onceWaiters.values()) { + for (const w of waiters) { + if (w.timer) clearTimeout(w.timer); + if (w.signal && w.abortHandler) { + w.signal.removeEventListener("abort", w.abortHandler); + } + } + } + this.onceWaiters.clear(); + this.handlers.clear(); + this.latest.clear(); + this.lastSeqNums.clear(); + this.pendingSends.clear(); + } + + disconnect(): void { + this.reset(); + } + + connectTail(_runId: string, _fromSeq?: number): void {} + + // ── Test driver API (not part of InputStreamManager interface) ────────── + + /** + * Push data onto an input stream. Resolves pending `once()` waiters + * and fires all `on()` handlers (awaiting async handlers). + */ + async __sendFromTest(streamId: string, data: unknown): Promise { + this.latest.set(streamId, data); + + const waiters = this.onceWaiters.get(streamId); + const handlers = this.handlers.get(streamId); + const hasWaiters = waiters && waiters.length > 0; + const hasHandlers = handlers && handlers.size > 0; + + // If nothing is listening yet, buffer so the next `.once()` call picks it up. + if (!hasWaiters && !hasHandlers) { + if (!this.pendingSends.has(streamId)) { + this.pendingSends.set(streamId, []); + } + this.pendingSends.get(streamId)!.push(data); + return; + } + + if (hasWaiters) { + // Drain every pending once() waiter — this mirrors the real manager's + // behavior where the stream tail delivers the same record to all listeners. + const pending = waiters!.splice(0); + for (const w of pending) { + if (w.timer) clearTimeout(w.timer); + if (w.signal && w.abortHandler) { + w.signal.removeEventListener("abort", w.abortHandler); + } + w.resolve({ ok: true, output: data }); + } + } + + if (hasHandlers) { + await Promise.all( + Array.from(handlers!).map((h) => Promise.resolve().then(() => h(data))) + ); + } + } + + /** + * Immediately resolve every pending `once()` waiter for a stream with a + * timeout error. Used to simulate closed streams (e.g. `exitAfterPreloadIdle`). + */ + __closeFromTest(streamId: string): void { + const waiters = this.onceWaiters.get(streamId); + if (!waiters) return; + const pending = waiters.splice(0); + for (const w of pending) { + if (w.timer) clearTimeout(w.timer); + if (w.signal && w.abortHandler) { + w.signal.removeEventListener("abort", w.abortHandler); + } + w.resolve({ + ok: false, + error: new InputStreamTimeoutError(streamId, 0), + }); + } + } + + private removeWaiter(streamId: string, waiter: OnceWaiter): void { + const waiters = this.onceWaiters.get(streamId); + if (!waiters) return; + const idx = waiters.indexOf(waiter); + if (idx >= 0) waiters.splice(idx, 1); + } +} diff --git a/packages/core/src/v3/test/test-realtime-streams-manager.ts b/packages/core/src/v3/test/test-realtime-streams-manager.ts new file mode 100644 index 00000000000..8f4142b28e7 --- /dev/null +++ b/packages/core/src/v3/test/test-realtime-streams-manager.ts @@ -0,0 +1,170 @@ +import { + AsyncIterableStream, + createAsyncIterableStreamFromAsyncIterable, +} from "../streams/asyncIterableStream.js"; +import type { + RealtimeStreamInstance, + RealtimeStreamOperationOptions, + RealtimeStreamsManager, +} from "../realtimeStreams/types.js"; + +/** + * In-memory implementation of `RealtimeStreamsManager` for unit tests. + * Collects every chunk that tasks write via `pipe()` or `append()` into + * per-stream buffers that tests can inspect. + * + * Use this alongside {@link runInMockTaskContext} — not directly. + */ +type WriteListener = (key: string, chunk: unknown) => void; + +export class TestRealtimeStreamsManager implements RealtimeStreamsManager { + private buffers = new Map(); + private pipeWaits = new Map[]>(); + private writeListeners = new Set(); + + pipe( + key: string, + source: AsyncIterable | ReadableStream, + _options?: RealtimeStreamOperationOptions + ): RealtimeStreamInstance { + const buffer = this.getBuffer(key); + const self = this; + + // Eagerly drain the source in the background so chunks land in the + // buffer + notify listeners even when the caller never consumes the + // returned stream. This mirrors the real SDK behavior: `streams.writer` + // awaits `instance.wait()`, it doesn't read the returned stream. + // + // The source is read ONCE (into a chunks array) and replayed into a + // ReadableStream so the caller can still consume it if they want. + const readChunks: T[] = []; + let resolveDone!: () => void; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + + (async () => { + try { + const iter = + source instanceof ReadableStream + ? (async function* () { + const reader = source.getReader(); + try { + while (true) { + const { done: d, value } = await reader.read(); + if (d) return; + yield value as T; + } + } finally { + reader.releaseLock(); + } + })() + : source; + + for await (const chunk of iter) { + readChunks.push(chunk); + buffer.push(chunk); + self.notify(key, chunk); + } + } catch { + // Swallow — tests can inspect what made it into the buffer + } finally { + resolveDone(); + } + })(); + + const replayStream = (async function* () { + // Wait for all chunks to be drained, then replay from our snapshot + await done; + for (const chunk of readChunks) yield chunk; + })(); + const wrappedStream = createAsyncIterableStreamFromAsyncIterable(replayStream); + + if (!this.pipeWaits.has(key)) this.pipeWaits.set(key, []); + this.pipeWaits.get(key)!.push(done); + + return { + wait: () => done.then(() => ({})), + get stream(): AsyncIterableStream { + return wrappedStream; + }, + }; + } + + async append( + key: string, + part: TPart, + _options?: RealtimeStreamOperationOptions + ): Promise { + this.getBuffer(key).push(part); + this.notify(key, part); + } + + /** + * Register a listener fired for every chunk written to any stream. + * Returns an unsubscribe function. + * + * Intended for test harnesses that need to react to writes synchronously + * (e.g. resolving a "turn complete" latch). + */ + onWrite(listener: WriteListener): () => void { + this.writeListeners.add(listener); + return () => { + this.writeListeners.delete(listener); + }; + } + + private notify(key: string, chunk: unknown): void { + for (const listener of this.writeListeners) { + try { + listener(key, chunk); + } catch { + // Never let a listener error break stream writes + } + } + } + + // ── Test driver API (not part of RealtimeStreamsManager interface) ────── + + /** + * Return all chunks written to the given stream key in order of write. + */ + __chunksFromTest(key: string): T[] { + return (this.buffers.get(key) ?? []).slice() as T[]; + } + + /** + * Return all chunks across every stream, keyed by stream id. + */ + __allChunksFromTest(): Record { + const result: Record = {}; + for (const [key, chunks] of this.buffers.entries()) { + result[key] = chunks.slice(); + } + return result; + } + + /** + * Clear the buffer for a specific stream or all streams. + */ + __clearFromTest(key?: string): void { + if (key === undefined) { + this.buffers.clear(); + } else { + this.buffers.delete(key); + } + } + + reset(): void { + this.buffers.clear(); + this.pipeWaits.clear(); + this.writeListeners.clear(); + } + + private getBuffer(key: string): unknown[] { + if (!this.buffers.has(key)) { + this.buffers.set(key, []); + } + return this.buffers.get(key)!; + } +} diff --git a/packages/core/src/v3/test/test-run-metadata-manager.ts b/packages/core/src/v3/test/test-run-metadata-manager.ts new file mode 100644 index 00000000000..9d806f17a03 --- /dev/null +++ b/packages/core/src/v3/test/test-run-metadata-manager.ts @@ -0,0 +1,103 @@ +import type { DeserializedJson } from "../../schemas/json.js"; +import type { AsyncIterableStream } from "../streams/asyncIterableStream.js"; +import type { RunMetadataManager, RunMetadataUpdater } from "../runMetadata/types.js"; + +/** + * In-memory implementation of `RunMetadataManager` for unit tests. + * + * Just stores metadata in a Map — no API calls, no queue. Good enough + * for tests that read/write metadata via `runMetadata.getKey()` / + * `runMetadata.set()`, including the IDLE_TIMEOUT and TURN_TIMEOUT + * checks inside `chat.agent()`. + */ +export class TestRunMetadataManager implements RunMetadataManager { + private store: Record = {}; + + enterWithMetadata(metadata: Record): void { + this.store = { ...metadata }; + } + + current(): Record | undefined { + return { ...this.store }; + } + + getKey(key: string): DeserializedJson | undefined { + return this.store[key]; + } + + set(key: string, value: DeserializedJson): this { + this.store[key] = value; + return this; + } + + del(key: string): this { + delete this.store[key]; + return this; + } + + append(key: string, value: DeserializedJson): this { + const existing = this.store[key]; + if (Array.isArray(existing)) { + existing.push(value); + } else { + this.store[key] = [value]; + } + return this; + } + + remove(key: string, value: DeserializedJson): this { + const existing = this.store[key]; + if (Array.isArray(existing)) { + this.store[key] = existing.filter((v) => v !== value) as DeserializedJson; + } + return this; + } + + increment(key: string, value: number): this { + const existing = this.store[key]; + const current = typeof existing === "number" ? existing : 0; + this.store[key] = current + value; + return this; + } + + decrement(key: string, value: number): this { + return this.increment(key, -value); + } + + update(metadata: Record): this { + this.store = { ...metadata }; + return this; + } + + async flush(): Promise {} + async refresh(): Promise {} + + async stream( + _key: string, + value: AsyncIterable | ReadableStream + ): Promise> { + return value as AsyncIterable; + } + + async fetchStream(_key: string): Promise> { + // Return an empty async iterable — tests can override if needed + const empty = { + [Symbol.asyncIterator]: () => ({ + next: () => Promise.resolve({ done: true as const, value: undefined as T }), + }), + }; + return empty as unknown as AsyncIterableStream; + } + + get parent(): RunMetadataUpdater { + return this; + } + + get root(): RunMetadataUpdater { + return this; + } + + reset(): void { + this.store = {}; + } +} diff --git a/packages/core/src/v3/test/test-session-stream-manager.ts b/packages/core/src/v3/test/test-session-stream-manager.ts new file mode 100644 index 00000000000..493093d686f --- /dev/null +++ b/packages/core/src/v3/test/test-session-stream-manager.ts @@ -0,0 +1,286 @@ +import { + InputStreamOncePromise, + InputStreamOnceResult, + InputStreamTimeoutError, +} from "../inputStreams/types.js"; +import type { InputStreamOnceOptions } from "../realtimeStreams/types.js"; +import type { + SessionChannelIO, + SessionStreamManager, +} from "../sessionStreams/types.js"; + +type OnceWaiter = { + resolve: (value: InputStreamOnceResult) => void; + timer?: ReturnType; + signal?: AbortSignal; + abortHandler?: () => void; +}; + +type Handler = (data: unknown) => void | Promise; + +function keyFor(sessionId: string, io: SessionChannelIO): string { + return `${sessionId}:${io}`; +} + +/** + * In-memory implementation of `SessionStreamManager` for unit tests. Same + * shape as {@link TestInputStreamManager} but keyed on `(sessionId, io)`. + * + * Tests push data via `__sendFromTest(sessionId, io, data)` — any pending + * `once()` waiters resolve immediately, and all `on()` handlers fire (awaited + * if they return a promise). Records that arrive before a listener is + * registered are buffered so the first `once()` picks them up. + */ +export class TestSessionStreamManager implements SessionStreamManager { + private handlers = new Map>(); + private onceWaiters = new Map(); + private buffer = new Map(); + private seqNums = new Map(); + + on( + sessionId: string, + io: SessionChannelIO, + handler: Handler + ): { off: () => void } { + const key = keyFor(sessionId, io); + + let set = this.handlers.get(key); + if (!set) { + set = new Set(); + this.handlers.set(key, set); + } + set.add(handler); + + // Note: we intentionally do NOT replay buffered records into the + // newly-registered handler, and we do NOT drain the buffer. The + // buffer is owned by `once()` — registering a passive observer + // (`on`) must not consume records destined for a future `once` + // waiter. This matches production SSE semantics where handlers + // observe records as they arrive, not retroactively. + // + // Earlier versions drained the buffer here, which caused user + // messages buffered during the runtime's `runFn` boot phase to be + // silently swallowed by the `stopInput.on()` handler registered at + // ai.ts:4806 (the stop handler ignores `kind: "message"` chunks). + // The next `messagesInput.waitWithIdleTimeout` then waited 30s for + // a record that had already been "delivered" to a handler that + // didn't want it. + + return { + off: () => { + this.handlers.get(key)?.delete(handler); + }, + }; + } + + once( + sessionId: string, + io: SessionChannelIO, + options?: InputStreamOnceOptions + ): InputStreamOncePromise { + const key = keyFor(sessionId, io); + + return new InputStreamOncePromise((resolve) => { + if (options?.signal?.aborted) { + resolve({ + ok: false, + error: new InputStreamTimeoutError(key, options.timeoutMs ?? 0), + }); + return; + } + + const buffered = this.buffer.get(key); + if (buffered && buffered.length > 0) { + const next = buffered.shift(); + if (buffered.length === 0) this.buffer.delete(key); + resolve({ ok: true, output: next }); + return; + } + + const waiter: OnceWaiter = { resolve, signal: options?.signal }; + + if (options?.timeoutMs !== undefined) { + waiter.timer = setTimeout(() => { + this.removeWaiter(key, waiter); + resolve({ + ok: false, + error: new InputStreamTimeoutError(key, options.timeoutMs!), + }); + }, options.timeoutMs); + } + + if (options?.signal) { + const abortHandler = () => { + this.removeWaiter(key, waiter); + if (waiter.timer) clearTimeout(waiter.timer); + resolve({ + ok: false, + error: new InputStreamTimeoutError(key, options.timeoutMs ?? 0), + }); + }; + waiter.abortHandler = abortHandler; + options.signal.addEventListener("abort", abortHandler, { once: true }); + } + + let waiters = this.onceWaiters.get(key); + if (!waiters) { + waiters = []; + this.onceWaiters.set(key, waiters); + } + waiters.push(waiter); + }); + } + + peek(sessionId: string, io: SessionChannelIO): unknown | undefined { + const buffered = this.buffer.get(keyFor(sessionId, io)); + if (buffered && buffered.length > 0) return buffered[0]; + return undefined; + } + + lastSeqNum(sessionId: string, io: SessionChannelIO): number | undefined { + return this.seqNums.get(keyFor(sessionId, io)); + } + + setLastSeqNum(sessionId: string, io: SessionChannelIO, seqNum: number): void { + this.seqNums.set(keyFor(sessionId, io), seqNum); + } + + setMinTimestamp( + _sessionId: string, + _io: SessionChannelIO, + _minTimestamp: number | undefined + ): void { + // No filter applied in tests; the test harness drives records directly + // and the chat.agent retry path is exercised separately. + } + + shiftBuffer(sessionId: string, io: SessionChannelIO): boolean { + const key = keyFor(sessionId, io); + const buffered = this.buffer.get(key); + if (buffered && buffered.length > 0) { + buffered.shift(); + if (buffered.length === 0) this.buffer.delete(key); + return true; + } + return false; + } + + disconnectStream(_sessionId: string, _io: SessionChannelIO): void { + // no-op — no real SSE tail in tests + } + + clearHandlers(): void { + this.handlers.clear(); + } + + reset(): void { + for (const waiters of this.onceWaiters.values()) { + for (const w of waiters) { + if (w.timer) clearTimeout(w.timer); + if (w.signal && w.abortHandler) { + w.signal.removeEventListener("abort", w.abortHandler); + } + } + } + this.onceWaiters.clear(); + this.handlers.clear(); + this.buffer.clear(); + this.seqNums.clear(); + } + + disconnect(): void { + this.reset(); + } + + // ── Test driver API (not part of SessionStreamManager interface) ────── + + /** + * Push a record onto the given channel. + * + * Dispatch rules — similar to the production manager, but with a tweak + * that makes unit tests deterministic: + * + * 1. **Handlers always observe** (like production). A session-level `.on` + * is a filter-observer — it fires every time a record arrives, + * regardless of whether a `.once` waiter is also active. + * 2. **First waiter consumes** the record if present (like production). + * 3. **If no waiter, the record is buffered for the next `.once` call.** + * Production discards records that only match handlers — but in + * production the SSE tail introduces enough latency that the next + * `.once` is usually registered before the next record arrives. Tests + * send synchronously right after `turn-complete`, so without this + * buffer the next `waitWithIdleTimeout` would race and lose the + * message. The buffer is the only deviation from production semantics. + */ + async __sendFromTest( + sessionId: string, + io: SessionChannelIO, + data: unknown + ): Promise { + const key = keyFor(sessionId, io); + + const handlers = this.handlers.get(key); + if (handlers && handlers.size > 0) { + // Awaited so test code can rely on handlers having completed by the + // time `__sendFromTest` resolves. Wrapped per-handler so a + // throwing/rejecting handler doesn't poison Promise.all and break + // unrelated test state. + await Promise.all( + Array.from(handlers).map(async (h) => { + try { + await h(data); + } catch { + // Never let a handler error break test state + } + }) + ); + } + + const waiters = this.onceWaiters.get(key); + if (waiters && waiters.length > 0) { + const w = waiters.shift()!; + if (waiters.length === 0) this.onceWaiters.delete(key); + if (w.timer) clearTimeout(w.timer); + if (w.signal && w.abortHandler) { + w.signal.removeEventListener("abort", w.abortHandler); + } + w.resolve({ ok: true, output: data }); + return; + } + + let buffered = this.buffer.get(key); + if (!buffered) { + buffered = []; + this.buffer.set(key, buffered); + } + buffered.push(data); + } + + /** + * Immediately resolve every pending `once()` waiter for the given channel + * with a timeout error. Simulates a closed stream (e.g. session closed). + */ + __closeFromTest(sessionId: string, io: SessionChannelIO): void { + const key = keyFor(sessionId, io); + const waiters = this.onceWaiters.get(key); + if (!waiters) return; + const pending = waiters.splice(0); + for (const w of pending) { + if (w.timer) clearTimeout(w.timer); + if (w.signal && w.abortHandler) { + w.signal.removeEventListener("abort", w.abortHandler); + } + w.resolve({ + ok: false, + error: new InputStreamTimeoutError(key, 0), + }); + } + } + + private removeWaiter(key: string, waiter: OnceWaiter): void { + const waiters = this.onceWaiters.get(key); + if (!waiters) return; + const idx = waiters.indexOf(waiter); + if (idx >= 0) waiters.splice(idx, 1); + } +} diff --git a/packages/core/src/v3/types/tasks.ts b/packages/core/src/v3/types/tasks.ts index d04d088ef1a..978a6e5bd0a 100644 --- a/packages/core/src/v3/types/tasks.ts +++ b/packages/core/src/v3/types/tasks.ts @@ -387,6 +387,12 @@ type CommonTaskOptions< * Should be a valid JSON Schema Draft 7 object. */ jsonSchema?: JSONSchema; + + /** @internal Set by SDK internals (e.g. `chat.agent()`, `schedules.task()`). */ + triggerSource?: string; + + /** @internal Agent configuration, only set when `triggerSource` is `"agent"`. */ + agentConfig?: { type: string }; }; export type TaskOptions< @@ -641,6 +647,30 @@ export interface Task requestOptions?: TriggerApiRequestOptions ) => TaskRunPromise; + /** + * Trigger a task and subscribe to its updates via realtime. Unlike `triggerAndWait`, + * this does NOT suspend the parent run — the parent stays alive and polls for updates. + * This enables parallel tool calls and proper abort signal handling. + * + * @param payload + * @param options - Options for the task run, including an optional `signal` to cancel the subscription and child run + * @returns TaskRunPromise + * @example + * ``` + * const result = await task.triggerAndSubscribe({ foo: "bar" }, { signal: abortSignal }); + * + * if (result.ok) { + * console.log(result.output); + * } else { + * console.error(result.error); + * } + * ``` + */ + triggerAndSubscribe: ( + payload: TInput, + options?: TriggerAndSubscribeOptions, + ) => TaskRunPromise; + /** * Batch trigger multiple task runs with the given payloads, and wait for the results. Returns the results of the task runs. * @param items - Array, AsyncIterable, or ReadableStream of batch items @@ -989,6 +1019,16 @@ export type TriggerOptions = { }; export type TriggerAndWaitOptions = Omit; + +export type TriggerAndSubscribeOptions = Omit & { + /** An AbortSignal to cancel the subscription. When fired, the subscription closes and the promise rejects. */ + signal?: AbortSignal; + /** + * Whether to cancel the child run when the abort signal fires. + * @default true + */ + cancelOnAbort?: boolean; +}; export type BatchTriggerOptions = { /** * If no idempotencyKey is set on an individual item in the batch, it will use this key on each item + the array index. diff --git a/packages/core/src/v3/utils/globals.ts b/packages/core/src/v3/utils/globals.ts index 08b62d379b2..fa5b8176f6e 100644 --- a/packages/core/src/v3/utils/globals.ts +++ b/packages/core/src/v3/utils/globals.ts @@ -3,6 +3,7 @@ import { Clock } from "../clock/clock.js"; import { HeartbeatsManager } from "../heartbeats/types.js"; import type { IdempotencyKeyCatalog } from "../idempotency-key-catalog/catalog.js"; import { InputStreamManager } from "../inputStreams/types.js"; +import { SessionStreamManager } from "../sessionStreams/types.js"; import { LifecycleHooksManager } from "../lifecycleHooks/types.js"; import { LocalsManager } from "../locals/types.js"; import { RealtimeStreamsManager } from "../realtimeStreams/types.js"; @@ -76,4 +77,5 @@ type TriggerDotDevGlobalAPI = { ["heartbeats"]?: HeartbeatsManager; ["realtime-streams"]?: RealtimeStreamsManager; ["input-streams"]?: InputStreamManager; + ["session-streams"]?: SessionStreamManager; }; diff --git a/packages/core/src/v3/utils/reconnectBackoff.test.ts b/packages/core/src/v3/utils/reconnectBackoff.test.ts new file mode 100644 index 00000000000..5de5a2db8e8 --- /dev/null +++ b/packages/core/src/v3/utils/reconnectBackoff.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { RECONNECT_BACKOFF_MAX_MS, computeReconnectDelayMs } from "./reconnectBackoff.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("computeReconnectDelayMs", () => { + // Hold Math.random steady so we can assert on the deterministic base. The + // jitter is added separately in the "jitter" test below. + function withFixedRandom(value: number, fn: () => void) { + const spy = vi.spyOn(Math, "random").mockReturnValue(value); + try { + fn(); + } finally { + spy.mockRestore(); + } + } + + it("base case — attempt 0 lands in [1000, 2000)", () => { + withFixedRandom(0, () => { + expect(computeReconnectDelayMs(0)).toBe(1000); + }); + withFixedRandom(0.999, () => { + expect(computeReconnectDelayMs(0)).toBeGreaterThanOrEqual(1000); + expect(computeReconnectDelayMs(0)).toBeLessThan(2000); + }); + }); + + it("doubles per attempt up to the 30s cap", () => { + withFixedRandom(0, () => { + // 1s, 2s, 4s, 8s, 16s, then capped at 30s + expect(computeReconnectDelayMs(0)).toBe(1_000); + expect(computeReconnectDelayMs(1)).toBe(2_000); + expect(computeReconnectDelayMs(2)).toBe(4_000); + expect(computeReconnectDelayMs(3)).toBe(8_000); + expect(computeReconnectDelayMs(4)).toBe(16_000); + // 32s would exceed the cap — should clamp to 30s. + expect(computeReconnectDelayMs(5)).toBe(RECONNECT_BACKOFF_MAX_MS); + // High attempt counts stay capped — protects against integer + // overflow on 2 ** N for large N. + expect(computeReconnectDelayMs(50)).toBe(RECONNECT_BACKOFF_MAX_MS); + expect(computeReconnectDelayMs(1_000)).toBe(RECONNECT_BACKOFF_MAX_MS); + }); + }); + + it("never exceeds RECONNECT_BACKOFF_MAX_MS + 1000ms (cap + jitter ceiling)", () => { + withFixedRandom(0.999, () => { + for (let attempt = 0; attempt < 100; attempt++) { + expect(computeReconnectDelayMs(attempt)).toBeLessThan( + RECONNECT_BACKOFF_MAX_MS + 1000 + ); + } + }); + }); + + it("adds 0–1000ms of jitter on top of the base", () => { + // Compare same attempt with random=0 vs random=0.5 — the difference is + // exactly the jitter. + withFixedRandom(0, () => { + expect(computeReconnectDelayMs(2)).toBe(4_000); + }); + withFixedRandom(0.5, () => { + expect(computeReconnectDelayMs(2)).toBe(4_500); + }); + withFixedRandom(0.999, () => { + const v = computeReconnectDelayMs(2); + expect(v).toBeGreaterThan(4_000); + expect(v).toBeLessThan(5_000); + }); + }); + + it("clamps negative / non-integer attempts to 0 (no NaN, no negative delay)", () => { + withFixedRandom(0, () => { + expect(computeReconnectDelayMs(-1)).toBe(1_000); + expect(computeReconnectDelayMs(-100)).toBe(1_000); + expect(computeReconnectDelayMs(0.7)).toBe(1_000); // floored to 0 + expect(computeReconnectDelayMs(2.9)).toBe(4_000); // floored to 2 + }); + }); +}); diff --git a/packages/core/src/v3/utils/reconnectBackoff.ts b/packages/core/src/v3/utils/reconnectBackoff.ts new file mode 100644 index 00000000000..51525591002 --- /dev/null +++ b/packages/core/src/v3/utils/reconnectBackoff.ts @@ -0,0 +1,25 @@ +/** + * Exponential backoff with full jitter for stream-tail reconnect loops. + * + * Shared between `SessionStreamManager` and `StandardInputStreamManager` + * — both reconnect a long-lived SSE tail when handlers/waiters are still + * registered, and both need to back off on persistent backend failures + * (auth rejection, 5xx, DNS) instead of reconnecting in a tight loop. + * + * - Base 1s, doubles per attempt (1s, 2s, 4s, 8s, 16s, 30s, 30s, ...) + * - Capped at 30s + * - Plus 0–1000ms jitter to avoid thundering herd when many clients + * share the same failure mode + * - Negative or non-integer attempts are clamped to 0 + * + * Callers track the per-key attempt count and reset to 0 on every + * successful record (any traffic flowing = healthy connection). + */ +export function computeReconnectDelayMs(attempt: number): number { + const safeAttempt = Math.max(0, Math.floor(attempt)); + const base = Math.min(1000 * 2 ** safeAttempt, 30_000); + return base + Math.random() * 1000; +} + +/** Maximum backoff floor without jitter — exposed for tests / asserts. */ +export const RECONNECT_BACKOFF_MAX_MS = 30_000; diff --git a/packages/core/src/v3/workers/index.ts b/packages/core/src/v3/workers/index.ts index e5f8eecff98..8ac06930328 100644 --- a/packages/core/src/v3/workers/index.ts +++ b/packages/core/src/v3/workers/index.ts @@ -33,3 +33,4 @@ export { StandardTraceContextManager } from "../traceContext/manager.js"; export { StandardHeartbeatsManager } from "../heartbeats/manager.js"; export { StandardRealtimeStreamsManager } from "../realtimeStreams/manager.js"; export { StandardInputStreamManager } from "../inputStreams/manager.js"; +export { StandardSessionStreamManager } from "../sessionStreams/manager.js"; diff --git a/packages/core/src/v3/workers/taskExecutor.ts b/packages/core/src/v3/workers/taskExecutor.ts index 2b9ffecf151..838ef3c6e77 100644 --- a/packages/core/src/v3/workers/taskExecutor.ts +++ b/packages/core/src/v3/workers/taskExecutor.ts @@ -17,6 +17,7 @@ import { lifecycleHooks, OTEL_SPAN_ATTRIBUTE_COUNT_LIMIT, runMetadata, + sessionStreams, traceContext, waitUntil, } from "../index.js"; @@ -1048,6 +1049,7 @@ export class TaskExecutor { ) { await this.#callCleanupFunctions(payload, ctx, initOutput, signal); inputStreams.clearHandlers(); + sessionStreams.clearHandlers(); await this.#blockForWaitUntil(); } diff --git a/packages/core/test/runStream.test.ts b/packages/core/test/runStream.test.ts index 0bf7f17432c..a953b7b694b 100644 --- a/packages/core/test/runStream.test.ts +++ b/packages/core/test/runStream.test.ts @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { RunSubscription, SSEStreamPart, + SSEStreamSubscription, StreamSubscription, StreamSubscriptionFactory, } from "../src/v3/apiClient/runStream.js"; @@ -470,6 +471,47 @@ describe("RunSubscription", () => { }); }); +describe("SSEStreamSubscription", () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("does not retry the initial fetch on 401", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 401 })); + global.fetch = fetchMock; + + const sub = new SSEStreamSubscription("https://api.test/realtime/v1/streams/run_x/chat", { + headers: { Authorization: "Bearer expired" }, + }); + + const stream = await sub.subscribe(); + const reader = stream.getReader(); + await expect(reader.read()).rejects.toMatchObject({ status: 401 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not retry the initial fetch on 403", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 403 })); + global.fetch = fetchMock; + + const sub = new SSEStreamSubscription("https://api.test/realtime/v1/streams/run_x/chat", { + headers: { Authorization: "Bearer denied" }, + }); + + const stream = await sub.subscribe(); + const reader = stream.getReader(); + await expect(reader.read()).rejects.toMatchObject({ status: 403 }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); + export async function convertAsyncIterableToArray(iterable: AsyncIterable): Promise { const result: T[] = []; for await (const item of iterable) { diff --git a/packages/trigger-sdk/src/v3/shared.ts b/packages/trigger-sdk/src/v3/shared.ts index c69bceeb535..0e6389a053c 100644 --- a/packages/trigger-sdk/src/v3/shared.ts +++ b/packages/trigger-sdk/src/v3/shared.ts @@ -90,6 +90,7 @@ import type { TaskWithToolOptions, ToolTask, ToolTaskParameters, + TriggerAndSubscribeOptions, TriggerAndWaitOptions, TriggerApiRequestOptions, TriggerOptions, @@ -214,6 +215,26 @@ export function createTask< }); }, params.id); }, + triggerAndSubscribe: (payload, options) => { + return new TaskRunPromise((resolve, reject) => { + triggerAndSubscribe_internal( + "triggerAndSubscribe()", + params.id, + payload, + undefined, + { + queue: params.queue?.name, + ...options, + } + ) + .then((result) => { + resolve(result); + }) + .catch((error) => { + reject(error); + }); + }, params.id); + }, batchTriggerAndWait: async (items, options) => { return await batchTriggerAndWait_internal( "batchTriggerAndWait()", @@ -235,6 +256,8 @@ export function createTask< queue: params.queue, retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined, machine: typeof params.machine === "string" ? { preset: params.machine } : params.machine, + triggerSource: params.triggerSource, + agentConfig: params.agentConfig, maxDuration: params.maxDuration, ttl: params.ttl, payloadSchema: params.jsonSchema, @@ -259,7 +282,7 @@ export function createTask< } /** - * @deprecated use ai.tool() instead + * @deprecated Use `schemaTask` plus AI SDK `tool()` with `execute: ai.toolExecute(task)` instead. */ export function createToolTask< TIdentifier extends string, @@ -346,6 +369,26 @@ export function createSchemaTask< }); }, params.id); }, + triggerAndSubscribe: (payload, options) => { + return new TaskRunPromise((resolve, reject) => { + triggerAndSubscribe_internal, TOutput>( + "triggerAndSubscribe()", + params.id, + payload, + parsePayload, + { + queue: params.queue?.name, + ...options, + } + ) + .then((result) => { + resolve(result); + }) + .catch((error) => { + reject(error); + }); + }, params.id); + }, batchTriggerAndWait: async (items, options) => { return await batchTriggerAndWait_internal, TOutput>( "batchTriggerAndWait()", @@ -367,6 +410,8 @@ export function createSchemaTask< queue: params.queue, retry: params.retry ? { ...defaultRetryOptions, ...params.retry } : undefined, machine: typeof params.machine === "string" ? { preset: params.machine } : params.machine, + triggerSource: params.triggerSource, + agentConfig: params.agentConfig, maxDuration: params.maxDuration, ttl: params.ttl, fns: { @@ -465,6 +510,51 @@ export function triggerAndWait( }, id); } +/** + * Trigger a task and subscribe to its updates via realtime. Unlike `triggerAndWait`, + * this does NOT suspend the parent run — the parent stays alive and subscribes to updates. + * This enables parallel execution and proper abort signal handling. + * + * @param id - The id of the task to trigger + * @param payload + * @param options - Options for the task run, including an optional `signal` to cancel the subscription and child run + * @returns TaskRunPromise + * @example + * ```ts + * import { tasks } from "@trigger.dev/sdk/v3"; + * const result = await tasks.triggerAndSubscribe("my-task", { foo: "bar" }); + * + * if (result.ok) { + * console.log(result.output); + * } else { + * console.error(result.error); + * } + * ``` + */ +export function triggerAndSubscribe( + id: TaskIdentifier, + payload: TaskPayload, + options?: TriggerAndSubscribeOptions, + requestOptions?: TriggerApiRequestOptions +): TaskRunPromise, TaskOutput> { + return new TaskRunPromise, TaskOutput>((resolve, reject) => { + triggerAndSubscribe_internal, TaskPayload, TaskOutput>( + "tasks.triggerAndSubscribe()", + id, + payload, + undefined, + options, + requestOptions + ) + .then((result) => { + resolve(result); + }) + .catch((error) => { + reject(error); + }); + }, id); +} + /** * Batch trigger multiple task runs with the given payloads, and wait for the results. Returns the results of the task runs. * @param id - The id of the task to trigger @@ -2441,6 +2531,153 @@ async function triggerAndWait_internal( + name: string, + id: TIdentifier, + payload: TPayload, + parsePayload?: SchemaParseFn, + options?: TriggerAndSubscribeOptions, + requestOptions?: TriggerApiRequestOptions +): Promise> { + const ctx = taskContext.ctx; + + if (!ctx) { + throw new Error("triggerAndSubscribe can only be used from inside a task.run()"); + } + + const apiClient = apiClientManager.clientOrThrow(requestOptions?.clientConfig); + + const parsedPayload = parsePayload ? await parsePayload(payload) : payload; + const payloadPacket = await stringifyIO(parsedPayload); + + const processedIdempotencyKey = await makeIdempotencyKey(options?.idempotencyKey); + const idempotencyKeyOptions = processedIdempotencyKey + ? getIdempotencyKeyOptions(processedIdempotencyKey) + : undefined; + + return await tracer.startActiveSpan( + name, + async (span) => { + const response = await apiClient.triggerTask( + id, + { + payload: payloadPacket.data, + options: { + lockToVersion: taskContext.worker?.version, + queue: options?.queue ? { name: options.queue } : undefined, + concurrencyKey: options?.concurrencyKey, + test: taskContext.ctx?.run.isTest, + payloadType: payloadPacket.dataType, + delay: options?.delay, + ttl: options?.ttl, + tags: options?.tags, + maxAttempts: options?.maxAttempts, + metadata: options?.metadata, + maxDuration: options?.maxDuration, + parentRunId: ctx.run.id, + // NOTE: no resumeParentOnCompletion — parent stays alive and subscribes + idempotencyKey: processedIdempotencyKey?.toString(), + idempotencyKeyTTL: options?.idempotencyKeyTTL, + idempotencyKeyOptions, + machine: options?.machine, + priority: options?.priority, + region: options?.region, + debounce: options?.debounce, + }, + }, + {} + ); + + // Set attributes after trigger so the dashboard can link to the child run + span.setAttribute("messaging.message.id", response.id); + span.setAttribute("runId", response.id); + span.setAttribute(SemanticInternalAttributes.ENTITY_TYPE, "run"); + span.setAttribute(SemanticInternalAttributes.ENTITY_ID, response.id); + + // Optionally cancel the child run when the abort signal fires (default: true) + const cancelOnAbort = options?.cancelOnAbort !== false; + let onAbort: (() => void) | undefined; + if (options?.signal && cancelOnAbort) { + if (options.signal.aborted) { + await apiClient.cancelRun(response.id).catch(() => {}); + throw new DOMException("Aborted", "AbortError"); + } + onAbort = () => { + apiClient.cancelRun(response.id).catch(() => {}); + }; + // `{ once: true }` auto-removes the listener on abort, but if the + // run completes normally the listener stays attached and pins + // `apiClient` + `response.id` until the signal is GC'd. Long-lived + // signals shared across many calls accumulate dead listeners; the + // `finally` below removes the listener on every exit path. + options.signal.addEventListener("abort", onAbort, { once: true }); + } + + try { + for await (const run of apiClient.subscribeToRun(response.id, { + closeOnComplete: true, + signal: options?.signal, + skipColumns: ["payload"], + })) { + if (run.isSuccess) { + // run.output from subscribeToRun is already deserialized + return { + ok: true as const, + id: response.id, + taskIdentifier: id as TIdentifier, + output: run.output as TOutput, + }; + } + if (run.isFailed || run.isCancelled) { + // Note: this intentionally diverges from `triggerAndWait`'s + // error shape. `triggerAndWait` receives a full `TaskRunError` + // (with `type` discriminator: BUILT_IN_ERROR, CUSTOM_ERROR, + // INTERNAL_ERROR, STRING_ERROR) via the completion message and + // passes it through `createErrorTaskError` to preserve the + // discriminator. `subscribeToRun` only surfaces a + // `SerializedError` (`{ name, message, stackTrace }`) because + // `createJsonErrorObject` strips the discriminator before the + // record hits the realtime stream. We can't reconstruct the + // discriminator here without lossy guessing — callers that + // need exact error-type matching should use `triggerAndWait` + // instead; subscribers get message + name only. + const error = new Error(run.error?.message ?? `Task ${id} failed (${run.status})`); + if (run.error?.name) error.name = run.error.name; + + return { + ok: false as const, + id: response.id, + taskIdentifier: id as TIdentifier, + error, + }; + } + } + + throw new Error(`Task ${id}: subscription ended without completion`); + } finally { + if (onAbort && options?.signal) { + options.signal.removeEventListener("abort", onAbort); + } + } + }, + { + kind: SpanKind.PRODUCER, + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "trigger", + ...accessoryAttributes({ + items: [ + { + text: id, + variant: "normal", + }, + ], + style: "codepath", + }), + }, + } + ); +} + async function batchTriggerAndWait_internal( name: string, id: TIdentifier, diff --git a/packages/trigger-sdk/src/v3/streams.ts b/packages/trigger-sdk/src/v3/streams.ts index 68edc2a64ab..6ccaea8891a 100644 --- a/packages/trigger-sdk/src/v3/streams.ts +++ b/packages/trigger-sdk/src/v3/streams.ts @@ -25,8 +25,10 @@ import { InputStreamOncePromise, type InputStreamOnceResult, type InputStreamWaitOptions, + type InputStreamWaitWithIdleTimeoutOptions, type SendInputStreamOptions, type InferInputStreamType, + type StreamWriteResult, } from "@trigger.dev/core/v3"; import { conditionallyImportAndParsePacket } from "@trigger.dev/core/v3/utils/ioSerialization"; import { tracer } from "./tracer.js"; @@ -139,7 +141,7 @@ function pipe( opts = valueOrOptions as PipeStreamOptions | undefined; } - return pipeInternal(key, value, opts, "streams.pipe()"); + return pipeInternal(key, value, opts, opts?.spanName ?? "streams.pipe()"); } /** @@ -167,6 +169,7 @@ function pipeInternal( [SemanticInternalAttributes.ENTITY_TYPE]: "realtime-stream", [SemanticInternalAttributes.ENTITY_ID]: `${runId}:${key}`, [SemanticInternalAttributes.STYLE_ICON]: "streams", + ...(opts?.collapsed ? { [SemanticInternalAttributes.COLLAPSED]: true } : {}), ...accessoryAttributes({ items: [ { @@ -194,7 +197,9 @@ function pipeInternal( return { stream: instance.stream, - waitUntilComplete: () => instance.wait(), + waitUntilComplete: async () => { + return instance.wait(); + }, }; } catch (error) { // if the error is a signal abort error, we need to end the span but not record an exception @@ -640,7 +645,7 @@ function writerInternal(key: string, options: WriterStreamOptions) } }); - return pipeInternal(key, stream, options, "streams.writer()"); + return pipeInternal(key, stream, options, options.spanName ?? "streams.writer()"); } export type RealtimeDefineStreamOptions = { @@ -656,8 +661,18 @@ function define(opts: RealtimeDefineStreamOptions): RealtimeDefinedStream read(runId, options) { return read(runId, opts.id, options); }, - append(value, options) { - return append(opts.id, value as BodyInit, options); + async append(value, options) { + // Use a single-write writer so objects are serialized the same way + // as stream.writer() — the raw append API sends BodyInit which + // doesn't serialize objects correctly for SSE consumers. + const { waitUntilComplete } = writer(opts.id, { + ...options, + spanName: "streams.append()", + execute: ({ write }) => { + write(value); + }, + }); + await waitUntilComplete(); }, writer(options) { return writer(opts.id, options); @@ -713,7 +728,7 @@ function input(opts: { id: string }): RealtimeDefinedInputStream { return new InputStreamOncePromise((resolve, reject) => { tracer .startActiveSpan( - `inputStream.once()`, + options?.spanName ?? `inputStream.once()`, async () => { const result = await innerPromise; resolve(result as InputStreamOnceResult); @@ -750,23 +765,21 @@ function input(opts: { id: string }): RealtimeDefinedInputStream { const apiClient = apiClientManager.clientOrThrow(); + // Create the waitpoint before the span so we have the entity ID upfront + const response = await apiClient.createInputStreamWaitpoint(ctx.run.id, { + streamId: opts.id, + timeout: options?.timeout, + idempotencyKey: options?.idempotencyKey, + idempotencyKeyTTL: options?.idempotencyKeyTTL, + tags: options?.tags, + lastSeqNum: inputStreams.lastSeqNum(opts.id), + }); + const result = await tracer.startActiveSpan( - `inputStream.wait()`, + options?.spanName ?? `inputStream.wait()`, async (span) => { - // 1. Create a waitpoint linked to this input stream - const response = await apiClient.createInputStreamWaitpoint(ctx.run.id, { - streamId: opts.id, - timeout: options?.timeout, - idempotencyKey: options?.idempotencyKey, - idempotencyKeyTTL: options?.idempotencyKeyTTL, - tags: options?.tags, - lastSeqNum: inputStreams.lastSeqNum(opts.id), - }); - // Set the entity ID now that we have the waitpoint ID - span.setAttribute(SemanticInternalAttributes.ENTITY_ID, response.waitpointId); - - // 2. Block the run on the waitpoint + // 1. Block the run on the waitpoint const waitResponse = await apiClient.waitForWaitpointToken({ runFriendlyId: ctx.run.id, waitpointFriendlyId: response.waitpointId, @@ -776,6 +789,12 @@ function input(opts: { id: string }): RealtimeDefinedInputStream { throw new Error("Failed to block on input stream waitpoint"); } + // 2. Disconnect the SSE tail and clear the buffer before suspending. + // Without this, the tail stays alive during the suspension window and + // may buffer a copy of the same message that will be delivered via the + // waitpoint, causing a duplicate on resume. + inputStreams.disconnectStream(opts.id); + // 3. Suspend the task const waitResult = await runtime.waitUntil(response.waitpointId); @@ -792,6 +811,12 @@ function input(opts: { id: string }): RealtimeDefinedInputStream { : undefined; if (waitResult.ok) { + // Advance the seq counter so the SSE tail doesn't replay + // the record that was consumed via the waitpoint path when + // it lazily reconnects on the next on()/once() call. + const prevSeq = inputStreams.lastSeqNum(opts.id); + inputStreams.setLastSeqNum(opts.id, (prevSeq ?? -1) + 1); + return { ok: true as const, output: data as TData }; } else { const error = new WaitpointTimeoutError(data?.message ?? "Timed out"); @@ -806,6 +831,7 @@ function input(opts: { id: string }): RealtimeDefinedInputStream { attributes: { [SemanticInternalAttributes.STYLE_ICON]: "wait", [SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint", + [SemanticInternalAttributes.ENTITY_ID]: response.waitpointId, streamId: opts.id, ...accessoryAttributes({ items: [ @@ -826,6 +852,70 @@ function input(opts: { id: string }): RealtimeDefinedInputStream { } }); }, + async waitWithIdleTimeout(options) { + const self = this; + const spanName = options.spanName ?? `inputStream.waitWithIdleTimeout()`; + + return tracer.startActiveSpan( + spanName, + async (span) => { + // Idle phase: keep compute alive + if (options.idleTimeoutInSeconds > 0) { + const warm = await inputStreams.once(opts.id, { + timeoutMs: options.idleTimeoutInSeconds * 1000, + }); + if (warm.ok) { + span.setAttribute("wait.resolved", "idle"); + return { ok: true as const, output: warm.output as TData }; + } + } + + // Skip suspend if requested — return a real WaitpointTimeoutError + // so the result shape matches the cold-phase `self.wait()` path + // below. Callers that check `if (!result.ok)` work the same as + // before; callers that do `throw result.error` get a useful error + // instead of `undefined`. + if (options.skipSuspend) { + span.setAttribute("wait.resolved", "skipped"); + return { + ok: false as const, + error: new WaitpointTimeoutError( + "Idle timeout elapsed and skipSuspend is set" + ), + }; + } + + // Fire onSuspend callback before entering cold phase + if (options.onSuspend) { + await options.onSuspend(); + } + + // Cold phase: suspend via .wait() — creates a child span + span.setAttribute("wait.resolved", "suspended"); + const waitResult = await self.wait({ + timeout: options.timeout, + spanName: "suspended", + }); + + // Fire onResume callback after successful resume + if (waitResult.ok && options.onResume) { + await options.onResume(); + } + + return waitResult; + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "streams", + streamId: opts.id, + ...accessoryAttributes({ + items: [{ text: opts.id, variant: "normal" }], + style: "codepath", + }), + }, + } + ); + }, async send(runId, data, options) { return tracer.startActiveSpan( `inputStream.send()`, diff --git a/packages/trigger-sdk/src/v3/tasks.ts b/packages/trigger-sdk/src/v3/tasks.ts index 75b7e85e625..5781a104229 100644 --- a/packages/trigger-sdk/src/v3/tasks.ts +++ b/packages/trigger-sdk/src/v3/tasks.ts @@ -20,6 +20,7 @@ import { SubtaskUnwrapError, trigger, triggerAndWait, + triggerAndSubscribe, } from "./shared.js"; export { SubtaskUnwrapError }; @@ -96,6 +97,7 @@ export const tasks = { trigger, batchTrigger, triggerAndWait, + triggerAndSubscribe, batchTriggerAndWait, /** @deprecated Use onStartAttempt instead */ onStart, From 09f5354a03bce7d34d98285d07437b045a0d460c Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Thu, 14 May 2026 13:24:50 +0100 Subject: [PATCH 012/238] fix(core): cap idempotencyKey length at the API boundary (#3560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tasks.trigger`, `tasks.batchTrigger`, `batch.create`, `wait.createToken`, `wait.forDuration`, and the input/session stream waitpoint endpoints all accept a caller-supplied `idempotencyKey` and store it verbatim against a composite-unique index on `TaskRun`, `BatchTaskRun`, or `Waitpoint`. The schemas had no length cap, so a sufficiently long high-entropy key produced an index row larger than the underlying storage layer can hold. The insert failed at the database, and the caller saw a generic 500 from `RunEngineTriggerTaskService.call()` / `CreateBatchService` / waitpoint creation, depending on the endpoint. Keys produced by `idempotencyKeys.create()` are 64-character SHA-256 hashes and never trip this — it only manifests for direct REST callers (or SDK callers passing a raw string they generated themselves). Low-entropy keys also sail through, because the storage layer compresses repeated bytes before they reach the index, which is why the failure mode is intermittent and tied to caller-side key shape. ## Fix Add `.max(2048, " must be 2048 characters or less")` to the seven schemas that feed an indexed `idempotencyKey` column: - `TriggerTaskRequestBody.options.idempotencyKey` - `BatchTriggerTaskItem.options.idempotencyKey` - `CreateBatchRequestBody.idempotencyKey` - `CreateWaitpointTokenRequestBody.idempotencyKey` - `CreateInputStreamWaitpointRequestBody.idempotencyKey` - `CreateSessionStreamWaitpointRequestBody.idempotencyKey` - `WaitForDurationRequestBody.idempotencyKey` Plus the `idempotency-key` HTTP header on the trigger route (and the three batch routes that re-export `HeadersSchema`). The header schema is lifted out of `api.v1.tasks.$taskId.trigger.ts` into `apps/webapp/app/v3/triggerHeaders.server.ts` so it can be exercised in tests without dragging the route's import-time side effects. The 2048 character ceiling is chosen to sit safely under the per-row index limit while staying generous against existing callers — keys that fit before still fit. Oversized keys now return a structured Zod 400 instead of a generic 500. Limit is documented under `Idempotency key` in `docs/limits.mdx` and as a `` on `docs/idempotency.mdx`. ## Test plan - [x] 15 schema unit tests added (`packages/core/src/v3/schemas/idempotencyKey.test.ts`, `apps/webapp/test/routes/triggerHeaders.test.ts`) — rejection-with-message + boundary acceptance for each capped schema. The webapp test exercises the extracted `TriggerHeadersSchema` directly with no mocks. - [x] `pnpm run build --filter @trigger.dev/core` - [x] `pnpm run typecheck --filter webapp` - [x] End-to-end verified locally: baseline (small key) → 200; 3000-char high-entropy header → 400 with the expected Zod error; same key at the 2048 boundary → 200; same key with the cap reverted → the database rejected the insert and the route returned 500 to the caller. Cap restored. Co-authored-by: Claude Opus 4.7 (1M context) --- .changeset/cap-idempotency-key-length.md | 5 + .../routes/api.v1.tasks.$taskId.trigger.ts | 5 +- docs/idempotency.mdx | 4 + packages/core/src/v3/schemas/api.ts | 56 +++++- .../src/v3/schemas/idempotencyKey.test.ts | 173 ++++++++++++++++++ 5 files changed, 235 insertions(+), 8 deletions(-) create mode 100644 .changeset/cap-idempotency-key-length.md create mode 100644 packages/core/src/v3/schemas/idempotencyKey.test.ts diff --git a/.changeset/cap-idempotency-key-length.md b/.changeset/cap-idempotency-key-length.md new file mode 100644 index 00000000000..d1360369148 --- /dev/null +++ b/.changeset/cap-idempotency-key-length.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Reject overlong `idempotencyKey` values at the API boundary so they no longer trip an internal size limit on the underlying unique index and surface as a generic 500. Inputs are capped at 2048 characters — well above what `idempotencyKeys.create()` produces (a 64-character hash) and above any realistic raw key. Applies to `tasks.trigger`, `tasks.batchTrigger`, `batch.create` (Phase 1 streaming batches), `wait.createToken`, `wait.forDuration`, and the input/session stream waitpoint endpoints. Over-limit requests now return a structured 400 instead. diff --git a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts index ee1ed0393ca..8206a90f320 100644 --- a/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts +++ b/apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts @@ -28,7 +28,10 @@ const ParamsSchema = z.object({ }); export const HeadersSchema = z.object({ - "idempotency-key": z.string().nullish(), + "idempotency-key": z + .string() + .max(2048, "idempotency-key must be 2048 characters or less") + .nullish(), "idempotency-key-ttl": z.string().nullish(), "trigger-version": z.string().nullish(), "x-trigger-span-parent-as-link": z.coerce.number().nullish(), diff --git a/docs/idempotency.mdx b/docs/idempotency.mdx index 034246eafbf..0d61341697a 100644 --- a/docs/idempotency.mdx +++ b/docs/idempotency.mdx @@ -108,6 +108,10 @@ When you pass a raw string, it defaults to `"run"` scope (scoped to the parent r Make sure you provide sufficiently unique keys to avoid collisions. + +Idempotency keys are limited to 2048 characters. Keys produced by `idempotencyKeys.create()` are 64-character hashes and always fit; this limit only matters if you pass a long raw string. Requests above the limit return `400`. + + You can pass the `idempotencyKey` when calling `batchTrigger` as well: ```ts diff --git a/packages/core/src/v3/schemas/api.ts b/packages/core/src/v3/schemas/api.ts index 0db92a67c64..90cf30eed35 100644 --- a/packages/core/src/v3/schemas/api.ts +++ b/packages/core/src/v3/schemas/api.ts @@ -197,7 +197,13 @@ export const TriggerTaskRequestBody = z.object({ .optional(), concurrencyKey: z.string().optional(), delay: z.string().or(z.coerce.date()).optional(), - idempotencyKey: z.string().optional(), + idempotencyKey: z + .string() + // Caps user-supplied keys before they reach the unique idempotency index + // on the underlying table — values past this fail at the database layer + // rather than returning a clean 400. + .max(2048, "idempotencyKey must be 2048 characters or less") + .optional(), idempotencyKeyTTL: z.string().optional(), /** The original user-provided idempotency key and scope */ idempotencyKeyOptions: IdempotencyKeyOptionsSchema.optional(), @@ -249,7 +255,13 @@ export const BatchTriggerTaskItem = z.object({ .object({ concurrencyKey: z.string().optional(), delay: z.string().or(z.coerce.date()).optional(), - idempotencyKey: z.string().optional(), + idempotencyKey: z + .string() + // Caps user-supplied keys before they reach the unique idempotency index + // on the underlying table — values past this fail at the database layer + // rather than returning a clean 400. + .max(2048, "idempotencyKey must be 2048 characters or less") + .optional(), idempotencyKeyTTL: z.string().optional(), /** The original user-provided idempotency key and scope */ idempotencyKeyOptions: IdempotencyKeyOptionsSchema.optional(), @@ -358,7 +370,13 @@ export const CreateBatchRequestBody = z.object({ /** Whether to resume parent on completion (true for batchTriggerAndWait) */ resumeParentOnCompletion: z.boolean().optional(), /** Idempotency key for the batch */ - idempotencyKey: z.string().optional(), + idempotencyKey: z + .string() + // Caps user-supplied keys before they reach the unique idempotency index + // on the underlying table — values past this fail at the database layer + // rather than returning a clean 400. + .max(2048, "idempotencyKey must be 2048 characters or less") + .optional(), /** The original user-provided idempotency key and scope */ idempotencyKeyOptions: IdempotencyKeyOptionsSchema.optional(), }); @@ -1350,7 +1368,13 @@ export const CreateWaitpointTokenRequestBody = z.object({ * * Note: This waitpoint may already be complete, in which case when you wait for it, it will immediately continue. */ - idempotencyKey: z.string().optional(), + idempotencyKey: z + .string() + // Caps user-supplied keys before they reach the unique idempotency index + // on the underlying table — values past this fail at the database layer + // rather than returning a clean 400. + .max(2048, "idempotencyKey must be 2048 characters or less") + .optional(), /** * When set, this means the passed in idempotency key will expire after this time. * This means after that time if you pass the same idempotency key again, you will get a new waitpoint. @@ -1389,7 +1413,13 @@ export type CreateWaitpointTokenResponseBody = z.infer { + describe("TriggerTaskRequestBody", () => { + it("rejects an idempotencyKey over 2048 characters with a clear message", () => { + const result = TriggerTaskRequestBody.safeParse({ + payload: {}, + options: { idempotencyKey: TOO_LONG }, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.path).toEqual(["options", "idempotencyKey"]); + expect(issue.message).toBe("idempotencyKey must be 2048 characters or less"); + } + }); + + it("accepts an idempotencyKey at the 2048-character limit", () => { + const result = TriggerTaskRequestBody.safeParse({ + payload: {}, + options: { idempotencyKey: AT_LIMIT }, + }); + + expect(result.success).toBe(true); + }); + + it("accepts the SDK-generated 64-character hash", () => { + const result = TriggerTaskRequestBody.safeParse({ + payload: {}, + options: { idempotencyKey: SDK_HASH }, + }); + + expect(result.success).toBe(true); + }); + }); + + describe("BatchTriggerTaskItem", () => { + it("rejects an idempotencyKey over 2048 characters", () => { + const result = BatchTriggerTaskItem.safeParse({ + task: "my-task", + payload: {}, + options: { idempotencyKey: TOO_LONG }, + }); + + expect(result.success).toBe(false); + }); + + it("accepts an idempotencyKey at the 2048-character limit", () => { + const result = BatchTriggerTaskItem.safeParse({ + task: "my-task", + payload: {}, + options: { idempotencyKey: AT_LIMIT }, + }); + + expect(result.success).toBe(true); + }); + }); + + describe("CreateBatchRequestBody", () => { + it("rejects an idempotencyKey over 2048 characters with a clear message", () => { + const result = CreateBatchRequestBody.safeParse({ + runCount: 1, + idempotencyKey: TOO_LONG, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.path).toEqual(["idempotencyKey"]); + expect(issue.message).toBe("idempotencyKey must be 2048 characters or less"); + } + }); + + it("accepts an idempotencyKey at the 2048-character limit", () => { + const result = CreateBatchRequestBody.safeParse({ + runCount: 1, + idempotencyKey: AT_LIMIT, + }); + + expect(result.success).toBe(true); + }); + }); + + describe("CreateWaitpointTokenRequestBody", () => { + it("rejects an idempotencyKey over 2048 characters with a clear message", () => { + const result = CreateWaitpointTokenRequestBody.safeParse({ + idempotencyKey: TOO_LONG, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.path).toEqual(["idempotencyKey"]); + expect(issue.message).toBe("idempotencyKey must be 2048 characters or less"); + } + }); + + it("accepts an idempotencyKey at the 2048-character limit", () => { + const result = CreateWaitpointTokenRequestBody.safeParse({ + idempotencyKey: AT_LIMIT, + }); + + expect(result.success).toBe(true); + }); + }); + + describe("CreateInputStreamWaitpointRequestBody", () => { + it("rejects an idempotencyKey over 2048 characters with a clear message", () => { + const result = CreateInputStreamWaitpointRequestBody.safeParse({ + streamId: "stream_1", + idempotencyKey: TOO_LONG, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.path).toEqual(["idempotencyKey"]); + expect(issue.message).toBe("idempotencyKey must be 2048 characters or less"); + } + }); + }); + + describe("CreateSessionStreamWaitpointRequestBody", () => { + it("rejects an idempotencyKey over 2048 characters with a clear message", () => { + const result = CreateSessionStreamWaitpointRequestBody.safeParse({ + session: "session_1", + io: "out", + idempotencyKey: TOO_LONG, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.path).toEqual(["idempotencyKey"]); + expect(issue.message).toBe("idempotencyKey must be 2048 characters or less"); + } + }); + }); + + describe("WaitForDurationRequestBody", () => { + it("rejects an idempotencyKey over 2048 characters with a clear message", () => { + const result = WaitForDurationRequestBody.safeParse({ + date: new Date(), + idempotencyKey: TOO_LONG, + }); + + expect(result.success).toBe(false); + if (!result.success) { + const issue = result.error.issues[0]!; + expect(issue.path).toEqual(["idempotencyKey"]); + expect(issue.message).toBe("idempotencyKey must be 2048 characters or less"); + } + }); + }); +}); From 9caf4ceaf19b4c5a7733a18751e2c91bc93be58f Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 14 May 2026 14:37:55 +0100 Subject: [PATCH 013/238] ci: skip typecheck for workflow-only PRs (#3619) The `code` paths filter currently matches `**` minus a tiny exclusion list, so a PR that only touches `.github/workflows/*.yml` still flips `code == true` and runs typecheck (~2 min on the runner). Exclude `.github/**` from `code`, then re-include just `pr_checks.yml` and `typecheck.yml` so a change to either of those still triggers the full code check matrix. Effect: - workflow-only PRs (this one, future dependabot/codeql/etc.) skip typecheck; `all-checks` treats the skipped job as non-failure so the required status passes. - modifying `pr_checks.yml` or `typecheck.yml` themselves still triggers typecheck. - the existing per-suite filters (`webapp`, `packages`, `internal`, `cli`, `sdk`) already re-include the specific workflows that gate them, so they're unaffected. --- .github/workflows/pr_checks.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml index f49ed4f2c09..5754f6500e3 100644 --- a/.github/workflows/pr_checks.yml +++ b/.github/workflows/pr_checks.yml @@ -33,7 +33,9 @@ jobs: - '!docs/**' - '!.changeset/**' - '!hosting/**' - - '!.github/workflows/helm-prerelease.yml' + - '!.github/**' + - '.github/workflows/pr_checks.yml' + - '.github/workflows/typecheck.yml' webapp: - 'apps/webapp/**' - 'packages/**' From b62c8a19c28b02319f81f31d2aba8dc413d37daa Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 14 May 2026 14:44:37 +0100 Subject: [PATCH 014/238] ci: add dependabot weekly summary workflow (#3616) Adds a Mon 08:00 UTC workflow that posts a summary of open Dependabot alerts and PRs to Slack. Uses env-scoped secrets so the alerts PAT and Slack token are only available to this workflow. --- .../workflows/dependabot-weekly-summary.yml | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 .github/workflows/dependabot-weekly-summary.yml diff --git a/.github/workflows/dependabot-weekly-summary.yml b/.github/workflows/dependabot-weekly-summary.yml new file mode 100644 index 00000000000..fb2717e2fb0 --- /dev/null +++ b/.github/workflows/dependabot-weekly-summary.yml @@ -0,0 +1,206 @@ +name: Dependabot Weekly Summary + +on: + schedule: + - cron: "0 8 * * 1" # Mon 08:00 UTC + workflow_dispatch: + +# Single-purpose monitoring workflow; serialise on workflow name only - we never +# want two concurrent summary runs racing to post the same digest. +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read # gh CLI baseline + pull-requests: read # gh pr list (open dependabot PRs) + actions: read # gh run list / view (parse latest dependabot run logs) + +jobs: + summary: + name: Post weekly Dependabot summary + runs-on: ubuntu-latest + environment: dependabot-summary + env: + # Severities surface in the actions list when their remaining TTR drops + # below this many days. Override via repo/env var ACTION_THRESHOLD_DAYS. + THRESHOLD_DAYS: ${{ vars.ACTION_THRESHOLD_DAYS || '7' }} + steps: + - name: Fetch alerts and compute summaries + id: alerts + env: + GH_TOKEN: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }} + REPO: ${{ github.repository }} + run: | + if ! gh api -X GET "/repos/$REPO/dependabot/alerts" --paginate > pages.json 2> err.txt; then + echo "total=?" >> "$GITHUB_OUTPUT" + ERR=$(head -c 200 err.txt | tr '\n' ' ') + echo "by_severity=:x: _failed to fetch alerts: ${ERR}_" >> "$GITHUB_OUTPUT" + echo "actions=:x: _alerts unavailable_" >> "$GITHUB_OUTPUT" + exit 0 + fi + jq -s '[.[][] | select(.state == "open")]' pages.json > open.json + + TOTAL=$(jq 'length' open.json) + echo "total=$TOTAL" >> "$GITHUB_OUTPUT" + + if [ "$TOTAL" = "0" ]; then + echo "by_severity=:white_check_mark: No open alerts." >> "$GITHUB_OUTPUT" + echo "actions=_None_" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Severity breakdown - real newlines so jq --arg in the payload + # builder encodes them as proper \n in JSON (Slack renders as breaks). + BY_SEV=$(jq -r ' + group_by(.security_advisory.severity) + | map({sev: .[0].security_advisory.severity, + count: length, + weight: ({"critical":0,"high":1,"medium":2,"low":3}[.[0].security_advisory.severity])}) + | sort_by(.weight) + | map("• *\(.count)* \(.sev)") + | join("\n") + ' open.json) + { + echo "by_severity<> "$GITHUB_OUTPUT" + + # Actions: alerts within THRESHOLD_DAYS of their TTR (P0=7d, P1=30d, P2=90d, P3=no deadline) + # Grouped by (package, severity); shows earliest deadline per group. + ACTIONS=$(jq -r --argjson threshold "$THRESHOLD_DAYS" ' + [.[] + | (.security_advisory.severity) as $sev + | ({"critical":7,"high":30,"medium":90,"low":null}[$sev]) as $ttr + | select($ttr != null) + | ((now - (.created_at | fromdateiso8601)) / 86400 | floor) as $age + | {pkg: .dependency.package.name, sev: $sev, remaining: ($ttr - $age)} + ] + | group_by([.pkg, .sev]) + | map({pkg: .[0].pkg, sev: .[0].sev, count: length, min_remaining: ([.[].remaining] | min)}) + | map(select(.min_remaining < $threshold)) + | sort_by(.min_remaining) + | if length == 0 then "_None_" + else (map( + "• *\(.pkg)* (\(.sev))" + + (if .count > 1 then " ×\(.count)" else "" end) + " - " + + (if .min_remaining < 0 then "*OVERDUE* by \(-.min_remaining)d" + else "\(.min_remaining)d remaining" end) + ) | join("\n")) + end + ' open.json) + { + echo "actions<> "$GITHUB_OUTPUT" + + - name: Fetch open dependabot PRs + id: prs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + REPO_URL: https://github.com/${{ github.repository }} + run: | + if ! PR_JSON=$(gh pr list --repo "$REPO" --state open --author "app/dependabot" --json number,title 2> err.txt); then + ERR=$(head -c 200 err.txt | tr '\n' ' ') + echo "list=:x: _failed to fetch PRs: ${ERR}_" >> "$GITHUB_OUTPUT" + exit 0 + fi + LIST=$(echo "$PR_JSON" | jq -r --arg url "$REPO_URL" ' + if length == 0 then "_None_" + else (map("• <\($url)/pull/\(.number)|#\(.number)> \(.title)") | join("\n")) + end + ') + { + echo "list<> "$GITHUB_OUTPUT" + + - name: Find latest npm dependabot run + id: latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + # Repos without a dependabot.yml have no "Dependabot Updates" workflow; + # treat the lookup failure as "no recent run found" rather than failing. + if ! RUN_ID=$(gh run list --repo "$REPO" --workflow "Dependabot Updates" --status success --limit 30 --json databaseId,name --jq 'first(.[] | select(.name | startswith("npm_and_yarn")) | .databaseId) // empty' 2>/dev/null); then + RUN_ID="" + fi + echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" + + - name: Extract stuck deps (only if actions pending) + id: stuck + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + RUN_ID: ${{ steps.latest.outputs.run_id }} + ACTIONS: ${{ steps.alerts.outputs.actions }} + run: | + # Skip the stuck section entirely when nothing in the actions list + # - keeps the digest tidy when there's nothing to actually act on. + if [ "$ACTIONS" = "_None_" ]; then + echo "section=" >> "$GITHUB_OUTPUT" + exit 0 + fi + HEADER=$'\n\n*Couldn\'t auto-fix (need manual `pnpm.overrides`):*\n' + if [ -z "$RUN_ID" ]; then + { + echo "section<> "$GITHUB_OUTPUT" + exit 0 + fi + gh run view "$RUN_ID" --repo "$REPO" --log > log.txt 2>&1 || true + STUCK=$(grep -oE "No update possible for [^[:space:]]+ [0-9][^[:space:]]*" log.txt | sed 's/No update possible for //' | sort -u || true) + if [ -z "$STUCK" ]; then + { + echo "section<> "$GITHUB_OUTPUT" + exit 0 + fi + LIST=$(echo "$STUCK" | awk 'NR>1{printf "\n"} {printf "• *%s* %s", $1, $2}') + { + echo "section<> "$GITHUB_OUTPUT" + + - name: Build Slack payload + env: + REPO: ${{ github.repository }} + CHANNEL: ${{ vars.SLACK_CHANNEL_ID }} + TOTAL: ${{ steps.alerts.outputs.total }} + BY_SEVERITY: ${{ steps.alerts.outputs.by_severity }} + PRS_LIST: ${{ steps.prs.outputs.list }} + ACTIONS: ${{ steps.alerts.outputs.actions }} + STUCK: ${{ steps.stuck.outputs.section }} + run: | + # Build payload via jq so PR titles or error strings containing + # quotes/backslashes/newlines can't break the JSON. + jq -n \ + --arg channel "$CHANNEL" \ + --arg repo "$REPO" \ + --arg total "$TOTAL" \ + --arg by_severity "$BY_SEVERITY" \ + --arg prs_list "$PRS_LIST" \ + --arg actions "$ACTIONS" \ + --arg stuck "$STUCK" \ + --arg threshold "$THRESHOLD_DAYS" \ + '{ + channel: $channel, + text: ":calendar: *Weekly Dependabot summary* - `\($repo)`\n\n*Open alerts (\($total)):*\n\($by_severity)\n\n*Open Dependabot PRs:*\n\($prs_list)\n\n*Actions needed (<\($threshold)d remaining):*\n\($actions)\($stuck)\n\n" + }' > payload.json + + - name: Post Slack summary + uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: payload.json From 16720a5e626ee73999deb7398696289ab041a1d7 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sun, 10 May 2026 22:26:59 +0100 Subject: [PATCH 015/238] =?UTF-8?q?feat(sdk):=20chat.agent=20=E2=80=94=20r?= =?UTF-8?q?untime=20+=20browser=20transport?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the chat.agent({...}) task definition (server runtime) and the browser-side TriggerChatTransport + AgentChat that drives it from a React or Next.js app. The runtime sits on top of the Sessions primitive and handles the durable conversational task lifecycle. Server runtime: - chat.agent({...}) — session-aware task definition - Lifecycle hooks: onChatStart, onTurnStart, onTurnComplete, onAction, onValidateMessages, hydrateMessages - chat.history read primitives for HITL flows - chat.local, chat.headStart, chat.handover, oomMachine - Delta-only wire + S3 snapshot reconstruction at run boot - Actions are no longer turns Browser transport: - TriggerChatTransport (ai-sdk Transport): delta-only wire sends, SSE reconnection with lastEventId resume, stop/abort cleanup, dynamic accessToken refresh - AgentChat: direct programmatic API - useTriggerChatTransport (React hook) - chat-tab-coordinator: cross-tab leader election Includes the chat-agent, chat-agent-delta-wire-snapshots, chat-history-read-primitives, chat-head-start, chat-actions-no-turn, chat-session-attributes, agent-skills, and mock-chat-agent-test-harness changesets. --- .changeset/agent-skills.md | 16 + .changeset/chat-actions-no-turn.md | 33 + .changeset/chat-agent-delta-wire-snapshots.md | 8 + .changeset/chat-agent-on-boot-hook.md | 21 + .changeset/chat-agent.md | 30 + .changeset/chat-head-start.md | 34 + .changeset/chat-history-read-primitives.md | 21 + .changeset/chat-session-attributes.md | 6 + .changeset/mock-chat-agent-test-harness.md | 8 + .../app/components/code/AIQueryInput.tsx | 14 +- .../components/code/StreamdownRenderer.tsx | 29 + apps/webapp/app/components/code/shikiTheme.ts | 222 + .../components/runs/v3/PromptSpanDetails.tsx | 13 +- .../components/runs/v3/ai/AIChatMessages.tsx | 173 +- .../components/runs/v3/ai/AISpanDetails.tsx | 13 +- .../webapp/app/components/runs/v3/ai/types.ts | 5 + .../AIPayloadTabContent.tsx | 34 +- apps/webapp/package.json | 4 +- .../test/chat-snapshot-integration.test.ts | 235 + apps/webapp/test/replay-after-crash.test.ts | 315 + package.json | 3 +- packages/build/package.json | 17 +- packages/build/src/extensions/secureExec.ts | 172 + packages/build/src/internal.ts | 1 + .../build/src/internal/additionalFiles.ts | 96 +- packages/build/src/internal/copyFiles.ts | 99 + packages/core/package.json | 15 + .../core/src/v3/resource-catalog/catalog.ts | 12 +- .../core/src/v3/resource-catalog/index.ts | 21 +- .../resource-catalog/noopResourceCatalog.ts | 21 +- .../standardResourceCatalog.ts | 70 +- .../core/src/v3/taskContext/index.test.ts | 86 + packages/core/src/v3/taskContext/index.ts | 20 + .../core/src/v3/taskContext/otelProcessors.ts | 16 + packages/core/src/v3/test/index.ts | 9 + .../core/src/v3/test/mock-task-context.ts | 294 + packages/core/test/mockTaskContext.test.ts | 226 + packages/core/test/skillCatalog.test.ts | 74 + packages/trigger-sdk/package.json | 89 +- .../trigger-sdk/src/v3/agentSkillsRuntime.ts | 166 + packages/trigger-sdk/src/v3/ai-shared.ts | 200 + packages/trigger-sdk/src/v3/ai.ts | 8754 ++++++++++++++++- packages/trigger-sdk/src/v3/auth.ts | 11 + packages/trigger-sdk/src/v3/chat-client.ts | 797 ++ packages/trigger-sdk/src/v3/chat-react.ts | 457 + .../trigger-sdk/src/v3/chat-server.test.ts | 617 ++ packages/trigger-sdk/src/v3/chat-server.ts | 915 ++ .../src/v3/chat-tab-coordinator.test.ts | 176 + .../src/v3/chat-tab-coordinator.ts | 268 + packages/trigger-sdk/src/v3/chat.test.ts | 1193 +++ packages/trigger-sdk/src/v3/chat.ts | 1264 +++ packages/trigger-sdk/src/v3/deployments.ts | 56 + packages/trigger-sdk/src/v3/index.ts | 13 +- packages/trigger-sdk/src/v3/runs.ts | 9 + packages/trigger-sdk/src/v3/sessions.ts | 751 ++ packages/trigger-sdk/src/v3/skill.ts | 211 + packages/trigger-sdk/src/v3/skills.ts | 9 + packages/trigger-sdk/src/v3/test/index.ts | 23 + .../src/v3/test/mock-chat-agent.ts | 738 ++ .../trigger-sdk/src/v3/test/setup-catalog.ts | 16 + .../src/v3/test/test-session-handle.ts | 268 + .../trigger-sdk/test/chat-snapshot.test.ts | 279 + .../trigger-sdk/test/chatHandover.test.ts | 370 + packages/trigger-sdk/test/merge-by-id.test.ts | 158 + .../trigger-sdk/test/mockChatAgent.test.ts | 1542 +++ .../test/replay-session-out.test.ts | 307 + packages/trigger-sdk/test/skill.test.ts | 86 + .../trigger-sdk/test/skillsRuntime.test.ts | 221 + packages/trigger-sdk/test/wire-shape.test.ts | 249 + patches/streamdown@2.5.0.patch | 14 + pnpm-lock.yaml | 1458 ++- pnpm-workspace.yaml | 2 + 72 files changed, 23897 insertions(+), 276 deletions(-) create mode 100644 .changeset/agent-skills.md create mode 100644 .changeset/chat-actions-no-turn.md create mode 100644 .changeset/chat-agent-delta-wire-snapshots.md create mode 100644 .changeset/chat-agent-on-boot-hook.md create mode 100644 .changeset/chat-agent.md create mode 100644 .changeset/chat-head-start.md create mode 100644 .changeset/chat-history-read-primitives.md create mode 100644 .changeset/chat-session-attributes.md create mode 100644 .changeset/mock-chat-agent-test-harness.md create mode 100644 apps/webapp/app/components/code/StreamdownRenderer.tsx create mode 100644 apps/webapp/app/components/code/shikiTheme.ts create mode 100644 apps/webapp/test/chat-snapshot-integration.test.ts create mode 100644 apps/webapp/test/replay-after-crash.test.ts create mode 100644 packages/build/src/extensions/secureExec.ts create mode 100644 packages/build/src/internal/copyFiles.ts create mode 100644 packages/core/src/v3/taskContext/index.test.ts create mode 100644 packages/core/src/v3/test/index.ts create mode 100644 packages/core/src/v3/test/mock-task-context.ts create mode 100644 packages/core/test/mockTaskContext.test.ts create mode 100644 packages/core/test/skillCatalog.test.ts create mode 100644 packages/trigger-sdk/src/v3/agentSkillsRuntime.ts create mode 100644 packages/trigger-sdk/src/v3/ai-shared.ts create mode 100644 packages/trigger-sdk/src/v3/chat-client.ts create mode 100644 packages/trigger-sdk/src/v3/chat-react.ts create mode 100644 packages/trigger-sdk/src/v3/chat-server.test.ts create mode 100644 packages/trigger-sdk/src/v3/chat-server.ts create mode 100644 packages/trigger-sdk/src/v3/chat-tab-coordinator.test.ts create mode 100644 packages/trigger-sdk/src/v3/chat-tab-coordinator.ts create mode 100644 packages/trigger-sdk/src/v3/chat.test.ts create mode 100644 packages/trigger-sdk/src/v3/chat.ts create mode 100644 packages/trigger-sdk/src/v3/deployments.ts create mode 100644 packages/trigger-sdk/src/v3/sessions.ts create mode 100644 packages/trigger-sdk/src/v3/skill.ts create mode 100644 packages/trigger-sdk/src/v3/skills.ts create mode 100644 packages/trigger-sdk/src/v3/test/index.ts create mode 100644 packages/trigger-sdk/src/v3/test/mock-chat-agent.ts create mode 100644 packages/trigger-sdk/src/v3/test/setup-catalog.ts create mode 100644 packages/trigger-sdk/src/v3/test/test-session-handle.ts create mode 100644 packages/trigger-sdk/test/chat-snapshot.test.ts create mode 100644 packages/trigger-sdk/test/chatHandover.test.ts create mode 100644 packages/trigger-sdk/test/merge-by-id.test.ts create mode 100644 packages/trigger-sdk/test/mockChatAgent.test.ts create mode 100644 packages/trigger-sdk/test/replay-session-out.test.ts create mode 100644 packages/trigger-sdk/test/skill.test.ts create mode 100644 packages/trigger-sdk/test/skillsRuntime.test.ts create mode 100644 packages/trigger-sdk/test/wire-shape.test.ts create mode 100644 patches/streamdown@2.5.0.patch diff --git a/.changeset/agent-skills.md b/.changeset/agent-skills.md new file mode 100644 index 00000000000..5ed3b11fc2f --- /dev/null +++ b/.changeset/agent-skills.md @@ -0,0 +1,16 @@ +--- +"@trigger.dev/sdk": patch +"@trigger.dev/core": patch +"@trigger.dev/build": patch +"trigger.dev": patch +--- + +Add Agent Skills for `chat.agent`. Drop a folder with a `SKILL.md` and any helper scripts/references next to your task code, register it with `skills.define({ id, path })`, and the CLI bundles it into the deploy image automatically — no `trigger.config.ts` changes. The agent gets a one-line summary in its system prompt and discovers full instructions on demand via `loadSkill`, with `bash` and `readFile` tools scoped per-skill (path-traversal guards, output caps, abort-signal propagation). + +```ts +const pdfSkill = skills.define({ id: "pdf-extract", path: "./skills/pdf-extract" }); + +chat.skills.set([await pdfSkill.local()]); +``` + +Built on the [AI SDK cookbook pattern](https://ai-sdk.dev/cookbook/guides/agent-skills) — portable across providers. SDK + CLI only for now; dashboard-editable `SKILL.md` text is on the roadmap. diff --git a/.changeset/chat-actions-no-turn.md b/.changeset/chat-actions-no-turn.md new file mode 100644 index 00000000000..a0113441520 --- /dev/null +++ b/.changeset/chat-actions-no-turn.md @@ -0,0 +1,33 @@ +--- +"@trigger.dev/sdk": minor +--- + +`chat.agent` actions are no longer treated as turns. They fire `hydrateMessages` and `onAction` only — no `onTurnStart` / `prepareMessages` / `onBeforeTurnComplete` / `onTurnComplete`, no `run()`, no turn-counter increment. The trace span is named `chat action` instead of `chat turn N`. + +`onAction` can now return a `StreamTextResult`, `string`, or `UIMessage` to produce a model response from the action; returning `void` (the previous and now default) is side-effect-only. + +**Migration**: if you previously had `run()` branching on `payload.trigger === "action"`, return your `streamText(...)` from `onAction` instead. If you persisted in `onTurnComplete`, do that work inside `onAction`. For any other state-only action, just remove your skip-the-model workaround — the default is now correct. + +```ts +// before +onAction: async ({ action }) => { + if (action.type === "regenerate") { + chat.store.set({ skipModelCall: false }); + chat.history.slice(0, -1); + } +}, +run: async ({ messages, signal }) => { + if (chat.store.get()?.skipModelCall) return; + return streamText({ model, messages, abortSignal: signal }); +}, + +// after +onAction: async ({ action, messages, signal }) => { + if (action.type === "regenerate") { + chat.history.slice(0, -1); + return streamText({ model, messages, abortSignal: signal }); + } +}, +run: async ({ messages, signal }) => + streamText({ model, messages, abortSignal: signal }), +``` diff --git a/.changeset/chat-agent-delta-wire-snapshots.md b/.changeset/chat-agent-delta-wire-snapshots.md new file mode 100644 index 00000000000..21a8fd01fa4 --- /dev/null +++ b/.changeset/chat-agent-delta-wire-snapshots.md @@ -0,0 +1,8 @@ +--- +"@trigger.dev/sdk": patch +"@trigger.dev/core": patch +--- + +`chat.agent` wire is now delta-only — clients ship at most one new message per `.in/append` instead of the full `UIMessage[]` history. The agent rebuilds prior history at run boot from a JSON snapshot in object storage plus a `wait=0` replay of the `session.out` tail. Long chats stop hitting the 512 KiB body cap on `/realtime/v1/sessions/{id}/in/append`. Snapshot writes happen after every `onTurnComplete`, awaited so they survive idle suspend; reads happen only at run boot. Registering a `hydrateMessages` hook short-circuits both the snapshot read/write and the replay — the customer is the source of truth for history. + +Custom transports that constructed `ChatTaskWirePayload` directly need to drop the `messages: UIMessage[]` field and use `message?: UIMessage` (singular). Built-in transports (`TriggerChatTransport`, `AgentChat`) handle the change below the customer-facing surface — most apps need no changes. Configure object-store env vars (`OBJECT_STORE_*`) on your webapp deployment if you haven't already; without an object store and without `hydrateMessages`, conversations don't survive run boundaries. diff --git a/.changeset/chat-agent-on-boot-hook.md b/.changeset/chat-agent-on-boot-hook.md new file mode 100644 index 00000000000..86715b31b74 --- /dev/null +++ b/.changeset/chat-agent-on-boot-hook.md @@ -0,0 +1,21 @@ +--- +"@trigger.dev/sdk": minor +--- + +Adds `onBoot` to `chat.agent` — a lifecycle hook that fires once per worker process picking up the chat. Runs for the initial run, preloaded runs, AND reactive continuation runs (post-cancel, crash, `endRun`, `requestUpgrade`, OOM retry), before any other hook. Use it to initialize `chat.local`, open per-process resources, or re-hydrate state from your DB on continuation — anywhere the SAME run picking up after suspend/resume isn't enough. + +```ts +const userContext = chat.local<{ name: string; plan: string }>({ id: "userContext" }); + +export const myChat = chat.agent({ + id: "my-chat", + onBoot: async ({ clientData, continuation }) => { + const user = await db.user.findUnique({ where: { id: clientData.userId } }); + userContext.init({ name: user.name, plan: user.plan }); + }, + run: async ({ messages, signal }) => + streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }), +}); +``` + +If you previously initialized `chat.local` in `onChatStart`, move it to `onBoot` — `onChatStart` is once-per-chat and won't fire on a continuation, leaving `chat.local` uninitialized when `run()` tries to use it. See the upgrade guide for the migration pattern. diff --git a/.changeset/chat-agent.md b/.changeset/chat-agent.md new file mode 100644 index 00000000000..9ca65682da7 --- /dev/null +++ b/.changeset/chat-agent.md @@ -0,0 +1,30 @@ +--- +"@trigger.dev/sdk": minor +"@trigger.dev/core": patch +--- + +Run AI chats as durable Trigger.dev tasks. Define the agent in one function, wire `useChat` to it from React, and the conversation survives page refreshes, network blips, and process restarts — with built-in support for tools, HITL approvals, multi-turn state, and stop-mid-stream cancellation. + +```ts +import { chat } from "@trigger.dev/sdk/ai"; +import { streamText } from "ai"; +import { openai } from "@ai-sdk/openai"; + +export const myChat = chat.agent({ + id: "my-chat", + run: async ({ messages, signal }) => + streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }), +}); +``` + +```tsx +import { useChat } from "@ai-sdk/react"; +import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; + +const transport = useTriggerChatTransport({ task: "my-chat", accessToken }); +const { messages, sendMessage } = useChat({ transport }); +``` + +Lifecycle hooks (`onPreload`, `onTurnStart`, `onTurnComplete`, etc.) cover the common needs around persistence, validation, and post-turn work. `chat.store` gives you a typed shared-data slot the agent and client both read and write. `chat.endRun()` exits cleanly when the agent decides it's done. The transport's `watch` mode lets a dashboard tab observe a run without driving it. + +Drops the pre-Sessions chat stream constants (`CHAT_STREAM_KEY`, `CHAT_MESSAGES_STREAM_ID`, `CHAT_STOP_STREAM_ID`) — migrate to `sessions.open(id).out` / `.in`. diff --git a/.changeset/chat-head-start.md b/.changeset/chat-head-start.md new file mode 100644 index 00000000000..5e33344493f --- /dev/null +++ b/.changeset/chat-head-start.md @@ -0,0 +1,34 @@ +--- +"@trigger.dev/sdk": minor +--- + +Add `chat.headStart` — an opt-in fast-path that runs the first turn's `streamText` step in your warm Next.js / Hono / Workers / Express handler while the trigger agent run boots in parallel. Cold-start TTFC drops by ~50% on the first message; the agent owns step 2+ (tool execution, persistence, hooks) so heavy deps stay where they belong. + +```ts +// app/api/chat/route.ts (Next.js / any Web Fetch framework) +import { chat } from "@trigger.dev/sdk/chat-server"; +import { streamText } from "ai"; +import { openai } from "@ai-sdk/openai"; +import { headStartTools } from "@/lib/chat-tools-schemas"; // schema-only + +export const POST = chat.headStart({ + agentId: "ai-chat", + run: async ({ chat: chatHelper }) => + streamText({ + ...chatHelper.toStreamTextOptions({ tools: headStartTools }), + model: openai("gpt-4o-mini"), + system: "You are a helpful AI assistant.", + }), +}); +``` + +```tsx +// browser — opt in by pointing the transport at your handler +const transport = useTriggerChatTransport({ + task: "ai-chat", + accessToken, + headStart: "/api/chat", // first-turn-only; turn 2+ bypasses the endpoint +}); +``` + +For Node-only frameworks (Express, Fastify, Koa, raw `node:http`) use `chat.toNodeListener(handler)` to bridge the Web Fetch handler to `(req, res)`. Adds a new `@trigger.dev/sdk/chat-server` subpath; bundle stays Web Fetch–only with no `node:*` imports. diff --git a/.changeset/chat-history-read-primitives.md b/.changeset/chat-history-read-primitives.md new file mode 100644 index 00000000000..fd26ad8548b --- /dev/null +++ b/.changeset/chat-history-read-primitives.md @@ -0,0 +1,21 @@ +--- +"@trigger.dev/sdk": minor +--- + +Add read primitives to `chat.history` for HITL flows: `getPendingToolCalls()`, `getResolvedToolCalls()`, `extractNewToolResults(message)`, `getChain()`, and `findMessage(messageId)`. These lift the accumulator-walking logic that customers building human-in-the-loop tools were re-implementing into the SDK. + +Use `getPendingToolCalls()` to gate fresh user turns while a tool call is awaiting an answer. Use `extractNewToolResults(message)` to dedup tool results when persisting to your own store — the helper returns only the parts whose `toolCallId` is not already resolved on the chain. + +```ts +const pending = chat.history.getPendingToolCalls(); +if (pending.length > 0) { + // an addToolOutput is expected before a new user message +} + +onTurnComplete: async ({ responseMessage }) => { + const newResults = chat.history.extractNewToolResults(responseMessage); + for (const r of newResults) { + await db.toolResults.upsert({ id: r.toolCallId, output: r.output, errorText: r.errorText }); + } +}; +``` diff --git a/.changeset/chat-session-attributes.md b/.changeset/chat-session-attributes.md new file mode 100644 index 00000000000..ec4c6a54076 --- /dev/null +++ b/.changeset/chat-session-attributes.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/sdk": patch +"@trigger.dev/core": patch +--- + +Stamp `gen_ai.conversation.id` (the chat id) on every span and metric emitted from inside a `chat.task` or `chat.agent` run. Lets you filter dashboard spans, runs, and metrics by the chat conversation that produced them — independent of the run boundary, so multi-run chats correlate cleanly. No code changes required on the user side. diff --git a/.changeset/mock-chat-agent-test-harness.md b/.changeset/mock-chat-agent-test-harness.md new file mode 100644 index 00000000000..9876e56a9f7 --- /dev/null +++ b/.changeset/mock-chat-agent-test-harness.md @@ -0,0 +1,8 @@ +--- +"@trigger.dev/sdk": patch +"@trigger.dev/core": patch +--- + +Unit-test `chat.agent` definitions offline with `mockChatAgent` from `@trigger.dev/sdk/ai/test`. Drives a real agent's turn loop in-process — no network, no task runtime — so you can send messages, actions, and stop signals via driver methods, inspect captured output chunks, and verify hooks fire. Pairs with `MockLanguageModelV3` from `ai/test` for model mocking. `setupLocals` lets you pre-seed `locals` (DB clients, service stubs) before `run()` starts. + +The broader `runInMockTaskContext` harness it's built on lives at `@trigger.dev/core/v3/test` — useful for unit-testing any task code, not just chat. diff --git a/apps/webapp/app/components/code/AIQueryInput.tsx b/apps/webapp/app/components/code/AIQueryInput.tsx index 0775ec2c2a0..cd5e9db3bd8 100644 --- a/apps/webapp/app/components/code/AIQueryInput.tsx +++ b/apps/webapp/app/components/code/AIQueryInput.tsx @@ -1,25 +1,15 @@ import { CheckIcon, PencilSquareIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { AnimatePresence, motion } from "framer-motion"; -import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"; +import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { Button } from "~/components/primitives/Buttons"; import { Spinner } from "~/components/primitives/Spinner"; +import { StreamdownRenderer } from "~/components/code/StreamdownRenderer"; import { useEnvironment } from "~/hooks/useEnvironment"; import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types"; import { cn } from "~/utils/cn"; -// Lazy load streamdown components to avoid SSR issues -const StreamdownRenderer = lazy(() => - import("streamdown").then((mod) => ({ - default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => ( - - {children} - - ), - })) -); - type StreamEventType = | { type: "thinking"; content: string } | { type: "tool_call"; tool: string; args: unknown } diff --git a/apps/webapp/app/components/code/StreamdownRenderer.tsx b/apps/webapp/app/components/code/StreamdownRenderer.tsx new file mode 100644 index 00000000000..996234ab180 --- /dev/null +++ b/apps/webapp/app/components/code/StreamdownRenderer.tsx @@ -0,0 +1,29 @@ +import { lazy } from "react"; +import type { CodeHighlighterPlugin } from "streamdown"; + +export const StreamdownRenderer = lazy(() => + Promise.all([import("streamdown"), import("@streamdown/code"), import("./shikiTheme")]).then( + ([{ Streamdown }, { createCodePlugin }, { triggerDarkTheme }]) => { + // Type assertion needed: @streamdown/code and streamdown resolve different shiki + // versions under pnpm, causing structurally-identical CodeHighlighterPlugin types + // to be considered incompatible (different BundledLanguage string unions). + const codePlugin = createCodePlugin({ + themes: [triggerDarkTheme, triggerDarkTheme], + }) as unknown as CodeHighlighterPlugin; + + return { + default: ({ + children, + isAnimating = false, + }: { + children: string; + isAnimating?: boolean; + }) => ( + + {children} + + ), + }; + } + ) +); diff --git a/apps/webapp/app/components/code/shikiTheme.ts b/apps/webapp/app/components/code/shikiTheme.ts new file mode 100644 index 00000000000..5d47155b979 --- /dev/null +++ b/apps/webapp/app/components/code/shikiTheme.ts @@ -0,0 +1,222 @@ +import type { ThemeRegistrationAny } from "streamdown"; + +// Custom Shiki theme matching the Trigger.dev VS Code dark theme. +// Colors taken directly from the VS Code extension's tokenColors. +export const triggerDarkTheme: ThemeRegistrationAny = { + name: "trigger-dark", + type: "dark", + colors: { + "editor.background": "#212327", + "editor.foreground": "#878C99", + "editorLineNumber.foreground": "#484c54", + }, + tokenColors: [ + // Control flow keywords: pink-purple + { + scope: [ + "keyword.control", + "keyword.operator.delete", + "keyword.other.using", + "keyword.other.operator", + "entity.name.operator", + ], + settings: { foreground: "#E888F8" }, + }, + // Storage type (const, let, var, function, class): purple + { + scope: "storage.type", + settings: { foreground: "#8271ED" }, + }, + // Storage modifiers (async, export, etc.): purple + { + scope: ["storage.modifier", "keyword.operator.noexcept"], + settings: { foreground: "#8271ED" }, + }, + // Keyword operator expressions (new, typeof, instanceof, etc.): purple + { + scope: [ + "keyword.operator.new", + "keyword.operator.expression", + "keyword.operator.cast", + "keyword.operator.sizeof", + "keyword.operator.instanceof", + "keyword.operator.logical.python", + "keyword.operator.wordlike", + ], + settings: { foreground: "#8271ED" }, + }, + // Types and namespaces: hot pink + { + scope: [ + "support.class", + "support.type", + "entity.name.type", + "entity.name.namespace", + "entity.name.scope-resolution", + "entity.name.class", + "entity.other.inherited-class", + ], + settings: { foreground: "#F770C6" }, + }, + // Functions: lime/yellow-green + { + scope: ["entity.name.function", "support.function"], + settings: { foreground: "#D9F07C" }, + }, + // Variables and parameters: light lavender + { + scope: [ + "variable", + "meta.definition.variable.name", + "support.variable", + "entity.name.variable", + "constant.other.placeholder", + ], + settings: { foreground: "#CCCBFF" }, + }, + // Constants and enums: medium purple + { + scope: ["variable.other.constant", "variable.other.enummember"], + settings: { foreground: "#9C9AF2" }, + }, + // this/self: purple-blue + { + scope: "variable.language", + settings: { foreground: "#9B99FF" }, + }, + // Object literal keys: medium purple-blue + { + scope: "meta.object-literal.key", + settings: { foreground: "#8B89FF" }, + }, + // Strings: sage green + { + scope: ["string", "meta.embedded.assembly"], + settings: { foreground: "#AFEC73" }, + }, + // String interpolation punctuation: blue-purple + { + scope: [ + "punctuation.definition.template-expression.begin", + "punctuation.definition.template-expression.end", + "punctuation.section.embedded", + ], + settings: { foreground: "#7A78EA" }, + }, + // Template expression reset + { + scope: "meta.template.expression", + settings: { foreground: "#d4d4d4" }, + }, + // Operators: gray (same as foreground) + { + scope: "keyword.operator", + settings: { foreground: "#878C99" }, + }, + // Comments: olive gray + { + scope: "comment", + settings: { foreground: "#6f736d" }, + }, + // Language constants (true, false, null, undefined): purple-blue + { + scope: "constant.language", + settings: { foreground: "#9B99FF" }, + }, + // Numeric constants: light green + { + scope: [ + "constant.numeric", + "keyword.operator.plus.exponent", + "keyword.operator.minus.exponent", + ], + settings: { foreground: "#b5cea8" }, + }, + // Regex: dark red + { + scope: "constant.regexp", + settings: { foreground: "#646695" }, + }, + // HTML/JSX tags: purple-blue + { + scope: "entity.name.tag", + settings: { foreground: "#9B99FF" }, + }, + // Tag brackets: dark gray + { + scope: "punctuation.definition.tag", + settings: { foreground: "#5F6570" }, + }, + // HTML/JSX attributes: light purple + { + scope: "entity.other.attribute-name", + settings: { foreground: "#C39EFF" }, + }, + // Escape characters: gold + { + scope: "constant.character.escape", + settings: { foreground: "#d7ba7d" }, + }, + // Regex string: dark red + { + scope: "string.regexp", + settings: { foreground: "#d16969" }, + }, + // Storage: purple-blue + { + scope: "storage", + settings: { foreground: "#9B99FF" }, + }, + // TS-specific: type casts, math/dom/json constants + { + scope: [ + "meta.type.cast.expr", + "meta.type.new.expr", + "support.constant.math", + "support.constant.dom", + "support.constant.json", + ], + settings: { foreground: "#9B99FF" }, + }, + // Markdown headings: purple-blue bold + { + scope: "markup.heading", + settings: { foreground: "#9B99FF", fontStyle: "bold" }, + }, + // Markup bold: purple-blue + { + scope: "markup.bold", + settings: { foreground: "#9B99FF", fontStyle: "bold" }, + }, + // Markup inline raw: sage green + { + scope: "markup.inline.raw", + settings: { foreground: "#AFEC73" }, + }, + // Markup inserted: light green + { + scope: "markup.inserted", + settings: { foreground: "#b5cea8" }, + }, + // Markup deleted: sage green + { + scope: "markup.deleted", + settings: { foreground: "#AFEC73" }, + }, + // Markup changed: purple-blue + { + scope: "markup.changed", + settings: { foreground: "#9B99FF" }, + }, + // Invalid: red + { + scope: "invalid", + settings: { foreground: "#f44747" }, + }, + // JSX text content + { + scope: ["meta.jsx.children"], + settings: { foreground: "#D7D9DD" }, + }, + ], +}; diff --git a/apps/webapp/app/components/runs/v3/PromptSpanDetails.tsx b/apps/webapp/app/components/runs/v3/PromptSpanDetails.tsx index 9645087b859..a78a0e183ed 100644 --- a/apps/webapp/app/components/runs/v3/PromptSpanDetails.tsx +++ b/apps/webapp/app/components/runs/v3/PromptSpanDetails.tsx @@ -1,5 +1,6 @@ -import { lazy, Suspense, useState } from "react"; +import { Suspense, useState } from "react"; import { CodeBlock } from "~/components/code/CodeBlock"; +import { StreamdownRenderer } from "~/components/code/StreamdownRenderer"; import { Header3 } from "~/components/primitives/Headers"; import { TextLink } from "~/components/primitives/TextLink"; import { tryPrettyJson } from "./ai/aiHelpers"; @@ -12,16 +13,6 @@ import { TabButton, TabContainer } from "~/components/primitives/Tabs"; import type { PromptSpanData } from "~/presenters/v3/SpanPresenter.server"; import { SpanHorizontalTimeline } from "~/components/runs/v3/SpanHorizontalTimeline"; -const StreamdownRenderer = lazy(() => - import("streamdown").then((mod) => ({ - default: ({ children }: { children: string }) => ( - - {children} - - ), - })) -); - type PromptTab = "overview" | "input" | "template"; export function PromptSpanDetails({ diff --git a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx index 297234b8d05..72539cd7910 100644 --- a/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AIChatMessages.tsx @@ -5,24 +5,14 @@ import { ClipboardDocumentIcon, CodeBracketSquareIcon, } from "@heroicons/react/20/solid"; -import { lazy, Suspense, useState } from "react"; +import { Suspense, useEffect, useState } from "react"; import { CodeBlock } from "~/components/code/CodeBlock"; +import { StreamdownRenderer } from "~/components/code/StreamdownRenderer"; import { Button, LinkButton } from "~/components/primitives/Buttons"; import { Header3 } from "~/components/primitives/Headers"; import tablerSpritePath from "~/components/primitives/tabler-sprite.svg"; import type { DisplayItem, ToolUse } from "./types"; -// Lazy load streamdown to avoid SSR issues -const StreamdownRenderer = lazy(() => - import("streamdown").then((mod) => ({ - default: ({ children }: { children: string }) => ( - - {children} - - ), - })) -); - export type PromptLink = { slug: string; version?: string; @@ -221,7 +211,7 @@ export function AssistantResponse({ /> {mode === "rendered" ? ( -
+
{text}}> {text} @@ -257,30 +247,59 @@ function ToolUseSection({ tools }: { tools: ToolUse[] }) { ); } -type ToolTab = "input" | "output" | "details"; +type ToolTab = "input" | "output" | "details" | "agent"; -function ToolUseRow({ tool }: { tool: ToolUse }) { +export function ToolUseRow({ tool }: { tool: ToolUse }) { const hasInput = tool.inputJson !== "{}"; const hasResult = !!tool.resultOutput; const hasDetails = !!tool.description || !!tool.parametersJson; + const hasSubAgent = !!tool.subAgent; const availableTabs: ToolTab[] = [ + ...(hasSubAgent ? (["agent"] as const) : []), ...(hasInput ? (["input"] as const) : []), ...(hasResult ? (["output"] as const) : []), ...(hasDetails ? (["details"] as const) : []), ]; - const defaultTab: ToolTab | null = hasInput ? "input" : null; - const [activeTab, setActiveTab] = useState(defaultTab); + const [activeTab, setActiveTab] = useState( + hasSubAgent ? "agent" : hasInput ? "input" : null + ); + + // Auto-select input tab when input arrives after initial render (e.g. streaming tool calls) + useEffect(() => { + if (!hasSubAgent && hasInput && activeTab === null) { + setActiveTab("input"); + } + }, [hasInput, hasSubAgent]); function handleTabClick(tab: ToolTab) { setActiveTab(activeTab === tab ? null : tab); } return ( -
+
- {tool.toolName} + {hasSubAgent && ( + + + + )} + + {tool.toolName} + + {hasSubAgent && tool.subAgent?.isStreaming && ( + + + streaming + + )} {tool.resultSummary && ( {tool.resultSummary} )} @@ -288,7 +307,11 @@ function ToolUseRow({ tool }: { tool: ToolUse }) { {availableTabs.length > 0 && ( <> -
+
{availableTabs.map((tab) => (
); } + +function SubAgentContent({ parts }: { parts: any[] }) { + // Extract sub-agent run ID from injected metadata part + const runPart = parts.find( + (p: any) => p.type === "data-subagent-run" && p.data?.runId + ); + const subAgentRunId = runPart?.data?.runId as string | undefined; + + return ( +
+ {subAgentRunId && ( +
+ + View sub-agent run + +
+ )} + {parts.map((part: any, j: number) => { + const partType = part.type as string; + + // Skip the injected metadata part — already rendered above + if (partType === "data-subagent-run") return null; + + if (partType === "text" && part.text) { + return ; + } + + if (partType === "step-start") { + return ( +
+
+ step +
+
+ ); + } + + if (partType.startsWith("tool-")) { + const subToolName = partType.slice(5); + return ( + + ); + } + + if (partType === "reasoning" && part.text) { + return ( +
+
+ {part.text} +
+
+ ); + } + + return null; + })} +
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/ai/AISpanDetails.tsx b/apps/webapp/app/components/runs/v3/ai/AISpanDetails.tsx index 5e8bb65688f..c243a1e4d9b 100644 --- a/apps/webapp/app/components/runs/v3/ai/AISpanDetails.tsx +++ b/apps/webapp/app/components/runs/v3/ai/AISpanDetails.tsx @@ -1,6 +1,7 @@ import { CheckIcon, ClipboardDocumentIcon } from "@heroicons/react/20/solid"; -import { lazy, Suspense, useState } from "react"; +import { Suspense, useState } from "react"; import { Button } from "~/components/primitives/Buttons"; +import { StreamdownRenderer } from "~/components/code/StreamdownRenderer"; import { Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { TabButton, TabContainer } from "~/components/primitives/Tabs"; @@ -20,16 +21,6 @@ import type { AISpanData, DisplayItem } from "./types"; import type { PromptSpanData } from "~/presenters/v3/SpanPresenter.server"; import { SpanHorizontalTimeline } from "~/components/runs/v3/SpanHorizontalTimeline"; -const StreamdownRenderer = lazy(() => - import("streamdown").then((mod) => ({ - default: ({ children }: { children: string }) => ( - - {children} - - ), - })) -); - type AITab = "overview" | "messages" | "tools" | "prompt"; export function AISpanDetails({ diff --git a/apps/webapp/app/components/runs/v3/ai/types.ts b/apps/webapp/app/components/runs/v3/ai/types.ts index bb0fd7e74b1..c59c87865d2 100644 --- a/apps/webapp/app/components/runs/v3/ai/types.ts +++ b/apps/webapp/app/components/runs/v3/ai/types.ts @@ -22,6 +22,11 @@ export type ToolUse = { resultSummary?: string; /** Full formatted result for display in a code block */ resultOutput?: string; + /** Sub-agent output — when the tool result is a UIMessage with parts */ + subAgent?: { + parts: any[]; + isStreaming: boolean; + }; }; // --------------------------------------------------------------------------- diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx index 3d9302356cc..6fc50a41280 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent.tsx @@ -1,8 +1,9 @@ import { CheckIcon, XMarkIcon } from "@heroicons/react/20/solid"; import { AnimatePresence, motion } from "framer-motion"; -import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react"; +import { Suspense, useCallback, useEffect, useRef, useState } from "react"; import { SparkleListIcon } from "~/assets/icons/SparkleListIcon"; import { Button } from "~/components/primitives/Buttons"; +import { StreamdownRenderer } from "~/components/code/StreamdownRenderer"; import { Header3 } from "~/components/primitives/Headers"; import { Paragraph } from "~/components/primitives/Paragraph"; import { Spinner } from "~/components/primitives/Spinner"; @@ -11,16 +12,6 @@ import { useOrganization } from "~/hooks/useOrganizations"; import { useProject } from "~/hooks/useProject"; import { cn } from "~/utils/cn"; -const StreamdownRenderer = lazy(() => - import("streamdown").then((mod) => ({ - default: ({ children, isAnimating }: { children: string; isAnimating: boolean }) => ( - - {children} - - ), - })) -); - type StreamEventType = | { type: "thinking"; content: string } | { type: "result"; success: true; payload: string } @@ -31,11 +22,19 @@ export function AIPayloadTabContent({ payloadSchema, taskIdentifier, getCurrentPayload, + generateButtonLabel = "Generate payload", + placeholder, + examplePromptsOverride, + isAgent = false, }: { onPayloadGenerated: (payload: string) => void; payloadSchema?: unknown; taskIdentifier: string; getCurrentPayload?: () => string; + generateButtonLabel?: string; + placeholder?: string; + examplePromptsOverride?: string[]; + isAgent?: boolean; }) { const [prompt, setPrompt] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -73,6 +72,7 @@ export function AIPayloadTabContent({ const formData = new FormData(); formData.append("prompt", queryPrompt); formData.append("taskIdentifier", taskIdentifier); + formData.append("isAgent", isAgent ? "true" : "false"); if (payloadSchema) { formData.append("payloadSchema", JSON.stringify(payloadSchema)); } @@ -144,7 +144,7 @@ export function AIPayloadTabContent({ setIsLoading(false); } }, - [resourcePath, taskIdentifier, payloadSchema, getCurrentPayload] + [resourcePath, taskIdentifier, payloadSchema, getCurrentPayload, isAgent] ); const processStreamEvent = useCallback( @@ -191,7 +191,7 @@ export function AIPayloadTabContent({ } }, [error]); - const examplePrompts = payloadSchema + const examplePrompts = examplePromptsOverride ?? (payloadSchema ? [ "Generate a valid payload", "Generate a payload with edge cases", @@ -201,7 +201,7 @@ export function AIPayloadTabContent({ "Generate a simple JSON payload", "Generate a payload with nested objects", "Generate a payload with an array of items", - ]; + ]); return (
@@ -215,9 +215,9 @@ export function AIPayloadTabContent({ ref={textareaRef} name="prompt" placeholder={ - payloadSchema + placeholder ?? (payloadSchema ? "e.g. generate a payload for a new user signup" - : "e.g. generate a JSON payload with name, email, and age fields" + : "e.g. generate a JSON payload with name, email, and age fields") } value={prompt} onChange={(e) => setPrompt(e.target.value)} @@ -251,7 +251,7 @@ export function AIPayloadTabContent({ className={cn(!prompt.trim() && "opacity-50")} onClick={() => handleSubmit()} > - Generate payload + {generateButtonLabel} )}
diff --git a/apps/webapp/package.json b/apps/webapp/package.json index b536d35d443..198ce88b9f5 100644 --- a/apps/webapp/package.json +++ b/apps/webapp/package.json @@ -28,6 +28,7 @@ ], "dependencies": { "@ai-sdk/openai": "^1.3.23", + "@ai-sdk/react": "^3.0.0", "@ariakit/react": "^0.4.6", "@ariakit/react-core": "^0.4.6", "@aws-sdk/client-ecr": "^3.931.0", @@ -219,7 +220,8 @@ "sonner": "^1.0.3", "sql-formatter": "^15.4.10", "sqs-consumer": "^7.4.0", - "streamdown": "^1.4.0", + "@streamdown/code": "^1.1.1", + "streamdown": "^2.5.0", "superjson": "^2.2.1", "tailwind-merge": "^1.12.0", "tailwind-scrollbar-hide": "^1.1.7", diff --git a/apps/webapp/test/chat-snapshot-integration.test.ts b/apps/webapp/test/chat-snapshot-integration.test.ts new file mode 100644 index 00000000000..3d157d58f9f --- /dev/null +++ b/apps/webapp/test/chat-snapshot-integration.test.ts @@ -0,0 +1,235 @@ +// Plan F.3: integration test that round-trips a `ChatSnapshotV1` blob +// through the SDK's snapshot helpers + a real MinIO backing store. Mirrors +// the testcontainer pattern from `objectStore.test.ts`. +// +// What this verifies end-to-end: +// - SDK's `writeChatSnapshot` calls `apiClient.createUploadPayloadUrl` +// to mint a presigned PUT, then PUTs JSON to it. +// - SDK's `readChatSnapshot` calls `apiClient.getPayloadUrl` to mint a +// presigned GET, then fetches and parses. +// - The webapp's `generatePresignedUrl` produces URLs MinIO accepts. +// - The blob round-trips with `version: 1` shape preserved. +// - 404 (no snapshot for a fresh session) returns `undefined`, not an +// error. +// +// This is the integration safety net behind the unit tests in +// `packages/trigger-sdk/test/chat-snapshot.test.ts` — those tests mock +// `fetch`; this one drives a real S3-compatible backend. + +import { postgresAndMinioTest } from "@internal/testcontainers"; +import { apiClientManager } from "@trigger.dev/core/v3"; +import { + __readChatSnapshotProductionPathForTests as readChatSnapshot, + __writeChatSnapshotProductionPathForTests as writeChatSnapshot, + type ChatSnapshotV1, +} from "@trigger.dev/sdk/ai"; +import type { UIMessage } from "ai"; +import { afterEach, describe, expect, vi } from "vitest"; +import { env } from "~/env.server"; +import { generatePresignedUrl } from "~/v3/objectStore.server"; + +vi.setConfig({ testTimeout: 60_000 }); + +// ── Helpers ──────────────────────────────────────────────────────────── + +function makeSnapshot(opts: { messages?: UIMessage[]; lastOutEventId?: string } = {}): ChatSnapshotV1 { + return { + version: 1, + savedAt: 1_700_000_000_000, + messages: opts.messages ?? [ + { + id: "u-1", + role: "user", + parts: [{ type: "text", text: "hello" }], + }, + { + id: "a-1", + role: "assistant", + parts: [{ type: "text", text: "world" }], + }, + ], + lastOutEventId: opts.lastOutEventId ?? "evt-42", + lastOutTimestamp: 1_700_000_000_500, + }; +} + +/** + * Stub `apiClientManager.clientOrThrow()` so the SDK helpers see a fake + * api client whose `getPayloadUrl` / `createUploadPayloadUrl` return + * presigned URLs minted by the webapp's real `generatePresignedUrl` + * (which signs against MinIO). + * + * The SDK helpers internally do `fetch(presignedUrl, ...)` to read/write + * the blob, so MinIO ends up holding the actual bytes. + */ +function stubApiClient(opts: { projectRef: string; envSlug: string }) { + vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({ + async getPayloadUrl(filename: string) { + const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "GET"); + if (!result.success) throw new Error(result.error); + return { presignedUrl: result.url }; + }, + async createUploadPayloadUrl(filename: string) { + const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "PUT"); + if (!result.success) throw new Error(result.error); + return { presignedUrl: result.url }; + }, + } as never); +} + +// Suppress noisy warnings from logger.warn during error-path tests. +let warnSpy: ReturnType; + +afterEach(() => { + vi.restoreAllMocks(); + warnSpy?.mockRestore(); +}); + +// ── Tests ────────────────────────────────────────────────────────────── + +describe("chat snapshot integration (MinIO + SDK helpers)", () => { + postgresAndMinioTest("round-trips a snapshot through real MinIO", async ({ minioConfig }) => { + env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl; + env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId; + env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey; + env.OBJECT_STORE_REGION = minioConfig.region; + env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined; + + stubApiClient({ projectRef: "proj_snap_rt", envSlug: "dev" }); + + const sessionId = "sess_round_trip_1"; + const snapshot = makeSnapshot(); + + // Write through the SDK helper — should land in MinIO at + // `packets/proj_snap_rt/dev/sessions/sess_round_trip_1/snapshot.json`. + await writeChatSnapshot(sessionId, snapshot); + + // Read back through the SDK helper — should reconstruct the original. + const result = await readChatSnapshot(sessionId); + + expect(result).toEqual(snapshot); + }); + + postgresAndMinioTest("returns undefined for a fresh session with no snapshot", async ({ minioConfig }) => { + env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl; + env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId; + env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey; + env.OBJECT_STORE_REGION = minioConfig.region; + env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined; + + stubApiClient({ projectRef: "proj_snap_404", envSlug: "dev" }); + + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // Session never had a snapshot written — read returns undefined. + const result = await readChatSnapshot("sess_never_existed"); + expect(result).toBeUndefined(); + }); + + postgresAndMinioTest("overwrites a prior snapshot in place (single-writer)", async ({ minioConfig }) => { + // The runtime guarantees one attempt alive at a time, and + // `writeChatSnapshot` runs awaited after `onTurnComplete`. Verify + // that a second write to the same key replaces the first cleanly — + // the read-after-write reflects the latest blob. + env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl; + env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId; + env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey; + env.OBJECT_STORE_REGION = minioConfig.region; + env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined; + + stubApiClient({ projectRef: "proj_snap_overwrite", envSlug: "dev" }); + + const sessionId = "sess_overwrite"; + + const turn1 = makeSnapshot({ + messages: [ + { id: "u-1", role: "user", parts: [{ type: "text", text: "first" }] }, + ], + lastOutEventId: "evt-turn1", + }); + const turn2 = makeSnapshot({ + messages: [ + { id: "u-1", role: "user", parts: [{ type: "text", text: "first" }] }, + { id: "a-1", role: "assistant", parts: [{ type: "text", text: "reply-1" }] }, + { id: "u-2", role: "user", parts: [{ type: "text", text: "second" }] }, + { id: "a-2", role: "assistant", parts: [{ type: "text", text: "reply-2" }] }, + ], + lastOutEventId: "evt-turn2", + }); + + await writeChatSnapshot(sessionId, turn1); + await writeChatSnapshot(sessionId, turn2); + + const result = await readChatSnapshot(sessionId); + expect(result).toEqual(turn2); + expect(result?.messages).toHaveLength(4); + expect(result?.lastOutEventId).toBe("evt-turn2"); + }); + + postgresAndMinioTest("isolates snapshots by sessionId (no cross-talk)", async ({ minioConfig }) => { + env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl; + env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId; + env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey; + env.OBJECT_STORE_REGION = minioConfig.region; + env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined; + + stubApiClient({ projectRef: "proj_snap_iso", envSlug: "dev" }); + + const sessA = "sess_iso_A"; + const sessB = "sess_iso_B"; + const snapA = makeSnapshot({ lastOutEventId: "evt-A" }); + const snapB = makeSnapshot({ lastOutEventId: "evt-B" }); + + await writeChatSnapshot(sessA, snapA); + await writeChatSnapshot(sessB, snapB); + + const readA = await readChatSnapshot(sessA); + const readB = await readChatSnapshot(sessB); + + expect(readA?.lastOutEventId).toBe("evt-A"); + expect(readB?.lastOutEventId).toBe("evt-B"); + // Distinct objects — modifying one shouldn't affect the other. + expect(readA?.lastOutEventId).not.toBe(readB?.lastOutEventId); + }); + + postgresAndMinioTest("handles snapshots with large message lists (~50 messages)", async ({ minioConfig }) => { + // Stress test: a 50-turn chat snapshot. Plan F.4 mentions the + // pre-change baseline grew past 512 KiB around turn 10-30 with tool + // use; the post-slim wire keeps wire payloads small but the snapshot + // itself can still get large. Verify the helpers handle a realistic + // payload size. + env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl; + env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId; + env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey; + env.OBJECT_STORE_REGION = minioConfig.region; + env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined; + + stubApiClient({ projectRef: "proj_snap_big", envSlug: "dev" }); + + const messages: UIMessage[] = []; + for (let i = 0; i < 50; i++) { + messages.push({ + id: `u-${i}`, + role: "user", + parts: [{ type: "text", text: `user message ${i}: ${"x".repeat(200)}` }], + }); + messages.push({ + id: `a-${i}`, + role: "assistant", + parts: [{ type: "text", text: `assistant reply ${i}: ${"y".repeat(500)}` }], + }); + } + const snapshot = makeSnapshot({ messages, lastOutEventId: "evt-50" }); + + await writeChatSnapshot("sess_big_chat", snapshot); + const result = await readChatSnapshot("sess_big_chat"); + + expect(result).toBeDefined(); + expect(result!.messages).toHaveLength(100); + expect(result!.lastOutEventId).toBe("evt-50"); + // Spot-check ordering integrity — the messages array round-tripped + // in the same order. + expect(result!.messages[0]!.id).toBe("u-0"); + expect(result!.messages[99]!.id).toBe("a-49"); + }); +}); diff --git a/apps/webapp/test/replay-after-crash.test.ts b/apps/webapp/test/replay-after-crash.test.ts new file mode 100644 index 00000000000..f5c6842b194 --- /dev/null +++ b/apps/webapp/test/replay-after-crash.test.ts @@ -0,0 +1,315 @@ +// Plan F.3: integration test for the crash-recovery boot path. The +// scenario it locks down: +// +// 1. Run A streams chunks to `session.out` and `onTurnComplete` fires. +// 2. Run A crashes BEFORE `writeChatSnapshot` lands the post-turn +// blob (or the write fails silently — both have the same effect). +// 3. Run B boots: `readChatSnapshot` returns `undefined` (no snapshot +// yet, or stale-from-prior-turn). Replay then drains +// `session.out` from the snapshot's `lastOutEventId` (or seq 0) +// and reduces the chunks back into UIMessage[]. +// 4. The accumulator is consistent — Run A's completed chunks reach +// Run B's run loop without losing data. +// +// Plan section H.1 / H.4 spell out the "snapshot didn't make it before +// crash" path; this test is the integration safety net behind the +// unit tests in `packages/trigger-sdk/test/replay-session-out.test.ts`. +// +// We exercise the SDK's `__replaySessionOutTailProductionPathForTests` +// against a stubbed `apiClient.readSessionStreamRecords` — the new +// non-SSE records endpoint introduced in plan task #22. The replay path +// is a single GET that returns whatever's already on the stream; no +// long-poll. MinIO is provisioned to keep parity with +// `chat-snapshot-integration.test.ts` (the snapshot read path runs +// through it), even though the replay path itself doesn't read from S3. + +import { postgresAndMinioTest } from "@internal/testcontainers"; +import { apiClientManager } from "@trigger.dev/core/v3"; +import { + __readChatSnapshotProductionPathForTests as readChatSnapshot, + __replaySessionOutTailProductionPathForTests as replaySessionOutTail, + type ChatSnapshotV1, +} from "@trigger.dev/sdk/ai"; +import type { UIMessageChunk } from "ai"; +import { afterEach, describe, expect, vi } from "vitest"; +import { env } from "~/env.server"; +import { generatePresignedUrl } from "~/v3/objectStore.server"; + +vi.setConfig({ testTimeout: 60_000 }); + +// ── Helpers ──────────────────────────────────────────────────────────── + +function textTurn(id: string, text: string): UIMessageChunk[] { + return [ + { type: "start", messageId: id, messageMetadata: { role: "assistant" } } as UIMessageChunk, + { type: "text-start", id: `${id}.t1` } as UIMessageChunk, + { type: "text-delta", id: `${id}.t1`, delta: text } as UIMessageChunk, + { type: "text-end", id: `${id}.t1` } as UIMessageChunk, + { type: "finish" } as UIMessageChunk, + ]; +} + +/** + * Stub `apiClientManager.clientOrThrow()` so: + * - `getPayloadUrl` / `createUploadPayloadUrl` mint MinIO presigned URLs + * via the webapp's real `generatePresignedUrl` (so snapshot reads + * hit a real S3-compatible backend). + * - `readSessionStreamRecords` returns the canonical + * `{ records: [{ data, id, seqNum }] }` shape — `data` is the + * JSON-encoded chunk body, mirroring the webapp's S2 record shape. + */ +function stubApiClient(opts: { + projectRef: string; + envSlug: string; + sessionOutChunks: unknown[]; +}) { + const records = opts.sessionOutChunks.map((chunk, i) => ({ + data: typeof chunk === "string" ? chunk : JSON.stringify(chunk), + id: `evt-${i + 1}`, + seqNum: i + 1, + })); + const readRecordsSpy = vi.fn( + async (_id: string, _io: "in" | "out", _options?: { afterEventId?: string }) => ({ + records, + }) + ); + vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({ + async getPayloadUrl(filename: string) { + const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "GET"); + if (!result.success) throw new Error(result.error); + return { presignedUrl: result.url }; + }, + async createUploadPayloadUrl(filename: string) { + const result = await generatePresignedUrl(opts.projectRef, opts.envSlug, filename, "PUT"); + if (!result.success) throw new Error(result.error); + return { presignedUrl: result.url }; + }, + readSessionStreamRecords: readRecordsSpy, + } as never); + return readRecordsSpy; +} + +let warnSpy: ReturnType; + +afterEach(() => { + vi.restoreAllMocks(); + warnSpy?.mockRestore(); +}); + +// ── Tests ────────────────────────────────────────────────────────────── + +describe("replay after crash (MinIO + SDK helpers)", () => { + postgresAndMinioTest( + "boot reconstructs accumulator from session.out replay when no snapshot exists", + async ({ minioConfig }) => { + env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl; + env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId; + env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey; + env.OBJECT_STORE_REGION = minioConfig.region; + env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined; + + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + // The crashed run's session.out: two completed assistant turns, no + // snapshot ever written. Boot must recover both via replay. + const chunks = [...textTurn("a-1", "first turn"), ...textTurn("a-2", "second turn")]; + stubApiClient({ + projectRef: "proj_replay_crash", + envSlug: "dev", + sessionOutChunks: chunks, + }); + + // Step 1: read snapshot — returns undefined (fresh boot, no snap). + const snapshot = await readChatSnapshot("sess_no_snap"); + expect(snapshot).toBeUndefined(); + + // Step 2: replay tail. + const replayed = await replaySessionOutTail("sess_no_snap"); + + expect(replayed).toHaveLength(2); + expect(replayed.map((m) => m.id)).toEqual(["a-1", "a-2"]); + const texts = replayed.flatMap((m) => + (m.parts as Array<{ type: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text) + ); + expect(texts).toEqual(["first turn", "second turn"]); + } + ); + + postgresAndMinioTest( + "boot replays only chunks AFTER snapshot.lastOutEventId (resume cursor)", + async ({ minioConfig }) => { + env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl; + env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId; + env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey; + env.OBJECT_STORE_REGION = minioConfig.region; + env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined; + + // The replay helper accepts the snapshot's `lastEventId` cursor + // and forwards it as `afterEventId` on the records endpoint — + // that's the cursor field name on the new non-SSE route. Here we + // feed only the post-snapshot chunks (modeling what the server + // returns for `afterEventId=evt-snapped`) and verify the helper + // threads the cursor through. + const readRecordsSpy = stubApiClient({ + projectRef: "proj_replay_resume", + envSlug: "dev", + sessionOutChunks: textTurn("a-after-snap", "post-snapshot turn"), + }); + + const result = await replaySessionOutTail("sess_resume", { lastEventId: "evt-snapped" }); + + expect(readRecordsSpy).toHaveBeenCalledWith( + "sess_resume", + "out", + expect.objectContaining({ afterEventId: "evt-snapped" }) + ); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("a-after-snap"); + } + ); + + postgresAndMinioTest( + "boot returns [] when session.out is empty (first-ever turn, no snapshot)", + async ({ minioConfig }) => { + env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl; + env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId; + env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey; + env.OBJECT_STORE_REGION = minioConfig.region; + env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined; + + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + stubApiClient({ + projectRef: "proj_replay_empty", + envSlug: "dev", + sessionOutChunks: [], + }); + + const snapshot = await readChatSnapshot("sess_empty"); + expect(snapshot).toBeUndefined(); + + const replayed = await replaySessionOutTail("sess_empty"); + expect(replayed).toEqual([]); + } + ); + + postgresAndMinioTest( + "boot drops orphaned trailing tool parts (cleanupAbortedParts) — partial crash", + async ({ minioConfig }) => { + // Simulates a true mid-turn crash: assistant finished one turn, + // then started a tool-call but the run died before resolution. + // Replay must surface the completed turn but NOT include the + // orphaned tool part in `input-streaming` state. + env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl; + env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId; + env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey; + env.OBJECT_STORE_REGION = minioConfig.region; + env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined; + + stubApiClient({ + projectRef: "proj_replay_partial", + envSlug: "dev", + sessionOutChunks: [ + ...textTurn("a-complete", "I finished step 1"), + // Partial tool turn — no tool-input-end, no finish. + { type: "start", messageId: "a-orphan", messageMetadata: { role: "assistant" } } as UIMessageChunk, + { type: "tool-input-start", id: "tc-cut", toolName: "search" } as UIMessageChunk, + { type: "tool-input-delta", id: "tc-cut", delta: '{"q":"x"}' } as UIMessageChunk, + ], + }); + + const replayed = await replaySessionOutTail("sess_partial_crash"); + + // Completed turn always present. + expect(replayed.find((m) => m.id === "a-complete")).toBeTruthy(); + // Orphaned tool-call never surfaces in `input-streaming` state. + const orphan = replayed.find((m) => m.id === "a-orphan"); + if (orphan) { + const stillStreaming = (orphan.parts as Array<{ toolCallId?: string; state?: string }>).find( + (p) => p.toolCallId === "tc-cut" && p.state === "input-streaming" + ); + expect(stillStreaming).toBeUndefined(); + } + } + ); + + postgresAndMinioTest( + "snapshot+replay merge: snapshot supplies user msgs, replay supplies assistants", + async ({ minioConfig }) => { + // The boot orchestration calls + // `mergeByIdReplaceWins(snapshot.messages, replayed)`. The runtime + // contract is that user messages live in snapshot only (session.in + // never goes through replay) and assistants come from replay + // (which carries the freshest representation). Here we simulate + // the realistic split: snapshot has [u-1, a-1-stale], replay has + // [a-1-fresh, a-2-new]. After merge the accumulator should reflect + // the fresh assistant + new assistant, with the user message + // preserved. + // + // Note: this is a pre-merge round-trip — we drive the read and + // replay through real MinIO + stubbed S2 to confirm both arrive + // intact for the orchestration to merge. + env.OBJECT_STORE_BASE_URL = minioConfig.baseUrl; + env.OBJECT_STORE_ACCESS_KEY_ID = minioConfig.accessKeyId; + env.OBJECT_STORE_SECRET_ACCESS_KEY = minioConfig.secretAccessKey; + env.OBJECT_STORE_REGION = minioConfig.region; + env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined; + + // Pre-write a snapshot to MinIO via real apiClient stub. + const sessionId = "sess_merge_round_trip"; + const snapshot: ChatSnapshotV1 = { + version: 1, + savedAt: 1_700_000_000_000, + messages: [ + { id: "u-1", role: "user", parts: [{ type: "text", text: "hi" }] }, + { id: "a-1", role: "assistant", parts: [{ type: "text", text: "stale-assistant" }] }, + ], + lastOutEventId: "evt-prev", + lastOutTimestamp: 1_700_000_000_500, + }; + + // Use the SDK's own writer to lay the snapshot down, then swap + // the stub to also serve replay chunks for the read path. + stubApiClient({ + projectRef: "proj_merge", + envSlug: "dev", + sessionOutChunks: [], + }); + const { __writeChatSnapshotProductionPathForTests: writeSnapshot } = await import( + "@trigger.dev/sdk/ai" + ); + await writeSnapshot(sessionId, snapshot); + + // Restubbing for the boot phase: replay tail carries the fresh + // assistant for `a-1` plus a brand-new `a-2`. The orchestration's + // merge would replace `a-1` and append `a-2` after `u-1`. + vi.restoreAllMocks(); + stubApiClient({ + projectRef: "proj_merge", + envSlug: "dev", + sessionOutChunks: [ + ...textTurn("a-1", "fresh-assistant"), + ...textTurn("a-2", "next-assistant"), + ], + }); + + const readBack = await readChatSnapshot(sessionId); + expect(readBack?.messages.map((m) => m.id)).toEqual(["u-1", "a-1"]); + + const replayed = await replaySessionOutTail(sessionId, { + lastEventId: readBack?.lastOutEventId, + }); + expect(replayed.map((m) => m.id)).toEqual(["a-1", "a-2"]); + // Replay's `a-1` carries the fresh content — when merge runs in + // the runtime, this version would replace the snapshot's stale + // `a-1`. + const replayedA1Text = (replayed[0]!.parts as Array<{ type: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text) + .join(""); + expect(replayedA1Text).toBe("fresh-assistant"); + } + ); +}); diff --git a/package.json b/package.json index 30f27bade95..3da35e40e67 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,8 @@ "@sentry/remix@9.46.0": "patches/@sentry__remix@9.46.0.patch", "@upstash/ratelimit@1.1.3": "patches/@upstash__ratelimit.patch", "antlr4ts@0.5.0-alpha.4": "patches/antlr4ts@0.5.0-alpha.4.patch", - "@window-splitter/state@1.1.3": "patches/@window-splitter__state@1.1.3.patch" + "@window-splitter/state@1.1.3": "patches/@window-splitter__state@1.1.3.patch", + "streamdown@2.5.0": "patches/streamdown@2.5.0.patch" }, "overrides": { "typescript": "5.5.4", diff --git a/packages/build/package.json b/packages/build/package.json index 206a80b89da..8d7bf6daf3f 100644 --- a/packages/build/package.json +++ b/packages/build/package.json @@ -31,7 +31,8 @@ "./extensions/typescript": "./src/extensions/typescript.ts", "./extensions/puppeteer": "./src/extensions/puppeteer.ts", "./extensions/playwright": "./src/extensions/playwright.ts", - "./extensions/lightpanda": "./src/extensions/lightpanda.ts" + "./extensions/lightpanda": "./src/extensions/lightpanda.ts", + "./extensions/secureExec": "./src/extensions/secureExec.ts" }, "sourceDialects": [ "@triggerdotdev/source" @@ -65,6 +66,9 @@ ], "extensions/lightpanda": [ "dist/commonjs/extensions/lightpanda.d.ts" + ], + "extensions/secureExec": [ + "dist/commonjs/extensions/secureExec.d.ts" ] } }, @@ -207,6 +211,17 @@ "types": "./dist/commonjs/extensions/lightpanda.d.ts", "default": "./dist/commonjs/extensions/lightpanda.js" } + }, + "./extensions/secureExec": { + "import": { + "@triggerdotdev/source": "./src/extensions/secureExec.ts", + "types": "./dist/esm/extensions/secureExec.d.ts", + "default": "./dist/esm/extensions/secureExec.js" + }, + "require": { + "types": "./dist/commonjs/extensions/secureExec.d.ts", + "default": "./dist/commonjs/extensions/secureExec.js" + } } }, "main": "./dist/commonjs/index.js", diff --git a/packages/build/src/extensions/secureExec.ts b/packages/build/src/extensions/secureExec.ts new file mode 100644 index 00000000000..808bc666501 --- /dev/null +++ b/packages/build/src/extensions/secureExec.ts @@ -0,0 +1,172 @@ +import { BuildTarget } from "@trigger.dev/core/v3"; +import { BuildManifest } from "@trigger.dev/core/v3/schemas"; +import { BuildContext, BuildExtension } from "@trigger.dev/core/v3/build"; +import { dirname, resolve, join } from "node:path"; +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { readPackageJSON } from "pkg-types"; + +export type SecureExecOptions = { + /** + * Packages available inside the sandbox at runtime. + * + * These are `require()`'d inside the V8 isolate at runtime — the bundler + * never sees them statically. They are marked external and installed as + * deploy dependencies. + * + * @example + * ```ts + * secureExec({ packages: ["jszip", "lodash"] }) + * ``` + */ + packages?: string[]; +}; + +/** + * Build extension for [secure-exec](https://secureexec.dev) — run untrusted + * JavaScript/TypeScript in V8 isolates with configurable permissions. + * + * Handles the esbuild workarounds needed for secure-exec's runtime + * `require.resolve` calls, native binaries, and module-scope resolution. + * + * @example + * ```ts + * import { secureExec } from "@trigger.dev/build/extensions/secureExec"; + * + * export default defineConfig({ + * build: { + * extensions: [secureExec()], + * }, + * }); + * ``` + */ +export function secureExec(options?: SecureExecOptions): BuildExtension { + return new SecureExecExtension(options ?? {}); +} + +class SecureExecExtension implements BuildExtension { + public readonly name = "SecureExecExtension"; + + private userPackages: string[]; + + constructor(options: SecureExecOptions) { + this.userPackages = options.packages ?? []; + } + + externalsForTarget(_target: BuildTarget) { + return [ + // esbuild must not be bundled — it locates its native binary via a + // relative path from its JS API entry point. secure-exec uses esbuild + // at runtime to bundle polyfills for sandbox code. + "esbuild", + // User-specified packages are require()'d inside the V8 sandbox at + // runtime — the bundler never sees them statically. + ...this.userPackages, + ]; + } + + onBuildStart(context: BuildContext) { + context.logger.debug(`Adding ${this.name} esbuild plugins`); + + // Plugin 1: Replace node-stdlib-browser with pre-resolved paths. + // + // Trigger's ESM shim anchors require.resolve() to the chunk path, so + // node-stdlib-browser's runtime require.resolve("./mock/empty.js") breaks. + // Fix: load the real node-stdlib-browser at build time (where require.resolve + // works), capture the resolved path map, and inline it as a static export. + const workingDir = context.workingDir; + context.registerPlugin({ + name: "secure-exec-stdlib-resolver", + setup(build) { + build.onResolve({ filter: /^node-stdlib-browser$/ }, () => ({ + path: "node-stdlib-browser", + namespace: "secure-exec-nsb-resolved", + })); + build.onLoad({ filter: /.*/, namespace: "secure-exec-nsb-resolved" }, () => { + const buildRequire = createRequire(join(workingDir, "package.json")); + const resolved = buildRequire("node-stdlib-browser"); + return { + contents: `export default ${JSON.stringify(resolved)};`, + loader: "js", + }; + }); + }, + }); + + // Plugin 2: Inline bridge.js at build time. + // + // bridge-loader.js in @secure-exec/node(js) uses __dirname and + // require.resolve("@secure-exec/core") at module scope to locate + // dist/bridge.js on disk. This fails in Trigger's bundled output. + // Fix: read bridge.js content at build time and inline it as a + // string literal so no runtime filesystem resolution is needed. + // + context.registerPlugin({ + name: "secure-exec-bridge-inline", + setup(build) { + build.onLoad( + { filter: /[\\/]@secure-exec[\\/]node[\\/]dist[\\/]bridge-loader\.js$/ }, + (args) => { + try { + const buildRequire = createRequire(args.path); + const coreEntry = buildRequire.resolve("@secure-exec/core"); + const coreRoot = resolve(dirname(coreEntry), ".."); + const bridgeCode = readFileSync(join(coreRoot, "dist", "bridge.js"), "utf8"); + + return { + contents: [ + `import { getIsolateRuntimeSource } from "@secure-exec/core";`, + `const bridgeCodeCache = ${JSON.stringify(bridgeCode)};`, + `export function getRawBridgeCode() { return bridgeCodeCache; }`, + `export function getBridgeAttachCode() { return getIsolateRuntimeSource("bridgeAttach"); }`, + ].join("\n"), + loader: "js", + }; + } catch { + // If we can't inline the bridge, let the normal loader handle it. + return undefined; + } + } + ); + }, + }); + } + + async onBuildComplete(context: BuildContext, _manifest: BuildManifest) { + if (context.target === "dev") { + return; + } + + context.logger.debug(`Adding ${this.name} deploy dependencies`); + + const dependencies: Record = {}; + + // Resolve versions for user-specified sandbox packages + for (const pkg of this.userPackages) { + try { + const modulePath = await context.resolvePath(pkg); + if (!modulePath) { + dependencies[pkg] = "latest"; + continue; + } + + const packageJSON = await readPackageJSON(dirname(modulePath)); + dependencies[pkg] = packageJSON.version ?? "latest"; + } catch { + context.logger.warn( + `Could not resolve version for sandbox package ${pkg}, defaulting to latest` + ); + dependencies[pkg] = "latest"; + } + } + + context.addLayer({ + id: "secureExec", + dependencies, + image: { + // isolated-vm requires native compilation tools + pkgs: ["python3", "make", "g++"], + }, + }); + } +} diff --git a/packages/build/src/internal.ts b/packages/build/src/internal.ts index 54f785a6106..0e1954c8b9e 100644 --- a/packages/build/src/internal.ts +++ b/packages/build/src/internal.ts @@ -1 +1,2 @@ export * from "./internal/additionalFiles.js"; +export * from "./internal/copyFiles.js"; diff --git a/packages/build/src/internal/additionalFiles.ts b/packages/build/src/internal/additionalFiles.ts index a815b53c9aa..57a746c36b6 100644 --- a/packages/build/src/internal/additionalFiles.ts +++ b/packages/build/src/internal/additionalFiles.ts @@ -1,8 +1,10 @@ import { BuildManifest } from "@trigger.dev/core/v3"; import { BuildContext } from "@trigger.dev/core/v3/build"; -import { copyFile, mkdir } from "node:fs/promises"; -import { dirname, join, posix, relative } from "node:path"; -import { glob } from "tinyglobby"; +import { + copyMatcherResults, + findFilesByMatchers, + type MatcherResult, +} from "./copyFiles.js"; export type AdditionalFilesOptions = { files: string[]; @@ -14,12 +16,13 @@ export async function addAdditionalFilesToBuild( context: BuildContext, manifest: BuildManifest ) { - // Copy any static assets to the destination - const staticAssets = await findStaticAssetFiles(options.files ?? [], manifest.outputPath, { - cwd: context.workingDir, - }); + const matcherResults: MatcherResult[] = await findFilesByMatchers( + options.files ?? [], + manifest.outputPath, + { cwd: context.workingDir } + ); - for (const { assets, matcher } of staticAssets) { + for (const { assets, matcher } of matcherResults) { if (assets.length === 0) { context.logger.warn(`[${source}] No files found for matcher`, matcher); } else { @@ -27,80 +30,7 @@ export async function addAdditionalFilesToBuild( } } - await copyStaticAssets(staticAssets, source, context); -} - -type MatchedStaticAssets = { source: string; destination: string }[]; - -type FoundStaticAssetFiles = Array<{ - matcher: string; - assets: MatchedStaticAssets; -}>; - -async function findStaticAssetFiles( - matchers: string[], - destinationPath: string, - options?: { cwd?: string; ignore?: string[] } -): Promise { - const result: FoundStaticAssetFiles = []; - - for (const matcher of matchers) { - const assets = await findStaticAssetsForMatcher(matcher, destinationPath, options); - - result.push({ matcher, assets }); - } - - return result; -} - -async function findStaticAssetsForMatcher( - matcher: string, - destinationPath: string, - options?: { cwd?: string; ignore?: string[] } -): Promise { - const result: MatchedStaticAssets = []; - - const files = await glob({ - patterns: [matcher], - cwd: options?.cwd, - ignore: options?.ignore ?? [], - onlyFiles: true, - absolute: true, + await copyMatcherResults(matcherResults, (pair) => { + context.logger.debug(`[${source}] Copying ${pair.source} to ${pair.destination}`); }); - - let matches = 0; - - for (const file of files) { - matches++; - - const pathInsideDestinationDir = relative(options?.cwd ?? process.cwd(), file) - .split(posix.sep) - .filter((p) => p !== "..") - .join(posix.sep); - - const relativeDestinationPath = join(destinationPath, pathInsideDestinationDir); - - result.push({ - source: file, - destination: relativeDestinationPath, - }); - } - - return result; -} - -async function copyStaticAssets( - staticAssetFiles: FoundStaticAssetFiles, - sourceName: string, - context: BuildContext -): Promise { - for (const { assets } of staticAssetFiles) { - for (const { source, destination } of assets) { - await mkdir(dirname(destination), { recursive: true }); - - context.logger.debug(`[${sourceName}] Copying ${source} to ${destination}`); - - await copyFile(source, destination); - } - } } diff --git a/packages/build/src/internal/copyFiles.ts b/packages/build/src/internal/copyFiles.ts new file mode 100644 index 00000000000..6fd3ede9545 --- /dev/null +++ b/packages/build/src/internal/copyFiles.ts @@ -0,0 +1,99 @@ +import { cp, copyFile, mkdir } from "node:fs/promises"; +import { dirname, join, posix, relative } from "node:path"; +import { glob } from "tinyglobby"; + +/** + * A single matched asset — source file and its destination inside the + * build output directory. + */ +export type CopyPair = { source: string; destination: string }; + +/** + * Result of a single matcher's glob, grouped with the matcher that + * produced it so callers can warn on empty matches. + */ +export type MatcherResult = { + matcher: string; + assets: CopyPair[]; +}; + +/** + * Glob a set of matchers relative to `cwd` and return pairs describing + * where each matched file should be copied to under `destinationDir`. + * + * Relative paths are preserved under `destinationDir`. Leading `..` + * segments (from `../shared/file.txt` style patterns) are stripped so + * files always land inside the destination. + */ +export async function findFilesByMatchers( + matchers: string[], + destinationDir: string, + options?: { cwd?: string; ignore?: string[] } +): Promise { + const result: MatcherResult[] = []; + const cwd = options?.cwd ?? process.cwd(); + + for (const matcher of matchers) { + const files = await glob({ + patterns: [matcher], + cwd, + ignore: options?.ignore ?? [], + onlyFiles: true, + absolute: true, + }); + + const assets: CopyPair[] = files.map((file) => { + const pathInsideDestinationDir = relative(cwd, file) + .split(posix.sep) + .filter((p) => p !== "..") + .join(posix.sep); + return { + source: file, + destination: join(destinationDir, pathInsideDestinationDir), + }; + }); + + result.push({ matcher, assets }); + } + + return result; +} + +/** + * Copy a single file, creating parent directories as needed. + */ +export async function copyFileEnsuringDir(source: string, destination: string): Promise { + await mkdir(dirname(destination), { recursive: true }); + await copyFile(source, destination); +} + +/** + * Copy every pair in the given matcher results. Parent directories are + * created automatically. Returns the total number of files copied. + */ +export async function copyMatcherResults( + matcherResults: MatcherResult[], + onCopy?: (pair: CopyPair) => void +): Promise { + let count = 0; + for (const { assets } of matcherResults) { + for (const pair of assets) { + onCopy?.(pair); + await copyFileEnsuringDir(pair.source, pair.destination); + count++; + } + } + return count; +} + +/** + * Recursively copy a directory to another location. Preserves structure; + * overwrites existing files at the destination. + * + * Used by the built-in skill bundler — we copy entire skill folders as a + * unit, not file-by-file. + */ +export async function copyDirectoryRecursive(source: string, destination: string): Promise { + await mkdir(destination, { recursive: true }); + await cp(source, destination, { recursive: true, force: true }); +} diff --git a/packages/core/package.json b/packages/core/package.json index 00c0315adb2..1235e0576b3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -43,6 +43,7 @@ "./v3/utils/omit": "./src/v3/utils/omit.ts", "./v3/utils/retries": "./src/v3/utils/retries.ts", "./v3/utils/structuredLogger": "./src/v3/utils/structuredLogger.ts", + "./v3/test": "./src/v3/test/index.ts", "./v3/zodfetch": "./src/v3/zodfetch.ts", "./v3/zodMessageHandler": "./src/v3/zodMessageHandler.ts", "./v3/zodNamespace": "./src/v3/zodNamespace.ts", @@ -160,6 +161,9 @@ ], "v3/isomorphic": [ "dist/commonjs/v3/isomorphic/index.d.ts" + ], + "v3/test": [ + "dist/commonjs/v3/test/index.d.ts" ] } }, @@ -476,6 +480,17 @@ "default": "./dist/commonjs/v3/utils/structuredLogger.js" } }, + "./v3/test": { + "import": { + "@triggerdotdev/source": "./src/v3/test/index.ts", + "types": "./dist/esm/v3/test/index.d.ts", + "default": "./dist/esm/v3/test/index.js" + }, + "require": { + "types": "./dist/commonjs/v3/test/index.d.ts", + "default": "./dist/commonjs/v3/test/index.js" + } + }, "./v3/zodfetch": { "import": { "@triggerdotdev/source": "./src/v3/zodfetch.ts", diff --git a/packages/core/src/v3/resource-catalog/catalog.ts b/packages/core/src/v3/resource-catalog/catalog.ts index 5b3ab023639..5c443b253cf 100644 --- a/packages/core/src/v3/resource-catalog/catalog.ts +++ b/packages/core/src/v3/resource-catalog/catalog.ts @@ -1,4 +1,11 @@ -import { PromptManifest, QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js"; +import { + PromptManifest, + QueueManifest, + SkillManifest, + SkillMetadata, + TaskManifest, + WorkerManifest, +} from "../schemas/index.js"; import { PromptMetadataWithFunctions, TaskMetadataWithFunctions, TaskSchema } from "../types/index.js"; export interface ResourceCatalog { @@ -18,4 +25,7 @@ export interface ResourceCatalog { listPromptManifests(): Array; getPrompt(id: string): PromptMetadataWithFunctions | undefined; getPromptSchema(id: string): TaskSchema | undefined; + registerSkillMetadata(skill: SkillMetadata): void; + listSkillManifests(): Array; + getSkillManifest(id: string): SkillManifest | undefined; } diff --git a/packages/core/src/v3/resource-catalog/index.ts b/packages/core/src/v3/resource-catalog/index.ts index 9ce7dee64cf..f809ede8135 100644 --- a/packages/core/src/v3/resource-catalog/index.ts +++ b/packages/core/src/v3/resource-catalog/index.ts @@ -1,6 +1,13 @@ const API_NAME = "resource-catalog"; -import { PromptManifest, QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js"; +import { + PromptManifest, + QueueManifest, + SkillManifest, + SkillMetadata, + TaskManifest, + WorkerManifest, +} from "../schemas/index.js"; import { PromptMetadataWithFunctions, TaskMetadataWithFunctions, TaskSchema } from "../types/index.js"; import { getGlobal, registerGlobal, unregisterGlobal } from "../utils/globals.js"; import { type ResourceCatalog } from "./catalog.js"; @@ -93,6 +100,18 @@ export class ResourceCatalogAPI { return this.#getCatalog().getPromptSchema(id); } + public registerSkillMetadata(skill: SkillMetadata): void { + this.#getCatalog().registerSkillMetadata(skill); + } + + public listSkillManifests(): Array { + return this.#getCatalog().listSkillManifests(); + } + + public getSkillManifest(id: string): SkillManifest | undefined { + return this.#getCatalog().getSkillManifest(id); + } + #getCatalog(): ResourceCatalog { return getGlobal(API_NAME) ?? NOOP_RESOURCE_CATALOG; } diff --git a/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts b/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts index 8f77544f05c..5da74d4a9b1 100644 --- a/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts +++ b/packages/core/src/v3/resource-catalog/noopResourceCatalog.ts @@ -1,4 +1,11 @@ -import { PromptManifest, QueueManifest, TaskManifest, WorkerManifest } from "../schemas/index.js"; +import { + PromptManifest, + QueueManifest, + SkillManifest, + SkillMetadata, + TaskManifest, + WorkerManifest, +} from "../schemas/index.js"; import { type PromptMetadataWithFunctions, type TaskMetadataWithFunctions, type TaskSchema } from "../types/index.js"; import { ResourceCatalog } from "./catalog.js"; @@ -70,4 +77,16 @@ export class NoopResourceCatalog implements ResourceCatalog { getPromptSchema(id: string): TaskSchema | undefined { return undefined; } + + registerSkillMetadata(skill: SkillMetadata): void { + // noop + } + + listSkillManifests(): Array { + return []; + } + + getSkillManifest(id: string): SkillManifest | undefined { + return undefined; + } } diff --git a/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts b/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts index ea134a45663..0a67a4fd9a4 100644 --- a/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts +++ b/packages/core/src/v3/resource-catalog/standardResourceCatalog.ts @@ -1,6 +1,8 @@ import { PromptManifest, PromptMetadata, + SkillManifest, + SkillMetadata, TaskFileMetadata, TaskMetadata, TaskManifest, @@ -21,6 +23,8 @@ export class StandardResourceCatalog implements ResourceCatalog { private _promptSchemas: Map = new Map(); private _currentFileContext?: Omit; private _queueMetadata: Map = new Map(); + private _skillMetadata: Map = new Map(); + private _skillFileMetadata: Map = new Map(); setCurrentFileContext(filePath: string, entryPoint: string) { this._currentFileContext = { filePath, entryPoint }; @@ -86,25 +90,31 @@ export class StandardResourceCatalog implements ResourceCatalog { } updateTaskMetadata(id: string, updates: Partial): void { + const { fns, schema, ...metadataUpdates } = updates; + const existingMetadata = this._taskMetadata.get(id); - if (existingMetadata) { + if (existingMetadata && Object.keys(metadataUpdates).length > 0) { this._taskMetadata.set(id, { ...existingMetadata, - ...updates, + ...metadataUpdates, }); } - if (updates.fns) { + if (fns) { const existingFunctions = this._taskFunctions.get(id); if (existingFunctions) { this._taskFunctions.set(id, { ...existingFunctions, - ...updates.fns, + ...fns, }); } } + + if (schema) { + this._taskSchemas.set(id, schema); + } } // Return all the tasks, without the functions @@ -233,6 +243,58 @@ export class StandardResourceCatalog implements ResourceCatalog { }; } + registerSkillMetadata(skill: SkillMetadata): void { + if (!this._currentFileContext) { + return; + } + + if (!skill.id) { + return; + } + + const existing = this._skillMetadata.get(skill.id); + if (existing && existing.sourcePath !== skill.sourcePath) { + console.warn( + `Skill "${skill.id}" is defined twice with different paths. Keeping the first:\n` + + ` existing: ${existing.sourcePath}\n` + + ` ignored: ${skill.sourcePath}` + ); + return; + } + + this._skillFileMetadata.set(skill.id, { + ...this._currentFileContext, + }); + this._skillMetadata.set(skill.id, skill); + } + + listSkillManifests(): Array { + const result: Array = []; + + for (const [id, metadata] of this._skillMetadata) { + const fileMetadata = this._skillFileMetadata.get(id); + if (!fileMetadata) continue; + + result.push({ + ...metadata, + ...fileMetadata, + }); + } + + return result; + } + + getSkillManifest(id: string): SkillManifest | undefined { + const metadata = this._skillMetadata.get(id); + const fileMetadata = this._skillFileMetadata.get(id); + if (!metadata || !fileMetadata) return undefined; + + return { + ...metadata, + ...fileMetadata, + }; + } + disable() { // noop } diff --git a/packages/core/src/v3/taskContext/index.test.ts b/packages/core/src/v3/taskContext/index.test.ts new file mode 100644 index 00000000000..34d169a177c --- /dev/null +++ b/packages/core/src/v3/taskContext/index.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { unregisterGlobal } from "../utils/globals.js"; +import { SemanticInternalAttributes } from "../semanticInternalAttributes.js"; +import { TaskContextAPI } from "./index.js"; + +const FAKE_CTX = { + attempt: { id: "attempt_1", number: 1, startedAt: new Date(), status: "EXECUTING" as const }, + run: { + id: "run_1", + payload: undefined, + payloadType: "application/json", + context: undefined, + createdAt: new Date(), + tags: [], + isTest: false, + isReplay: false, + startedAt: new Date(), + durationMs: 0, + costInCents: 0, + baseCostInCents: 0, + }, + task: { id: "my-task", filePath: "src/trigger/task.ts", exportName: "myTask" }, + queue: { id: "queue_1", name: "default" }, + environment: { id: "env_1", slug: "dev", type: "DEVELOPMENT" as const }, + organization: { id: "org_1", slug: "acme", name: "Acme" }, + project: { id: "proj_1", ref: "proj_xyz", slug: "demo", name: "Demo" }, + machine: { + name: "small-1x" as const, + cpu: 0.5, + memory: 0.5, + centsPerMs: 0.0001, + }, +} as never; + +const FAKE_WORKER = { id: "worker_1", version: "1.0.0", contentHash: "abc" } as never; + +describe("TaskContextAPI conversation id", () => { + afterEach(() => { + unregisterGlobal("task-context"); + TaskContextAPI.getInstance().setConversationId(undefined); + }); + + it("returns no conversation attribute when setConversationId was never called", () => { + const api = TaskContextAPI.getInstance(); + api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER }); + + expect(api.attributes[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]).toBeUndefined(); + }); + + it("includes gen_ai.conversation.id after setConversationId", () => { + const api = TaskContextAPI.getInstance(); + api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER }); + + api.setConversationId("chat_123"); + + expect(api.attributes[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]).toBe("chat_123"); + }); + + it("clears the conversation attribute when called with undefined", () => { + const api = TaskContextAPI.getInstance(); + api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER }); + api.setConversationId("chat_123"); + + api.setConversationId(undefined); + + expect(api.attributes[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]).toBeUndefined(); + expect(api.conversationId).toBeUndefined(); + }); + + it("returns no attributes when there is no task context", () => { + const api = TaskContextAPI.getInstance(); + api.setConversationId("chat_123"); + + expect(api.attributes).toEqual({}); + }); + + it("clears conversation id when a new task context is registered (warm restart)", () => { + const api = TaskContextAPI.getInstance(); + api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER }); + api.setConversationId("chat_old"); + + api.setGlobalTaskContext({ ctx: FAKE_CTX, worker: FAKE_WORKER }); + + expect(api.attributes[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]).toBeUndefined(); + }); +}); diff --git a/packages/core/src/v3/taskContext/index.ts b/packages/core/src/v3/taskContext/index.ts index 92e0194cde9..ecbfa184a6b 100644 --- a/packages/core/src/v3/taskContext/index.ts +++ b/packages/core/src/v3/taskContext/index.ts @@ -9,6 +9,7 @@ const API_NAME = "task-context"; export class TaskContextAPI { private static _instance?: TaskContextAPI; private _runDisabled = false; + private _conversationId?: string; private constructor() {} @@ -45,6 +46,7 @@ export class TaskContextAPI { return { ...this.contextAttributes, ...this.workerAttributes, + ...this.conversationAttributes, [SemanticInternalAttributes.WARM_START]: !!this.isWarmStart, }; } @@ -52,6 +54,19 @@ export class TaskContextAPI { return {}; } + get conversationAttributes(): Attributes { + if (!this._conversationId) return {}; + return { [SemanticInternalAttributes.GEN_AI_CONVERSATION_ID]: this._conversationId }; + } + + get conversationId(): string | undefined { + return this._conversationId; + } + + public setConversationId(conversationId: string | undefined): void { + this._conversationId = conversationId || undefined; + } + get resourceAttributes(): Attributes { if (this.ctx) { return { @@ -109,6 +124,11 @@ export class TaskContextAPI { public setGlobalTaskContext(taskContext: TaskContext): boolean { this._runDisabled = false; + // Each run boot re-registers the global; clear any conversation id + // left over from a previous run on this warm-restarted process so + // attributes don't bleed across runs that don't call + // `setConversationId` themselves. + this._conversationId = undefined; return registerGlobal(API_NAME, taskContext, true); } diff --git a/packages/core/src/v3/taskContext/otelProcessors.ts b/packages/core/src/v3/taskContext/otelProcessors.ts index 1c0958d655d..fc30e9d1145 100644 --- a/packages/core/src/v3/taskContext/otelProcessors.ts +++ b/packages/core/src/v3/taskContext/otelProcessors.ts @@ -36,6 +36,17 @@ export class TaskContextSpanProcessor implements SpanProcessor { if (!taskContext.isRunDisabled && taskContext.ctx.run.tags?.length) { span.setAttribute(SemanticInternalAttributes.RUN_TAGS, taskContext.ctx.run.tags); } + + // Stamp `gen_ai.conversation.id` (OTel GenAI semantic convention) + // directly on every span so it survives the OTLP ingest's `ctx.*` + // strip and lands in the stored attributes column without a schema + // migration. + if (taskContext.conversationId) { + span.setAttribute( + SemanticInternalAttributes.GEN_AI_CONVERSATION_ID, + taskContext.conversationId + ); + } } if (!isPartialSpan(span) && !skipPartialSpan(span)) { @@ -178,6 +189,11 @@ export class TaskContextMetricExporter implements PushMetricExporter { contextAttrs[SemanticInternalAttributes.RUN_TAGS] = ctx.run.tags; } + if (taskContext.conversationId) { + contextAttrs[SemanticInternalAttributes.GEN_AI_CONVERSATION_ID] = + taskContext.conversationId; + } + const modified: ResourceMetrics = { resource: metrics.resource, scopeMetrics: metrics.scopeMetrics.map((scope) => ({ diff --git a/packages/core/src/v3/test/index.ts b/packages/core/src/v3/test/index.ts new file mode 100644 index 00000000000..402f618c01b --- /dev/null +++ b/packages/core/src/v3/test/index.ts @@ -0,0 +1,9 @@ +export { + runInMockTaskContext, + type MockTaskContextDrivers, + type MockTaskContextOptions, +} from "./mock-task-context.js"; +export { TestInputStreamManager } from "./test-input-stream-manager.js"; +export { TestRealtimeStreamsManager } from "./test-realtime-streams-manager.js"; +export { TestRunMetadataManager } from "./test-run-metadata-manager.js"; +export { TestSessionStreamManager } from "./test-session-stream-manager.js"; diff --git a/packages/core/src/v3/test/mock-task-context.ts b/packages/core/src/v3/test/mock-task-context.ts new file mode 100644 index 00000000000..66e58490019 --- /dev/null +++ b/packages/core/src/v3/test/mock-task-context.ts @@ -0,0 +1,294 @@ +import { inputStreams } from "../input-streams-api.js"; +import { realtimeStreams } from "../realtime-streams-api.js"; +import { sessionStreams } from "../session-streams-api.js"; +import { localsAPI } from "../locals-api.js"; +import { runMetadata } from "../run-metadata-api.js"; +import { taskContext } from "../task-context-api.js"; +import { lifecycleHooks } from "../lifecycle-hooks-api.js"; +import { runtime } from "../runtime-api.js"; +import { StandardLocalsManager } from "../locals/manager.js"; +import { StandardLifecycleHooksManager } from "../lifecycleHooks/manager.js"; +import { NoopRuntimeManager } from "../runtime/noopRuntimeManager.js"; +import { unregisterGlobal } from "../utils/globals.js"; +import type { ServerBackgroundWorker, TaskRunContext } from "../schemas/index.js"; +import type { LocalsKey } from "../locals/types.js"; +import type { SessionChannelIO } from "../sessionStreams/types.js"; +import { TestInputStreamManager } from "./test-input-stream-manager.js"; +import { TestRealtimeStreamsManager } from "./test-realtime-streams-manager.js"; +import { TestRunMetadataManager } from "./test-run-metadata-manager.js"; +import { TestSessionStreamManager } from "./test-session-stream-manager.js"; + +/** + * Shallow-partial overrides applied on top of the default mock + * `TaskRunContext`. Each sub-object is a partial of its real shape — + * unset fields get sensible defaults. + */ +export type MockTaskRunContextOverrides = { + task?: Partial; + attempt?: Partial; + run?: Partial; + machine?: Partial; + queue?: Partial; + environment?: Partial; + organization?: Partial; + project?: Partial; + batch?: TaskRunContext["batch"]; +}; + +/** + * Options for overriding parts of the mock task context. + */ +export type MockTaskContextOptions = { + /** Overrides applied on top of the default mock `TaskRunContext`. */ + ctx?: MockTaskRunContextOverrides; + /** Overrides applied on top of the default `ServerBackgroundWorker`. */ + worker?: Partial; + /** Whether this is a warm start. */ + isWarmStart?: boolean; +}; + +/** + * Drivers passed to the function running inside `runInMockTaskContext`. + */ +export type MockTaskContextDrivers = { + /** Push data into input streams — simulates realtime input from outside the task. */ + inputs: { + /** + * Send `data` to the named input stream. Resolves when all `.on()` + * handlers have run. + */ + send(streamId: string, data: unknown): Promise; + /** Resolve any pending `.once()` waiters with a timeout error. */ + close(streamId: string): void; + }; + /** Inspect chunks written to output (realtime) streams. */ + outputs: { + /** All chunks for a given stream, in the order they were written. */ + chunks(streamId: string): T[]; + /** All chunks across every stream, keyed by stream id. */ + all(): Record; + /** Clear chunks for one stream, or all streams if no id is provided. */ + clear(streamId?: string): void; + /** + * Register a listener fired for every chunk written to any stream. + * Returns an unsubscribe function. + */ + onWrite(listener: (streamId: string, chunk: unknown) => void): () => void; + }; + /** Read or seed locals for the run. */ + locals: { + /** Read a local set by either the task or `set()` below. */ + get(key: LocalsKey): T | undefined; + /** + * Pre-seed a local before the task runs. Use this for dependency + * injection — e.g. supply a test database client that the agent's + * hooks read via `locals.get()` instead of constructing the prod one. + */ + set(key: LocalsKey, value: T): void; + }; + /** + * Session-scoped channel drivers. The `.in` side is backed by a + * {@link TestSessionStreamManager} installed as the `sessionStreams` + * global — so the task's `session.in.on/once/peek/waitWithIdleTimeout` + * calls receive records sent through this driver. + */ + sessions: { + in: { + /** + * Send a record onto `session.in` for the given session. Resolves + * pending `once()` waiters and fires all `on()` handlers. + */ + send(sessionId: string, data: unknown, io?: SessionChannelIO): Promise; + /** Close pending `once()` waiters with a timeout error. */ + close(sessionId: string, io?: SessionChannelIO): void; + }; + }; + /** The mock `TaskRunContext` assembled from defaults + user overrides. */ + ctx: TaskRunContext; +}; + +function defaultTaskRunContext(overrides?: MockTaskRunContextOverrides): TaskRunContext { + return { + task: { + id: "test-task", + filePath: "test-task.ts", + ...overrides?.task, + }, + attempt: { + number: 1, + startedAt: new Date(), + ...overrides?.attempt, + }, + run: { + id: "run_test", + tags: [], + isTest: false, + isReplay: false, + createdAt: new Date(), + startedAt: new Date(), + ...overrides?.run, + }, + machine: { + name: "micro", + cpu: 1, + memory: 0.5, + centsPerMs: 0, + ...overrides?.machine, + }, + queue: { + name: "test-queue", + id: "test-queue-id", + ...overrides?.queue, + }, + environment: { + id: "test-env-id", + slug: "test-env", + type: "DEVELOPMENT", + ...overrides?.environment, + }, + organization: { + id: "test-org-id", + slug: "test-org", + name: "Test Org", + ...overrides?.organization, + }, + project: { + id: "test-project-id", + ref: "test-project-ref", + slug: "test-project", + name: "Test Project", + ...overrides?.project, + }, + batch: overrides?.batch, + }; +} + +function defaultWorker(overrides?: Partial): ServerBackgroundWorker { + return { + id: "test-worker-id", + version: "test-version", + contentHash: "test-content-hash", + engine: "V2", + ...overrides, + }; +} + +/** + * Run a function inside a fully mocked task runtime context. + * + * Installs in-memory test managers for `locals`, `inputStreams`, + * `realtimeStreams`, `lifecycleHooks`, and `runtime`, sets a mock + * `TaskContext`, and tears everything down when the function returns. + * + * Inside the function, any code that reads from `locals`, `inputStreams`, + * `realtimeStreams`, or `taskContext.ctx` will see the mock context — + * so you can directly invoke the internal `run` function of any task + * (including `chat.agent`) without hitting the Trigger.dev runtime. + * + * @example + * ```ts + * import { runInMockTaskContext } from "@trigger.dev/core/v3/test"; + * + * await runInMockTaskContext( + * async ({ inputs, outputs, ctx }) => { + * // Fire an input stream from the "outside" + * setTimeout(() => { + * inputs.send("chat-messages", { messages: [], chatId: "c1" }); + * }, 0); + * + * // Run task code that reads from inputStreams.once(...) + * await myTask.fns.run(payload, { ctx, signal: new AbortController().signal }); + * + * // Inspect chunks written to the output stream + * expect(outputs.chunks("chat")).toContainEqual({ type: "text-delta", delta: "hi" }); + * }, + * { ctx: { run: { id: "run_abc" } } } + * ); + * ``` + */ +export async function runInMockTaskContext( + fn: (drivers: MockTaskContextDrivers) => T | Promise, + options?: MockTaskContextOptions +): Promise { + const ctx = defaultTaskRunContext(options?.ctx); + const worker = defaultWorker(options?.worker); + + const localsManager = new StandardLocalsManager(); + const lifecycleManager = new StandardLifecycleHooksManager(); + const runtimeManager = new NoopRuntimeManager(); + const metadataManager = new TestRunMetadataManager(); + const inputManager = new TestInputStreamManager(); + const outputManager = new TestRealtimeStreamsManager(); + const sessionStreamManager = new TestSessionStreamManager(); + + // Unregister any previously-installed managers so `setGlobal*` wins — + // `registerGlobal` returns false silently if an entry already exists. + unregisterGlobal("locals"); + unregisterGlobal("lifecycle-hooks"); + unregisterGlobal("runtime"); + unregisterGlobal("run-metadata"); + unregisterGlobal("input-streams"); + unregisterGlobal("realtime-streams"); + unregisterGlobal("session-streams"); + unregisterGlobal("task-context"); + + localsAPI.setGlobalLocalsManager(localsManager); + lifecycleHooks.setGlobalLifecycleHooksManager(lifecycleManager); + runtime.setGlobalRuntimeManager(runtimeManager); + runMetadata.setGlobalManager(metadataManager); + inputStreams.setGlobalManager(inputManager); + realtimeStreams.setGlobalManager(outputManager); + sessionStreams.setGlobalManager(sessionStreamManager); + taskContext.setGlobalTaskContext({ + ctx, + worker, + isWarmStart: options?.isWarmStart ?? false, + }); + + const drivers: MockTaskContextDrivers = { + inputs: { + send: (streamId, data) => inputManager.__sendFromTest(streamId, data), + close: (streamId) => inputManager.__closeFromTest(streamId), + }, + outputs: { + chunks: (streamId) => outputManager.__chunksFromTest(streamId), + all: () => outputManager.__allChunksFromTest(), + clear: (streamId) => outputManager.__clearFromTest(streamId), + onWrite: (listener) => outputManager.onWrite(listener), + }, + locals: { + get: (key: LocalsKey) => localsManager.getLocal(key), + set: (key: LocalsKey, value: TValue) => + localsManager.setLocal(key, value), + }, + sessions: { + in: { + send: (sessionId, data, io = "in") => + sessionStreamManager.__sendFromTest(sessionId, io, data), + close: (sessionId, io = "in") => + sessionStreamManager.__closeFromTest(sessionId, io), + }, + }, + ctx, + }; + + try { + return await fn(drivers); + } finally { + localsAPI.disable(); + lifecycleHooks.disable(); + runtime.disable(); + // taskContext.disable() only sets a flag — unregister the global so + // `taskContext.ctx` returns undefined after the harness returns. + unregisterGlobal("task-context"); + unregisterGlobal("input-streams"); + unregisterGlobal("realtime-streams"); + unregisterGlobal("session-streams"); + unregisterGlobal("run-metadata"); + localsManager.reset(); + inputManager.reset(); + outputManager.reset(); + sessionStreamManager.reset(); + metadataManager.reset(); + } +} diff --git a/packages/core/test/mockTaskContext.test.ts b/packages/core/test/mockTaskContext.test.ts new file mode 100644 index 00000000000..5ea3685e466 --- /dev/null +++ b/packages/core/test/mockTaskContext.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from "vitest"; +import { runInMockTaskContext } from "../src/v3/test/index.js"; +import { inputStreams } from "../src/v3/input-streams-api.js"; +import { realtimeStreams } from "../src/v3/realtime-streams-api.js"; +import { locals } from "../src/v3/locals-api.js"; +import { taskContext } from "../src/v3/task-context-api.js"; + +describe("runInMockTaskContext", () => { + it("installs a mock TaskRunContext with sensible defaults", async () => { + await runInMockTaskContext(async ({ ctx }) => { + expect(taskContext.ctx).toBeDefined(); + expect(taskContext.ctx?.run.id).toBe("run_test"); + expect(taskContext.ctx?.task.id).toBe("test-task"); + expect(ctx.run.id).toBe("run_test"); + }); + }); + + it("applies ctx overrides on top of defaults", async () => { + await runInMockTaskContext( + async ({ ctx }) => { + expect(ctx.run.id).toBe("run_abc"); + expect(ctx.task.id).toBe("my-chat-agent"); + // Unspecified fields still use defaults + expect(ctx.queue.id).toBe("test-queue-id"); + }, + { + ctx: { + run: { id: "run_abc" }, + task: { id: "my-chat-agent", filePath: "chat.ts" }, + }, + } + ); + }); + + it("isolates locals from the surrounding context", async () => { + const key = locals.create<{ count: number }>("test.counter"); + + await runInMockTaskContext(async ({ locals: inspect }) => { + expect(inspect.get(key)).toBeUndefined(); + locals.set(key, { count: 1 }); + expect(inspect.get(key)).toEqual({ count: 1 }); + }); + + // After the harness exits, the locals should be gone + expect(locals.get(key)).toBeUndefined(); + }); + + it("tears down the task context after fn returns", async () => { + await runInMockTaskContext(async () => { + expect(taskContext.ctx).toBeDefined(); + }); + + expect(taskContext.ctx).toBeUndefined(); + }); + + it("tears down even when fn throws", async () => { + await expect( + runInMockTaskContext(async () => { + throw new Error("boom"); + }) + ).rejects.toThrow("boom"); + + expect(taskContext.ctx).toBeUndefined(); + }); + + it("returns the value returned by fn", async () => { + const result = await runInMockTaskContext(async () => "hello"); + expect(result).toBe("hello"); + }); + + describe("input streams driver", () => { + it("resolves inputStreams.once() when test sends data", async () => { + await runInMockTaskContext(async ({ inputs }) => { + const pending = inputStreams.once("chat-messages"); + setTimeout(() => inputs.send("chat-messages", { hello: "world" }), 0); + const result = await pending; + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.output).toEqual({ hello: "world" }); + } + }); + }); + + it("fires inputStreams.on() handlers when test sends data", async () => { + await runInMockTaskContext(async ({ inputs }) => { + const received: unknown[] = []; + inputStreams.on("chat-messages", (data) => { + received.push(data); + }); + + await inputs.send("chat-messages", { n: 1 }); + await inputs.send("chat-messages", { n: 2 }); + + expect(received).toEqual([{ n: 1 }, { n: 2 }]); + }); + }); + + it("fires multiple on() handlers on the same stream", async () => { + await runInMockTaskContext(async ({ inputs }) => { + const a: unknown[] = []; + const b: unknown[] = []; + inputStreams.on("chat-messages", (data) => a.push(data)); + inputStreams.on("chat-messages", (data) => b.push(data)); + + await inputs.send("chat-messages", "hi"); + expect(a).toEqual(["hi"]); + expect(b).toEqual(["hi"]); + }); + }); + + it("off() unsubscribes a handler", async () => { + await runInMockTaskContext(async ({ inputs }) => { + const received: unknown[] = []; + const sub = inputStreams.on("chat-messages", (data) => received.push(data)); + + await inputs.send("chat-messages", 1); + sub.off(); + await inputs.send("chat-messages", 2); + + expect(received).toEqual([1]); + }); + }); + + it("times out once() after timeoutMs", async () => { + await runInMockTaskContext(async () => { + const result = await inputStreams.once("chat-messages", { timeoutMs: 10 }); + expect(result.ok).toBe(false); + }); + }); + + it("peek() returns the latest sent value", async () => { + await runInMockTaskContext(async ({ inputs }) => { + expect(inputStreams.peek("chat-messages")).toBeUndefined(); + await inputs.send("chat-messages", { latest: true }); + expect(inputStreams.peek("chat-messages")).toEqual({ latest: true }); + }); + }); + + it("close() rejects pending once() waiters with a timeout error", async () => { + await runInMockTaskContext(async ({ inputs }) => { + const pending = inputStreams.once("chat-messages"); + inputs.close("chat-messages"); + const result = await pending; + expect(result.ok).toBe(false); + }); + }); + + it("resolves multiple concurrent once() waiters from a single send", async () => { + await runInMockTaskContext(async ({ inputs }) => { + const a = inputStreams.once("chat-messages"); + const b = inputStreams.once("chat-messages"); + await inputs.send("chat-messages", "shared"); + const [ra, rb] = await Promise.all([a, b]); + expect(ra.ok && ra.output).toBe("shared"); + expect(rb.ok && rb.output).toBe("shared"); + }); + }); + }); + + describe("realtime streams driver", () => { + it("collects chunks from realtimeStreams.append()", async () => { + await runInMockTaskContext(async ({ outputs }) => { + await realtimeStreams.append("chat", "chunk-1" as unknown as BodyInit); + await realtimeStreams.append("chat", "chunk-2" as unknown as BodyInit); + + expect(outputs.chunks("chat")).toEqual(["chunk-1", "chunk-2"]); + }); + }); + + it("collects chunks from realtimeStreams.pipe()", async () => { + await runInMockTaskContext(async ({ outputs }) => { + const source = (async function* () { + yield "a"; + yield "b"; + yield "c"; + })(); + + const instance = realtimeStreams.pipe("chat", source); + + // Drain the returned stream — that's what feeds the buffer + for await (const _ of instance.stream) { + // no-op + } + + expect(outputs.chunks("chat")).toEqual(["a", "b", "c"]); + }); + }); + + it("separates chunks by stream id", async () => { + await runInMockTaskContext(async ({ outputs }) => { + await realtimeStreams.append("chat", "a" as unknown as BodyInit); + await realtimeStreams.append("stop", "halt" as unknown as BodyInit); + + expect(outputs.chunks("chat")).toEqual(["a"]); + expect(outputs.chunks("stop")).toEqual(["halt"]); + expect(outputs.all()).toEqual({ chat: ["a"], stop: ["halt"] }); + }); + }); + + it("clear() empties one stream or all streams", async () => { + await runInMockTaskContext(async ({ outputs }) => { + await realtimeStreams.append("chat", "a" as unknown as BodyInit); + await realtimeStreams.append("stop", "halt" as unknown as BodyInit); + + outputs.clear("chat"); + expect(outputs.chunks("chat")).toEqual([]); + expect(outputs.chunks("stop")).toEqual(["halt"]); + + outputs.clear(); + expect(outputs.chunks("stop")).toEqual([]); + }); + }); + }); + + it("tears down input/output managers so consecutive calls are isolated", async () => { + await runInMockTaskContext(async ({ inputs }) => { + await inputs.send("chat-messages", "first-run"); + }); + + await runInMockTaskContext(async ({ outputs }) => { + expect(outputs.chunks("chat-messages")).toEqual([]); + // inputs.peek should NOT see "first-run" from the prior harness + expect(inputStreams.peek("chat-messages")).toBeUndefined(); + }); + }); +}); diff --git a/packages/core/test/skillCatalog.test.ts b/packages/core/test/skillCatalog.test.ts new file mode 100644 index 00000000000..3f1d29bf572 --- /dev/null +++ b/packages/core/test/skillCatalog.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it, vi } from "vitest"; +import { StandardResourceCatalog } from "../src/v3/resource-catalog/standardResourceCatalog.js"; + +describe("StandardResourceCatalog — skills", () => { + it("registers and lists a skill manifest", () => { + const catalog = new StandardResourceCatalog(); + catalog.setCurrentFileContext("trigger/chat.ts", "chat"); + + catalog.registerSkillMetadata({ id: "pdf-processing", sourcePath: "./skills/pdf-processing" }); + + const manifests = catalog.listSkillManifests(); + expect(manifests).toHaveLength(1); + expect(manifests[0]).toMatchObject({ + id: "pdf-processing", + sourcePath: "./skills/pdf-processing", + filePath: "trigger/chat.ts", + entryPoint: "chat", + }); + }); + + it("getSkillManifest returns the registered skill", () => { + const catalog = new StandardResourceCatalog(); + catalog.setCurrentFileContext("trigger/chat.ts", "chat"); + catalog.registerSkillMetadata({ id: "a", sourcePath: "./skills/a" }); + + expect(catalog.getSkillManifest("a")?.sourcePath).toBe("./skills/a"); + expect(catalog.getSkillManifest("missing")).toBeUndefined(); + }); + + it("skips registration without a file context", () => { + const catalog = new StandardResourceCatalog(); + + catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" }); + + expect(catalog.listSkillManifests()).toHaveLength(0); + }); + + it("warns and ignores when the same id is registered with a different path", () => { + const catalog = new StandardResourceCatalog(); + catalog.setCurrentFileContext("trigger/chat.ts", "chat"); + + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + + catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" }); + catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/other-pdf" }); + + const manifests = catalog.listSkillManifests(); + expect(manifests).toHaveLength(1); + expect(manifests[0]?.sourcePath).toBe("./skills/pdf"); + expect(warn).toHaveBeenCalledWith(expect.stringContaining("defined twice")); + + warn.mockRestore(); + }); + + it("re-registering the same id + path is idempotent", () => { + const catalog = new StandardResourceCatalog(); + catalog.setCurrentFileContext("trigger/chat.ts", "chat"); + + catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" }); + catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" }); + + expect(catalog.listSkillManifests()).toHaveLength(1); + }); + + it("registers multiple distinct skills", () => { + const catalog = new StandardResourceCatalog(); + catalog.setCurrentFileContext("trigger/chat.ts", "chat"); + + catalog.registerSkillMetadata({ id: "pdf", sourcePath: "./skills/pdf" }); + catalog.registerSkillMetadata({ id: "researcher", sourcePath: "./skills/researcher" }); + + expect(catalog.listSkillManifests().map((s) => s.id).sort()).toEqual(["pdf", "researcher"]); + }); +}); diff --git a/packages/trigger-sdk/package.json b/packages/trigger-sdk/package.json index eac075466f0..18446acbb9f 100644 --- a/packages/trigger-sdk/package.json +++ b/packages/trigger-sdk/package.json @@ -24,7 +24,12 @@ "./package.json": "./package.json", ".": "./src/v3/index.ts", "./v3": "./src/v3/index.ts", - "./ai": "./src/v3/ai.ts" + "./ai": "./src/v3/ai.ts", + "./ai/skills-runtime": "./src/v3/agentSkillsRuntime.ts", + "./ai/test": "./src/v3/test/index.ts", + "./chat": "./src/v3/chat.ts", + "./chat/react": "./src/v3/chat-react.ts", + "./chat-server": "./src/v3/chat-server.ts" }, "sourceDialects": [ "@triggerdotdev/source" @@ -37,6 +42,21 @@ ], "ai": [ "dist/commonjs/v3/ai.d.ts" + ], + "ai/skills-runtime": [ + "dist/commonjs/v3/agentSkillsRuntime.d.ts" + ], + "ai/test": [ + "dist/commonjs/v3/test/index.d.ts" + ], + "chat": [ + "dist/commonjs/v3/chat.d.ts" + ], + "chat/react": [ + "dist/commonjs/v3/chat-react.d.ts" + ], + "chat-server": [ + "dist/commonjs/v3/chat-server.d.ts" ] } }, @@ -63,11 +83,13 @@ "ws": "^8.11.0" }, "devDependencies": { + "@ai-sdk/provider": "3.0.8", "@arethetypeswrong/cli": "^0.15.4", "@types/debug": "^4.1.7", + "@types/react": "^19.2.14", "@types/slug": "^5.0.3", "@types/ws": "^8.5.3", - "ai": "^6.0.0", + "ai": "^6.0.116", "encoding": "^0.1.13", "rimraf": "^6.0.1", "tshy": "^3.0.2", @@ -76,12 +98,16 @@ "zod": "3.25.76" }, "peerDependencies": { - "zod": "^3.0.0 || ^4.0.0", - "ai": "^4.2.0 || ^5.0.0 || ^6.0.0" + "ai": "^5.0.0 || ^6.0.0", + "react": "^18.0 || ^19.0", + "zod": "^3.0.0 || ^4.0.0" }, "peerDependenciesMeta": { "ai": { "optional": true + }, + "react": { + "optional": true } }, "engines": { @@ -121,6 +147,61 @@ "types": "./dist/commonjs/v3/ai.d.ts", "default": "./dist/commonjs/v3/ai.js" } + }, + "./ai/skills-runtime": { + "import": { + "@triggerdotdev/source": "./src/v3/agentSkillsRuntime.ts", + "types": "./dist/esm/v3/agentSkillsRuntime.d.ts", + "default": "./dist/esm/v3/agentSkillsRuntime.js" + }, + "require": { + "types": "./dist/commonjs/v3/agentSkillsRuntime.d.ts", + "default": "./dist/commonjs/v3/agentSkillsRuntime.js" + } + }, + "./ai/test": { + "import": { + "@triggerdotdev/source": "./src/v3/test/index.ts", + "types": "./dist/esm/v3/test/index.d.ts", + "default": "./dist/esm/v3/test/index.js" + }, + "require": { + "types": "./dist/commonjs/v3/test/index.d.ts", + "default": "./dist/commonjs/v3/test/index.js" + } + }, + "./chat": { + "import": { + "@triggerdotdev/source": "./src/v3/chat.ts", + "types": "./dist/esm/v3/chat.d.ts", + "default": "./dist/esm/v3/chat.js" + }, + "require": { + "types": "./dist/commonjs/v3/chat.d.ts", + "default": "./dist/commonjs/v3/chat.js" + } + }, + "./chat/react": { + "import": { + "@triggerdotdev/source": "./src/v3/chat-react.ts", + "types": "./dist/esm/v3/chat-react.d.ts", + "default": "./dist/esm/v3/chat-react.js" + }, + "require": { + "types": "./dist/commonjs/v3/chat-react.d.ts", + "default": "./dist/commonjs/v3/chat-react.js" + } + }, + "./chat-server": { + "import": { + "@triggerdotdev/source": "./src/v3/chat-server.ts", + "types": "./dist/esm/v3/chat-server.d.ts", + "default": "./dist/esm/v3/chat-server.js" + }, + "require": { + "types": "./dist/commonjs/v3/chat-server.d.ts", + "default": "./dist/commonjs/v3/chat-server.js" + } } }, "main": "./dist/commonjs/v3/index.js", diff --git a/packages/trigger-sdk/src/v3/agentSkillsRuntime.ts b/packages/trigger-sdk/src/v3/agentSkillsRuntime.ts new file mode 100644 index 00000000000..2cb9d2f70bf --- /dev/null +++ b/packages/trigger-sdk/src/v3/agentSkillsRuntime.ts @@ -0,0 +1,166 @@ +import { spawn } from "node:child_process"; +import * as fs from "node:fs/promises"; +import * as nodePath from "node:path"; + +/** + * Server-only runtime for the auto-injected skill tools + * (`loadSkill` / `readFile` / `bash`) that `chat.agent({ skills })` + * wires up. Split off from `./ai.ts` so the chat-agent surface in + * `@trigger.dev/sdk/ai` stays importable from client bundles — + * Next.js + Webpack reject top-level `node:*` imports anywhere in a + * client graph, even when a consumer only pulls in types. + * + * The SDK's `ai.ts` loads this module via a computed-string dynamic + * import inside each tool's `execute` — webpack treats the + * expression as an unknown dependency and skips static tracing, so + * the node-only symbols here never surface in a client build. The + * module resolves fine at runtime on a server worker because the + * relative path (`./agentSkillsRuntime.js`) lands next to `ai.js` in + * the emitted dist. + * + * Public subpath: `@trigger.dev/sdk/ai/skills-runtime`. Customers + * who want to eagerly bundle the runtime server-side (e.g. warming + * it on worker bootstrap) can import from there. + */ + +const DEFAULT_BASH_OUTPUT_BYTES = 64 * 1024; +const DEFAULT_READ_FILE_BYTES = 1024 * 1024; + +export type BashSkillInput = { + /** Absolute path to the skill's root (used as `cwd`). */ + skillPath: string; + /** The bash command to run. */ + command: string; + /** Optional abort signal forwarded to `spawn()`. */ + abortSignal?: AbortSignal; +}; + +export type BashSkillResult = + | { exitCode: number | null; stdout: string; stderr: string } + | { error: string }; + +export type ReadFileInSkillInput = { + /** Absolute path to the skill's root — the relative path must resolve inside it. */ + skillPath: string; + /** Relative path the tool caller supplied. */ + relativePath: string; +}; + +export type ReadFileInSkillResult = { content: string } | { error: string }; + +function truncate(s: string, limit: number): string { + if (s.length <= limit) return s; + return s.slice(0, limit) + `\n…[truncated ${s.length - limit} bytes]`; +} + +/** + * Path-traversal guard: confirm `relative` resolves inside `root`, + * even after symlinks are followed. Throws if it escapes via `..`, an + * absolute prefix, or a symlink that points outside. Returns the + * resolved real path. + * + * `fs.realpath` only works on paths that exist, so when the resolved + * path doesn't exist yet (e.g. writing a new file) we fall back to + * the lexical check — a non-existent path can't traverse a symlink + * to escape since the symlink doesn't exist either. + */ +async function safeJoinInside(root: string, relative: string): Promise { + if (nodePath.isAbsolute(relative)) { + throw new Error(`Path must be relative to the skill directory: ${relative}`); + } + const realRoot = await fs.realpath(nodePath.resolve(root)); + const resolved = nodePath.resolve(realRoot, relative); + let real = resolved; + try { + real = await fs.realpath(resolved); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== "ENOENT") { + throw err; + } + // Path doesn't exist yet; fall through with the lexical resolve. + } + const normalized = realRoot + nodePath.sep; + if (real !== realRoot && !real.startsWith(normalized)) { + throw new Error(`Path escapes the skill directory: ${relative}`); + } + return real; +} + +export async function readFileInSkill({ + skillPath, + relativePath, +}: ReadFileInSkillInput): Promise { + let absolute: string; + try { + absolute = await safeJoinInside(skillPath, relativePath); + } catch (err) { + return { error: (err as Error).message }; + } + try { + const content = await fs.readFile(absolute, "utf8"); + return { content: truncate(content, DEFAULT_READ_FILE_BYTES) }; + } catch (err) { + return { error: (err as Error).message }; + } +} + +export async function runBashInSkill({ + skillPath, + command, + abortSignal, +}: BashSkillInput): Promise { + return new Promise((resolvePromise) => { + let child; + try { + child = spawn("bash", ["-c", command], { + cwd: skillPath, + signal: abortSignal, + }); + } catch (err) { + resolvePromise({ error: (err as Error).message }); + return; + } + + // Cap stdout/stderr accumulation at the byte budget so an + // LLM-generated command (`cat /dev/zero`, `yes`) can't OOM the + // worker. Track total seen length separately so the truncation + // notice still reports how much was dropped. + let stdout = ""; + let stderr = ""; + let stdoutSeen = 0; + let stderrSeen = 0; + const limit = DEFAULT_BASH_OUTPUT_BYTES; + child.stdout?.on("data", (chunk: Buffer | string) => { + const text = chunk.toString(); + stdoutSeen += text.length; + if (stdout.length >= limit) return; + const remaining = limit - stdout.length; + stdout += text.length > remaining ? text.slice(0, remaining) : text; + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + const text = chunk.toString(); + stderrSeen += text.length; + if (stderr.length >= limit) return; + const remaining = limit - stderr.length; + stderr += text.length > remaining ? text.slice(0, remaining) : text; + }); + child.once("close", (code: number | null) => { + const stdoutFinal = + stdoutSeen > stdout.length + ? `${stdout}\n…[truncated ${stdoutSeen - stdout.length} bytes]` + : stdout; + const stderrFinal = + stderrSeen > stderr.length + ? `${stderr}\n…[truncated ${stderrSeen - stderr.length} bytes]` + : stderr; + resolvePromise({ + exitCode: code, + stdout: stdoutFinal, + stderr: stderrFinal, + }); + }); + child.once("error", (err: Error) => { + resolvePromise({ error: err.message }); + }); + }); +} diff --git a/packages/trigger-sdk/src/v3/ai-shared.ts b/packages/trigger-sdk/src/v3/ai-shared.ts new file mode 100644 index 00000000000..9370ea4a164 --- /dev/null +++ b/packages/trigger-sdk/src/v3/ai-shared.ts @@ -0,0 +1,200 @@ +/** + * Browser-safe primitives shared between `@trigger.dev/sdk/ai` (server) and + * `@trigger.dev/sdk/chat` / `@trigger.dev/sdk/chat/react` (client). + * + * This module exists to keep `ai.ts` reachable only from the server graph. + * `ai.ts` weighs in at ~7000 lines and statically imports the agent-skills + * runtime (which uses `node:child_process` / `node:fs/promises`). When a + * browser bundle imports a runtime value from `ai.ts` — historically the + * `PENDING_MESSAGE_INJECTED_TYPE` constant in `chat-react.ts` — the bundler + * traces `ai.ts`'s entire module graph into the client chunk and hits the + * `node:` builtins, which Turbopack rejects outright (and webpack flags as + * a "Critical dependency" warning). + * + * Anything in this file MUST stay free of `node:*` imports and free of any + * import from `ai.ts`. + */ + +import type { Task, AnyTask } from "@trigger.dev/core/v3"; +import type { ModelMessage, UIMessage } from "ai"; + +/** + * Message-part `type` value for the pending-message data part the agent + * injects when a follow-up message arrives mid-turn. + */ +export const PENDING_MESSAGE_INJECTED_TYPE = "data-pending-message-injected" as const; + +/** + * The wire payload shape sent by `TriggerChatTransport`. + * Uses `metadata` to match the AI SDK's `ChatRequestOptions` field name. + * + * Slim wire: at most ONE message per record. The agent runtime + * reconstructs prior history at run boot from a durable S3 snapshot + + * `session.out` replay (or `hydrateMessages` if registered). The wire is + * delta-only — see plan `vivid-humming-bonbon.md`. + */ +export type ChatTaskWirePayload = { + /** + * The single message being delivered on this trigger. Set for: + * - `submit-message`: the new user message OR a tool-approval-responded + * assistant message (with `state: "approval-responded"` tool parts). + * - `regenerate-message`: omitted (the agent slices its own history). + * - `preload` / `close` / `action`: omitted. + * - `handover-prepare`: omitted (use `headStartMessages` instead). + */ + message?: TMessage; + /** + * Bespoke escape hatch for `chat.headStart`. The customer's HTTP route + * handler ships full `UIMessage[]` history at the very first turn — before + * any snapshot exists. The route handler isn't subject to the + * `MAX_APPEND_BODY_BYTES` cap on `/in/append` because it goes through the + * customer's own HTTP endpoint. Used ONLY by `trigger: "handover-prepare"`. + * Ignored on every other trigger. + */ + headStartMessages?: TMessage[]; + chatId: string; + trigger: + | "submit-message" + | "regenerate-message" + | "preload" + | "close" + | "action" + /** + * The customer's `chat.handover` route handler kicked us off in + * parallel with the first-turn `streamText` running in the warm + * Next.js process. The run sits idle on `session.in` waiting for + * a `kind: "handover"` (continue from tool execution) or + * `kind: "handover-skip"` (handler finished pure-text, exit + * cleanly). See `chat.handover` in `@trigger.dev/sdk/chat-server`. + */ + | "handover-prepare"; + messageId?: string; + metadata?: TMetadata; + /** Custom action payload when `trigger` is `"action"`. Validated against `actionSchema` on the backend. */ + action?: unknown; + /** Whether this run is continuing an existing chat whose previous run ended. */ + continuation?: boolean; + /** The run ID of the previous run (only set when `continuation` is true). */ + previousRunId?: string; + /** Override idle timeout for this run (seconds). Set by transport.preload(). */ + idleTimeoutInSeconds?: number; + /** + * The friendlyId of the Session primitive backing this chat. The + * transport opens (or lazy-creates) the session with + * `externalId = chatId` on first message, then sends this friendlyId + * through to the run so the agent can attach to `.in` / `.out` + * without needing to round-trip through the control plane again. + * Optional for backward-compat while the migration is in flight; + * required once the legacy run-scoped stream path is removed. + */ + sessionId?: string; +}; + +/** + * One chunk on the chat input stream. `kind` discriminates the variants — + * a single ordered stream now carries all the signals the old three-stream + * split did (`chat-messages`, `chat-stop`, plus action messages piggybacked + * on `chat-messages`). + */ +export type ChatInputChunk = + | { + kind: "message"; + /** + * Full wire payload for a new user message or regeneration. Mirrors + * what the legacy `chat-messages` input stream carried. + */ + payload: ChatTaskWirePayload; + } + | { + kind: "stop"; + /** Optional human-readable reason. Maps to the legacy `chat-stop` record. */ + message?: string; + } + | { + /** + * Sent by `chat.headStart` when the customer's first-turn + * `streamText` finishes. The agent run (currently parked in + * `handover-prepare`) wakes, seeds its accumulators with + * `partialAssistantMessage`, and runs the normal turn loop + * (`onChatStart` → `onTurnStart` → … → `onTurnComplete`). + * + * What happens after that depends on `isFinal`: + * + * - `isFinal: false` — step 1 ended with `finishReason: + * "tool-calls"`. The partial carries the assistant's + * tool-call(s) wrapped in AI SDK's tool-approval round. The + * agent's `streamText` runs the approved tools and continues + * from step 2. + * - `isFinal: true` — step 1 ended pure-text (no tool calls). + * The partial carries the final assistant text. The agent + * skips the LLM call entirely (the response is already + * complete on the customer side) and runs `onTurnComplete` + * with the partial as `responseMessage` so persistence and + * any post-turn work fire normally. + */ + kind: "handover"; + /** Customer's step-1 response messages (ModelMessage form). */ + partialAssistantMessage: ModelMessage[]; + /** + * The UI messageId the customer's handler used for its step-1 + * assistant message. The agent reuses this so any post-handover + * chunks (tool-output-available, step-2 text, data-* parts + * written by hooks) merge into the SAME assistant message on + * the browser side instead of starting a new one. + */ + messageId?: string; + /** + * Whether the customer's step 1 is the final response. See + * `kind` description above for the two branches. + */ + isFinal: boolean; + } + | { + /** + * Sent by `chat.headStart` only when the customer's handler + * ABORTS before producing a finishReason (e.g., dispatch error, + * stream cancelled before any tokens). The agent run exits + * cleanly without firing turn hooks. Normal pure-text and + * tool-call finishes go through `kind: "handover"` with the + * appropriate `isFinal` flag. + */ + kind: "handover-skip"; + }; + +/** + * Extracts the client-data (`metadata`) type from a chat task. + * + * @example + * ```ts + * import type { InferChatClientData } from "@trigger.dev/sdk/ai"; + * import type { myChat } from "@/trigger/chat"; + * + * type MyClientData = InferChatClientData; + * ``` + */ +export type InferChatClientData = TTask extends Task< + string, + ChatTaskWirePayload, + any +> + ? TMetadata + : unknown; + +/** + * Extracts the UI message type from a chat task (wire payload `message` items). + * + * @example + * ```ts + * import type { InferChatUIMessage } from "@trigger.dev/sdk/ai"; + * import type { myChat } from "@/trigger/chat"; + * + * type Msg = InferChatUIMessage; + * ``` + */ +export type InferChatUIMessage = TTask extends Task< + string, + ChatTaskWirePayload, + any +> + ? TUIM + : UIMessage; diff --git a/packages/trigger-sdk/src/v3/ai.ts b/packages/trigger-sdk/src/v3/ai.ts index 59afa2fe21a..63a129ae987 100644 --- a/packages/trigger-sdk/src/v3/ai.ts +++ b/packages/trigger-sdk/src/v3/ai.ts @@ -1,38 +1,845 @@ import { + accessoryAttributes, AnyTask, + apiClientManager, + getSchemaParseFn, + InputStreamOncePromise, + type InputStreamOnceOptions, + type InputStreamWaitOptions, + type InputStreamWaitWithIdleTimeoutOptions, isSchemaZodEsque, + logger, + type MachinePresetName, + ManualWaitpointPromise, + OutOfMemoryError, + sessionStreams, + type PipeStreamResult, + type RealtimeDefinedInputStream, + type RealtimeDefinedStream, + type ReadStreamOptions, + SemanticInternalAttributes, + type SendInputStreamOptions, Task, + taskContext, + type AppendStreamOptions, + type InputStreamOnceResult, type inferSchemaIn, + type inferSchemaOut, + type PipeStreamOptions, + type TaskIdentifier, + type TaskOptions, type TaskSchema, + type TaskRunContext, type TaskWithSchema, + type WriterStreamOptions, } from "@trigger.dev/core/v3"; -import { dynamicTool, jsonSchema, JSONSchema7, Schema, Tool, ToolCallOptions, zodSchema } from "ai"; +import type { + FinishReason, + ModelMessage, + ToolSet, + UIMessage, + UIMessageChunk, + UIMessageStreamOptions, + LanguageModelUsage, +} from "ai"; +import type { StreamWriteResult } from "@trigger.dev/core/v3"; +import { + convertToModelMessages, + dynamicTool, + generateId as generateMessageId, + getToolName, + isToolUIPart, + jsonSchema, + JSONSchema7, + readUIMessageStream, + Schema, + tool as aiTool, + Tool, + ToolCallOptions, + zodSchema, +} from "ai"; +import { type Attributes, trace } from "@opentelemetry/api"; +import { auth } from "./auth.js"; +import { locals } from "./locals.js"; import { metadata } from "./metadata.js"; +import type { ResolvedPrompt } from "./prompt.js"; +import type { ResolvedSkill } from "./skill.js"; +// Bash-skill runtime lives in `./agentSkillsRuntime.ts` (exposed as +// the `@trigger.dev/sdk/ai/skills-runtime` subpath). It's a normal +// static import — `ai.ts` is server-only by reachability now that +// browser-side primitives (PENDING_MESSAGE_INJECTED_TYPE and the +// chat-task wire types) live in `./ai-shared.ts`. Any browser bundle +// that wants those primitives imports `./ai-shared.js` directly and +// never touches `ai.ts`'s module graph, so the `node:*` builtins +// pulled in transitively here never reach a client chunk. +import { runBashInSkill, readFileInSkill } from "./agentSkillsRuntime.js"; +import { streams } from "./streams.js"; +import { + sessions, + type SessionHandle, + type SessionInputChannel, + type SessionOutputChannel, + type SessionPipeStreamOptions, + type SessionSubscribeOptions, +} from "./sessions.js"; +import { createTask } from "./shared.js"; +import { resourceCatalog, type SessionTriggerConfig } from "@trigger.dev/core/v3"; +import { tracer } from "./tracer.js"; + +/** Re-export for typing `ctx` in `chat.agent` hooks without importing `@trigger.dev/core`. */ +export type { TaskRunContext } from "@trigger.dev/core/v3"; const METADATA_KEY = "tool.execute.options"; -export type ToolCallExecutionOptions = Omit; +/** + * Wrapper around `convertToModelMessages` that always passes + * `ignoreIncompleteToolCalls: true` to prevent failures from + * stopped/aborted conversations with partial tool parts. + */ +function toModelMessages(messages: UIMessage[]): Promise { + return convertToModelMessages(messages, { ignoreIncompleteToolCalls: true }); +} + +export type ToolCallExecutionOptions = { + toolCallId: string; + experimental_context?: unknown; + /** Chat context — only present when the tool runs inside a chat.agent turn. */ + chatId?: string; + turn?: number; + continuation?: boolean; + clientData?: unknown; + /** Serialized chat.local values from the parent run. @internal */ + chatLocals?: Record; +}; + +/** Chat context stored in locals during each chat.agent turn for auto-detection. */ +type ChatTurnContext = { + chatId: string; + turn: number; + continuation: boolean; + clientData?: TClientData; +}; +const chatTurnContextKey = locals.create("chat.turnContext"); + +/** + * Per-run slot holding the Session handle that backs this chat's `.in` / + * `.out` channels. Populated at the top of `chatAgent`'s run function from + * `payload.sessionId`; read by every module-level helper (`chatStream`, + * `messagesInput`, `stopInput`) so the chat.agent internals can remain + * the same module-level shape they were when the I/O was run-scoped. + * @internal + */ +const chatSessionHandleKey = locals.create("chat.sessionHandle"); + +/** + * Scan `session.out` for the latest `trigger:turn-complete` chunk and + * return its SSE timestamp. Used at OOM-retry boot to derive a + * lower-bound timestamp for the `session.in` filter — records older + * than `T_last_complete` belong to turns that already completed on the + * prior attempt and are dropped before they reach the turn loop. + * + * Implementation is a streaming scan: subscribes via the existing SSE + * endpoint with a short `timeoutInSeconds`, processes each part inline, + * and discards the chunk body so memory stays O(1) regardless of how + * many records are on `session.out`. Bandwidth scales linearly with + * stream length but the scan only fires on retry — a rare event. + * + * Returns `undefined` if no `trigger:turn-complete` chunk has been + * written yet (first-turn OOM, no completed turns to dedup against). + * @internal + */ +async function findLatestTurnCompleteTimestamp( + chatId: string +): Promise { + const apiClient = apiClientManager.clientOrThrow(); + let latestTs: number | undefined; + const stream = await apiClient.subscribeToSessionStream(chatId, "out", { + timeoutInSeconds: 1, + onPart: (part) => { + let chunk: unknown = part.chunk; + if (typeof chunk === "string") { + try { + chunk = JSON.parse(chunk); + } catch { + return; + } + } + if (chunk && typeof chunk === "object" && (chunk as { type?: unknown }).type === "trigger:turn-complete") { + latestTs = part.timestamp; + } + }, + }); + // Drain the stream to drive `onPart`. We don't accumulate the chunks — + // each iteration discards the data immediately, so a long session.out + // doesn't blow memory on the retry-boot worker. + for await (const _ of stream) { + // intentionally empty + } + return latestTs; +} + +/** + * Versioned blob written to S3 after every turn completes (when no + * `hydrateMessages` hook is registered). Read at run boot to seed the + * accumulator with prior conversation state, replacing the old wire-borne + * full-history seed. Only the runtime owns this format — customers never + * touch it. + * + * `lastOutEventId` is the SSE Last-Event-ID after the snapshot's final + * chunk, used to resume `session.out` replay from precisely after the + * snapshot. `lastOutTimestamp` is the same chunk's timestamp, used to + * skip `findLatestTurnCompleteTimestamp` on OOM retry boot. + * + * @internal + */ +export type ChatSnapshotV1 = { + version: 1; + savedAt: number; + messages: TUIMessage[]; + lastOutEventId?: string; + lastOutTimestamp?: number; +}; + +/** + * S3 key suffix for a session's snapshot blob. The webapp's presigned-URL + * routes prefix this with `packets/{projectRef}/{envSlug}/` server-side, so + * the final S3 key lands at + * `packets/{projectRef}/{envSlug}/sessions/{sessionId}/snapshot.json`. + * + * Stable per session: the friendlyId persists across `chat.requestUpgrade` + * continuations and idle-suspend restarts. + * @internal + */ +function snapshotFilename(sessionId: string): string { + return `sessions/${sessionId}/snapshot.json`; +} + +/** + * Test-only override hook — `mockChatAgent` installs a fake to return + * synthetic snapshots without hitting S3. Mirrors the `__set*ImplForTests` + * pattern in `sessions.ts`. Not part of the public API. + * @internal + */ +type ReadChatSnapshotImpl = ( + sessionId: string +) => Promise | undefined> | ChatSnapshotV1 | undefined; +let readChatSnapshotImpl: ReadChatSnapshotImpl | undefined; + +export function __setReadChatSnapshotImplForTests(impl: ReadChatSnapshotImpl | undefined): void { + readChatSnapshotImpl = impl; +} + +/** + * Test-only override hook — see `__setReadChatSnapshotImplForTests`. The + * mock harness records writes for assertion via this setter. Not public. + * @internal + */ +type WriteChatSnapshotImpl = ( + sessionId: string, + snapshot: ChatSnapshotV1 +) => Promise | void; +let writeChatSnapshotImpl: WriteChatSnapshotImpl | undefined; + +export function __setWriteChatSnapshotImplForTests(impl: WriteChatSnapshotImpl | undefined): void { + writeChatSnapshotImpl = impl; +} + +/** + * Read the persisted snapshot for a session. Returns `undefined` on: + * - missing object (404 from the presigned GET — fresh session, never + * persisted) + * - presign failure (network/auth issue) + * - malformed JSON + * - version mismatch (forward-compat — older runtimes ignore newer blobs) + * + * Always swallows errors via `logger.warn`. The agent boot loop must stay + * available even if S3 hiccups; the worst case is replaying more of + * `session.out` than strictly necessary. + * @internal + */ +async function readChatSnapshot( + sessionId: string +): Promise | undefined> { + if (readChatSnapshotImpl) { + return (await readChatSnapshotImpl(sessionId)) ?? undefined; + } + const apiClient = apiClientManager.clientOrThrow(); + let presignedUrl: string; + try { + const resp = await apiClient.getPayloadUrl(snapshotFilename(sessionId)); + presignedUrl = resp.presignedUrl; + } catch (error) { + logger.warn("chat.agent: snapshot presign (read) failed; continuing without snapshot", { + error: error instanceof Error ? error.message : String(error), + sessionId, + }); + return undefined; + } + let response: Response; + try { + response = await fetch(presignedUrl, { method: "GET" }); + } catch (error) { + logger.warn("chat.agent: snapshot fetch failed; continuing without snapshot", { + error: error instanceof Error ? error.message : String(error), + sessionId, + }); + return undefined; + } + if (response.status === 404) { + // First-ever boot for this session — no snapshot yet. Caller falls + // through to replay-only. + return undefined; + } + if (!response.ok) { + logger.warn("chat.agent: snapshot fetch returned non-OK; continuing without snapshot", { + status: response.status, + sessionId, + }); + return undefined; + } + let parsed: unknown; + try { + parsed = await response.json(); + } catch (error) { + logger.warn("chat.agent: snapshot JSON parse failed; continuing without snapshot", { + error: error instanceof Error ? error.message : String(error), + sessionId, + }); + return undefined; + } + if (!parsed || typeof parsed !== "object") return undefined; + const candidate = parsed as Partial>; + if (candidate.version !== 1 || !Array.isArray(candidate.messages)) { + logger.warn("chat.agent: snapshot version/shape mismatch; ignoring", { + version: candidate.version, + sessionId, + }); + return undefined; + } + return candidate as ChatSnapshotV1; +} + +/** + * Persist the snapshot for a session. Awaited by callers immediately after + * `onTurnComplete` — the agent may suspend right after this point, and + * fire-and-forget promises don't reliably complete on suspend. + * + * Errors are swallowed via `logger.warn`. A failed write means the next + * boot replays slightly more of `session.out` (back to the previous + * snapshot's cursor) instead of failing — the conversation stays + * coherent, only the boot path does marginally more work. + * @internal + */ +async function writeChatSnapshot( + sessionId: string, + snapshot: ChatSnapshotV1 +): Promise { + if (writeChatSnapshotImpl) { + await writeChatSnapshotImpl(sessionId, snapshot); + return; + } + const apiClient = apiClientManager.clientOrThrow(); + let presignedUrl: string; + try { + const resp = await apiClient.createUploadPayloadUrl(snapshotFilename(sessionId)); + presignedUrl = resp.presignedUrl; + } catch (error) { + logger.warn("chat.agent: snapshot presign (write) failed; next run will replay further", { + error: error instanceof Error ? error.message : String(error), + sessionId, + }); + return; + } + let response: Response; + try { + response = await fetch(presignedUrl, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(snapshot), + }); + } catch (error) { + logger.warn("chat.agent: snapshot upload failed; next run will replay further", { + error: error instanceof Error ? error.message : String(error), + sessionId, + }); + return; + } + if (!response.ok) { + logger.warn("chat.agent: snapshot upload returned non-OK; next run will replay further", { + status: response.status, + sessionId, + }); + } +} + +/** + * Test-only entry point that bypasses `__setReadChatSnapshotImplForTests` + * and reaches the real `apiClient.getPayloadUrl` + `fetch` + JSON-parse path. + * Used by `chat-snapshot.test.ts` to verify 404 / 500 / malformed JSON / + * version-mismatch / network-error behavior end-to-end. Tests mock global + * `fetch` and the api-client config; this wrapper lets them drive the + * production code without the override hook short-circuiting. + * + * Not part of the public API. The `__` prefix and `ForTests` suffix mirror + * the override-hook setters above. + * @internal + */ +export async function __readChatSnapshotProductionPathForTests( + sessionId: string +): Promise | undefined> { + const saved = readChatSnapshotImpl; + readChatSnapshotImpl = undefined; + try { + return await readChatSnapshot(sessionId); + } finally { + readChatSnapshotImpl = saved; + } +} + +/** + * Test-only entry point that bypasses `__setWriteChatSnapshotImplForTests` + * and reaches the real `apiClient.createUploadPayloadUrl` + `fetch` PUT + * path. Pairs with `__readChatSnapshotProductionPathForTests` — see that + * function's note for the rationale. + * + * Not part of the public API. + * @internal + */ +export async function __writeChatSnapshotProductionPathForTests( + sessionId: string, + snapshot: ChatSnapshotV1 +): Promise { + const saved = writeChatSnapshotImpl; + writeChatSnapshotImpl = undefined; + try { + await writeChatSnapshot(sessionId, snapshot); + } finally { + writeChatSnapshotImpl = saved; + } +} + +/** + * Merge two `UIMessage[]` lists by `id`, with the second list winning on + * collision. Used at run boot to combine the snapshot's persisted history + * with the replayed `session.out` tail — replay produces the freshest + * representation of any assistant message that landed after the snapshot's + * cursor, so it should overwrite the older copy from the snapshot. + * + * Order: items unique to `a` keep their original positions; items unique to + * `b` are appended at the end in their `b` order; collisions take `b`'s + * value but keep the position they had in `a`. + * + * @internal + */ +function mergeByIdReplaceWins( + a: TUIMessage[], + b: TUIMessage[] +): TUIMessage[] { + if (b.length === 0) return [...a]; + if (a.length === 0) return [...b]; + const indexById = new Map(); + for (let i = 0; i < a.length; i++) { + const id = a[i]!.id; + if (typeof id === "string" && id.length > 0) indexById.set(id, i); + } + const result = [...a]; + for (const next of b) { + const id = next.id; + if (typeof id === "string" && id.length > 0 && indexById.has(id)) { + result[indexById.get(id)!] = next; + } else { + const newIdx = result.length; + result.push(next); + if (typeof id === "string" && id.length > 0) indexById.set(id, newIdx); + } + } + return result; +} + +/** + * Test-only entry point for `mergeByIdReplaceWins`. The merge helper is the + * one piece of slim-wire boot logic that's purely functional, so it earns a + * direct unit test that exercises empty inputs, id collisions, no-id append, + * order preservation, and the replay-wins-on-collision invariant. Mirrors + * the `__*ProductionPathForTests` pattern used for the snapshot/replay + * helpers above. + * + * Not part of the public API. + * @internal + */ +export function __mergeByIdReplaceWinsForTests( + a: TUIMessage[], + b: TUIMessage[] +): TUIMessage[] { + return mergeByIdReplaceWins(a, b); +} + +/** + * Test-only override hook — `mockChatAgent` installs a fake replay that + * returns a synthetic `UIMessage[]` so unit tests can drive the boot loop + * without an SSE subscription. Mirrors the snapshot setters above. Not + * part of the public API. + * @internal + */ +type ReplaySessionOutTailImpl = ( + sessionId: string, + options?: { lastEventId?: string } +) => Promise; +let replaySessionOutTailImpl: ReplaySessionOutTailImpl | undefined; + +export function __setReplaySessionOutTailImplForTests( + impl: ReplaySessionOutTailImpl | undefined +): void { + replaySessionOutTailImpl = impl; +} + +/** + * Drain `session.out` from `lastEventId` (or the start) and reduce the + * remaining `UIMessageChunk`s back into `UIMessage[]`. Used at run boot to + * catch any chunks that landed AFTER the last persisted snapshot — typically + * the chunks from the turn whose `onTurnComplete` ran but whose snapshot + * write didn't make it to S3 before the run crashed / suspended. + * + * Implementation: + * 1. `apiClient.readSessionStreamRecords` — non-SSE, `wait=0` drain. + * Returns immediately with whatever records exist after the cursor. + * The previous SSE-subscribe path paid a fixed ~1s long-poll tax on + * every fresh chat (timeout duration on empty streams) — unacceptable + * for the first-message TTFC budget. + * 2. Filter out the agent's control chunks (`type: "trigger:*"`) — they + * ride on the same stream as the user-visible UIMessageChunks. + * 3. Split chunks at `start`/`finish` boundaries so each segment is a + * single message, then feed each segment through the AI SDK's + * `readUIMessageStream` reducer (the same one `useChat` uses on the + * browser side) and grab the final emitted snapshot. + * 4. The trailing message — if it never received a `finish` chunk — + * goes through `cleanupAbortedParts` so partial in-flight parts + * don't leak into the next turn's accumulator. Drop it entirely + * if cleanup empties it. + * + * Errors are propagated to the caller (the boot loop wraps in try/catch and + * `logger.warn`s); we don't swallow here so test code can observe failures + * directly. + * @internal + */ +async function replaySessionOutTail( + sessionId: string, + options?: { lastEventId?: string } +): Promise { + if (replaySessionOutTailImpl) { + return await replaySessionOutTailImpl(sessionId, options); + } + const apiClient = apiClientManager.clientOrThrow(); + const response = await apiClient.readSessionStreamRecords(sessionId, "out", { + afterEventId: options?.lastEventId, + }); + const collected: UIMessageChunk[] = []; + for (const record of response.records) { + // Each record's `data` is the JSON-encoded chunk body the agent + // wrote at append time. The records endpoint returns it as an + // opaque string so the parsing cost is paid here, not on the + // server's hot path. + let chunk: unknown; + try { + chunk = JSON.parse(record.data); + } catch { + continue; + } + if (!chunk || typeof chunk !== "object") continue; + const type = (chunk as { type?: unknown }).type; + if (typeof type !== "string") continue; + // Drop agent control chunks (`trigger:turn-complete`, `trigger:upgrade-required`, + // session-state telemetry, etc.). They ride the same stream but aren't part + // of the UIMessageChunk discriminated union and would confuse the reducer. + if (type.startsWith("trigger:")) continue; + collected.push(chunk as UIMessageChunk); + } + if (collected.length === 0) return []; + + // Split chunks into per-message segments. A `start` chunk demarcates the + // beginning of an assistant message; chunks before any `start` (rare — + // but possible if the stream begins mid-message after a resume) get + // bundled into a leading "implicit" segment so we don't drop them silently. + type Segment = { chunks: UIMessageChunk[]; closed: boolean }; + const segments: Segment[] = []; + let current: Segment | undefined; + for (const chunk of collected) { + if (chunk.type === "start") { + current = { chunks: [chunk], closed: false }; + segments.push(current); + continue; + } + if (!current) { + // Chunk arrived before any `start`. Synthesize a segment so the reducer + // has something to work with — `readUIMessageStream` tolerates a missing + // `start` because we pass `message: undefined`. + current = { chunks: [], closed: false }; + segments.push(current); + } + current.chunks.push(chunk); + if (chunk.type === "finish") { + current.closed = true; + current = undefined; + } + } + + const messages: TUIMessage[] = []; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]!; + const isTrailing = i === segments.length - 1 && !seg.closed; + const segmentStream = new ReadableStream({ + start(controller) { + for (const c of seg.chunks) controller.enqueue(c); + controller.close(); + }, + }); + let last: UIMessage | undefined; + try { + for await (const snapshot of readUIMessageStream({ stream: segmentStream })) { + last = snapshot; + } + } catch (error) { + // Reducer error — the segment is malformed. Skip it and keep going so a + // single corrupt chunk doesn't sink the entire replay. + logger.warn("chat.agent: replay reducer failed for segment; skipping", { + sessionId, + segmentIndex: i, + error: error instanceof Error ? error.message : String(error), + }); + continue; + } + if (!last) continue; + if (isTrailing) { + const cleaned = cleanupAbortedParts(last as TUIMessage); + if (cleaned.parts.length === 0) continue; + messages.push(cleaned); + } else { + messages.push(last as TUIMessage); + } + } + return messages; +} + +/** + * Test-only entry point that bypasses `__setReplaySessionOutTailImplForTests` + * and reaches the real `apiClient.subscribeToSessionStream` + chunk-segment + * splitter + `readUIMessageStream` reducer. Pairs with the snapshot + * production-path wrappers above. Lets `replay-session-out.test.ts` drive + * synthetic chunk sequences through the real reducer to lock down chunk- + * stream → `UIMessage[]` correctness — if the AI SDK's chunk semantics + * shift in a future version, the test catches it before customers do. + * + * Tests should mock `apiClient.subscribeToSessionStream` (e.g. via + * `vi.spyOn(apiClient, ...)`) to feed a `ReadableStream`. + * + * Not part of the public API. + * @internal + */ +export async function __replaySessionOutTailProductionPathForTests< + TUIMessage extends UIMessage, +>( + sessionId: string, + options?: { lastEventId?: string } +): Promise { + const saved = replaySessionOutTailImpl; + replaySessionOutTailImpl = undefined; + try { + return await replaySessionOutTail(sessionId, options); + } finally { + replaySessionOutTailImpl = saved; + } +} + +/** + * Resolve the Session handle for the current chat.agent run. + * + * Two contexts populate this: + * 1. Inside a `chat.agent` run — `locals.chatSessionHandleKey` is set + * at boot (lines 4665 / 4760). + * 2. Inside an `ai.toolExecute` subtask — the tool wrapper threads the + * parent's `chatId` through tool metadata under `METADATA_KEY`. We + * lazily open the session from that chatId here so subtasks can use + * `chat.stream.writer({ target: "root" })` to write back to the + * parent's chat session without any wiring. + * + * Throws if neither is available. + * @internal + */ +function getChatSession(): SessionHandle { + let handle = locals.get(chatSessionHandleKey); + if (handle) return handle; + + // Fallback: subtask context. The parent threaded chatId via tool metadata. + const toolMeta = metadata.get(METADATA_KEY) as ToolCallExecutionOptions | undefined; + if (toolMeta?.chatId) { + handle = sessions.open(toolMeta.chatId); + locals.set(chatSessionHandleKey, handle); + return handle; + } + + throw new Error( + "chat.agent session handle is not initialized. This indicates a chat.agent helper was used outside of a chat.agent run, or the transport did not send a sessionId." + ); +} + +/** + * Stamp `gen_ai.conversation.id` on the active span at chat-run boot. + * The run-level span is already alive when the run callback fires, so + * `TaskContextSpanProcessor.onStart` (which stamps subsequent spans + * automatically) won't catch it — set explicitly here. + */ +function stampConversationIdOnActiveSpan( + conversationId: string | undefined, + span = trace.getActiveSpan() +): void { + if (!span || !conversationId) return; + span.setAttribute(SemanticInternalAttributes.GEN_AI_CONVERSATION_ID, conversationId); +} type ToolResultContent = Array< | { - type: "text"; - text: string; - } + type: "text"; + text: string; + } | { - type: "image"; - data: string; - mimeType?: string; - } + type: "image"; + data: string; + mimeType?: string; + } >; export type ToolOptions = { experimental_toToolResultContent?: (result: TResult) => ToolResultContent; }; +/** Satisfies AI SDK `ToolSet` index signature alongside concrete `Tool` input/output types. */ +type ToolSetCompatible> = T & NonNullable; + +function assertTaskUsableAsTool(task: AnyTask): void { + if (("schema" in task && !task.schema) || ("jsonSchema" in task && !task.jsonSchema)) { + throw new Error( + "Cannot convert this task to to a tool because the task has no schema. Make sure to either use schemaTask or a task with an input jsonSchema." + ); + } +} + +/** + * Shared implementation: run a task as a tool invocation (`triggerAndSubscribe` + tool metadata). + * Used by {@link toolExecute} and the deprecated `ai.tool()` wrapper. + */ +function createTaskToolExecuteHandler< + TIdentifier extends string, + TTaskSchema extends TaskSchema | undefined = undefined, + TInput = void, + TOutput = unknown, +>( + task: TaskWithSchema | Task +): (input: unknown, toolOpts: ToolCallOptions | undefined) => Promise { + assertTaskUsableAsTool(task); + + return async function taskToolExecuteHandler( + input: unknown, + toolOpts: ToolCallOptions | undefined + ): Promise { + const toolMeta: ToolCallExecutionOptions = { + toolCallId: toolOpts?.toolCallId ?? "", + }; + if (toolOpts?.experimental_context !== undefined) { + try { + toolMeta.experimental_context = JSON.parse(JSON.stringify(toolOpts.experimental_context)); + } catch { + /* non-serializable */ + } + } + + const chatCtx = locals.get(chatTurnContextKey); + if (chatCtx) { + toolMeta.chatId = chatCtx.chatId; + toolMeta.turn = chatCtx.turn; + toolMeta.continuation = chatCtx.continuation; + toolMeta.clientData = chatCtx.clientData; + } + + const chatLocals: Record = {}; + for (const entry of chatLocalRegistry) { + const value = locals.get(entry.key); + if (value !== undefined) { + chatLocals[entry.id] = value; + } + } + if (Object.keys(chatLocals).length > 0) { + toolMeta.chatLocals = chatLocals; + } + + return await task + .triggerAndSubscribe(input as inferSchemaIn, { + metadata: { + [METADATA_KEY]: toolMeta as any, + }, + tags: toolOpts?.toolCallId ? [`toolCallId:${toolOpts.toolCallId}`] : undefined, + signal: toolOpts?.abortSignal, + }) + .unwrap(); + }; +} + +/** + * Returns an `execute` function for the AI SDK `tool()` helper (or any compatible tool definition). + * Preferred API for task-backed tools: the same Trigger wiring as the deprecated `ai.tool()` + * (`triggerAndSubscribe`, tool-call metadata, chat context, `chat.local` serialization) without + * building the tool object. You supply `description`, `inputSchema`, and any AI-SDK-only options + * (e.g. `experimental_toToolResultContent`) on `tool()` yourself. + * + * @example + * ```ts + * import { tool } from "ai"; + * import { z } from "zod"; + * import { ai } from "@trigger.dev/sdk/ai"; + * import { myTask } from "./trigger/myTask"; + * + * export const myTool = tool({ + * description: myTask.description ?? "", + * inputSchema: z.object({ id: z.string() }), + * execute: ai.toolExecute(myTask), + * }); + * ``` + */ +function toolExecute( + task: Task +): (input: TInput, toolOpts: ToolCallOptions) => Promise; +function toolExecute< + TIdentifier extends string, + TTaskSchema extends TaskSchema | undefined = undefined, + TOutput = unknown, +>( + task: TaskWithSchema +): (input: inferSchemaIn, toolOpts: ToolCallOptions) => Promise; +function toolExecute< + TIdentifier extends string, + TTaskSchema extends TaskSchema | undefined = undefined, + TInput = void, + TOutput = unknown, +>( + task: TaskWithSchema | Task +): ( + input: TTaskSchema extends TaskSchema ? inferSchemaIn : TInput, + toolOpts: ToolCallOptions +) => Promise { + return createTaskToolExecuteHandler(task) as ( + input: TTaskSchema extends TaskSchema ? inferSchemaIn : TInput, + toolOpts: ToolCallOptions + ) => Promise; +} + +/** + * @deprecated Use `tool()` from the `ai` package with `execute: ai.toolExecute(task)` instead. + * This helper may be removed in a future major release. + */ function toolFromTask( task: Task, options?: ToolOptions -): Tool; +): ToolSetCompatible>; +/** @deprecated Use `tool()` from `ai` with `execute: ai.toolExecute(task)`. */ function toolFromTask< TIdentifier extends string, TTaskSchema extends TaskSchema | undefined = undefined, @@ -40,7 +847,8 @@ function toolFromTask< >( task: TaskWithSchema, options?: ToolOptions -): Tool, TOutput>; +): ToolSetCompatible, TOutput>>; +/** @deprecated Use `tool()` from `ai` with `execute: ai.toolExecute(task)`. */ function toolFromTask< TIdentifier extends string, TTaskSchema extends TaskSchema | undefined = undefined, @@ -49,35 +857,41 @@ function toolFromTask< >( task: TaskWithSchema | Task, options?: ToolOptions -): TTaskSchema extends TaskSchema - ? Tool, TOutput> - : Tool { - if (("schema" in task && !task.schema) || ("jsonSchema" in task && !task.jsonSchema)) { - throw new Error( - "Cannot convert this task to to a tool because the task has no schema. Make sure to either use schemaTask or a task with an input jsonSchema." - ); +): ToolSetCompatible< + TTaskSchema extends TaskSchema ? Tool, TOutput> : Tool +> { + const executeFromTaskInput = createTaskToolExecuteHandler(task); + + // Zod-backed tasks: use static `tool()` so runtime shape matches `ToolSet`. Generic task context + // prevents `tool()` overloads from inferring input; `as any` is localized to this call only. + if ("schema" in task && task.schema && isSchemaZodEsque(task.schema)) { + const staticTool = aiTool({ + description: task.description ?? "", + inputSchema: zodSchema(task.schema as any), + execute: async (input: unknown, toolOpts: ToolCallOptions) => + executeFromTaskInput(input, toolOpts), + ...(options?.experimental_toToolResultContent !== undefined + ? { experimental_toToolResultContent: options.experimental_toToolResultContent } + : {}), + } as any); + return staticTool as unknown as ToolSetCompatible< + TTaskSchema extends TaskSchema ? Tool, TOutput> : Tool + >; } const toolDefinition = dynamicTool({ description: task.description, inputSchema: convertTaskSchemaToToolParameters(task), - execute: async (input, options) => { - const serializedOptions = options ? JSON.parse(JSON.stringify(options)) : undefined; - - return await task - .triggerAndWait(input as inferSchemaIn, { - metadata: { - [METADATA_KEY]: serializedOptions, - }, - }) - .unwrap(); - }, - ...options, + ...(options?.experimental_toToolResultContent !== undefined + ? { experimental_toToolResultContent: options.experimental_toToolResultContent } + : {}), + execute: async (input: unknown, toolOpts: ToolCallOptions) => + executeFromTaskInput(input, toolOpts), }); - return toolDefinition as TTaskSchema extends TaskSchema - ? Tool, TOutput> - : Tool; + return toolDefinition as unknown as ToolSetCompatible< + TTaskSchema extends TaskSchema ? Tool, TOutput> : Tool + >; } function getToolOptionsFromMetadata(): ToolCallExecutionOptions | undefined { @@ -88,6 +902,61 @@ function getToolOptionsFromMetadata(): ToolCallExecutionOptions | undefined { return tool as ToolCallExecutionOptions; } +/** + * Get the current tool call ID from inside a subtask invoked via `ai.toolExecute()` (or legacy `ai.tool()`). + * Returns `undefined` if not running as a tool subtask. + */ +function getToolCallId(): string | undefined { + return getToolOptionsFromMetadata()?.toolCallId; +} + +/** + * Get the chat context from inside a subtask invoked via `ai.toolExecute()` (or legacy `ai.tool()`) within a `chat.agent`. + * Pass `typeof yourChatTask` as the type parameter to get typed `clientData`. + * Returns `undefined` if the parent is not a chat task. + * + * @example + * ```ts + * const ctx = ai.chatContext(); + * // ctx?.clientData is typed based on myChat's clientDataSchema + * ``` + */ +function getToolChatContext(): + | ChatTurnContext> + | undefined { + const opts = getToolOptionsFromMetadata(); + if (!opts?.chatId) return undefined; + return { + chatId: opts.chatId, + turn: opts.turn ?? 0, + continuation: opts.continuation ?? false, + clientData: opts.clientData as InferChatClientData, + }; +} + +/** + * Get the chat context from inside a subtask, throwing if not in a chat context. + * Pass `typeof yourChatTask` as the type parameter to get typed `clientData`. + * + * @example + * ```ts + * const ctx = ai.chatContextOrThrow(); + * // ctx.chatId, ctx.clientData are guaranteed non-null + * ``` + */ +function getToolChatContextOrThrow(): ChatTurnContext< + InferChatClientData +> { + const ctx = getToolChatContext(); + if (!ctx) { + throw new Error( + "ai.chatContextOrThrow() called outside of a chat.agent context. " + + "This helper can only be used inside a subtask invoked via ai.toolExecute() (or legacy ai.tool()) from a chat.agent." + ); + } + return ctx; +} + function convertTaskSchemaToToolParameters( task: AnyTask | TaskWithSchema ): Schema { @@ -113,6 +982,7825 @@ function convertTaskSchemaToToolParameters( } export const ai = { + /** + * @deprecated Use `tool()` from the `ai` package with `execute: ai.toolExecute(task)` instead. + */ tool: toolFromTask, + /** + * Preferred: return value for the `execute` field of AI SDK `tool()`. Keeps Trigger subtask and + * metadata behavior without coupling to a specific `ai` version’s `Tool` / `ToolSet` types. + */ + toolExecute, currentToolOptions: getToolOptionsFromMetadata, + /** Get the tool call ID from inside a subtask invoked via `ai.toolExecute()` (or legacy `ai.tool()`). */ + toolCallId: getToolCallId, + /** Get chat context (chatId, turn, clientData, etc.) from inside a subtask of a `chat.agent`. Returns undefined if not in a chat context. */ + chatContext: getToolChatContext, + /** Get chat context or throw if not in a chat context. Pass `typeof yourChatTask` for typed clientData. */ + chatContextOrThrow: getToolChatContextOrThrow, +}; + +/** + * Creates a public access token for a chat task. + * + * This is a convenience helper that creates a multi-use trigger public token + * scoped to the given task. Use it in a server action to provide the frontend + * `TriggerChatTransport` with an `accessToken`. + * + * @example + * ```ts + * // actions.ts + * "use server"; + * import { chat } from "@trigger.dev/sdk/ai"; + * import type { myChat } from "@/trigger/chat"; + * + * export const getChatToken = () => chat.createAccessToken("my-chat"); + * ``` + */ +function createChatAccessToken( + taskId: TaskIdentifier +): Promise { + return auth.createTriggerPublicToken(taskId as string, { expirationTime: "24h" }); +} + +// --------------------------------------------------------------------------- +// Chat transport helpers — backend side +// --------------------------------------------------------------------------- + +/** + * Typed chat output stream — `.writer()`, `.pipe()`, `.append()`, and + * `.read()` methods pre-bound to this run's Session `.out` channel and + * typed to `UIMessageChunk`. + * + * Use from within a `chat.agent` run to write custom chunks: + * ```ts + * const { waitUntilComplete } = chat.stream.writer({ + * execute: ({ write }) => { + * write({ type: "text-start", id: "status-1" }); + * write({ type: "text-delta", id: "status-1", delta: "Processing..." }); + * write({ type: "text-end", id: "status-1" }); + * }, + * }); + * await waitUntilComplete(); + * ``` + * + * Backed by the Session primitive so a chat's output outlives any single + * run — subscribers (browser transport, server-side `ChatStream`) read + * the session's `.out`, not a per-run stream. Run-scoped `target` + * options on `.pipe()` are honoured as no-ops; the session is the target. + */ +const chatStream: RealtimeDefinedStream = { + // Stable opaque label for the run-scoped `RealtimeDefinedStream` shape. + // `chatStream` is backed by the Session's `.out` channel — this id is + // not the real addressing key (the session is). Kept as a literal so + // the facade type stays satisfied without re-introducing a top-level + // constant; dashboards/telemetry that already read "chat" keep working. + id: "chat", + pipe(value, options) { + const { target: _target, ...sessionOptions } = (options ?? {}) as PipeStreamOptions; + return getChatSession().out.pipe( + value, + sessionOptions as SessionPipeStreamOptions + ); + }, + async read(_runId, options) { + // Session channels don't need a runId — the session is the address. + // Keep the signature for backward compatibility with the run-scoped + // RealtimeDefinedStream shape, but ignore the argument. + return getChatSession().out.read( + options as SessionSubscribeOptions | undefined + ); + }, + async append(value, options) { + const { target: _target, ...sessionOptions } = (options ?? {}) as AppendStreamOptions; + return getChatSession().out.append(value, sessionOptions as SessionPipeStreamOptions); + }, + writer(options) { + return getChatSession().out.writer(options); + }, +}; + +// --------------------------------------------------------------------------- +// chat.response — write data parts that persist to the response message +// --------------------------------------------------------------------------- + +/** + * Write data parts that both stream to the frontend AND persist in + * `onTurnComplete`'s `responseMessage` and `uiMessages`. + * + * Non-transient data chunks (`type` starts with `data-`, no `transient: true`) + * are queued for accumulation into the assistant response message. + * Transient or non-data chunks are streamed only (same as `chat.stream`). + * + * @example + * ```ts + * // Persists to responseMessage.parts + * chat.response.write({ type: "data-handover", data: { context: summary } }); + * + * // Transient — streams only, not in responseMessage + * chat.response.write({ type: "data-progress", data: { percent: 50 }, transient: true }); + * ``` + */ +const chatResponse = { + /** + * Write a single chunk. Non-transient data parts are accumulated into the + * response message; everything else is stream-only. + */ + write(part: UIMessageChunk): void { + queueResponsePart(part); + const { waitUntilComplete } = chatStream.writer({ + spanName: "chat.response.write", + collapsed: true, + execute: ({ write }) => { + write(part); + }, + }); + waitUntilComplete().catch(() => {}); + }, +}; + +// --------------------------------------------------------------------------- +// ChatWriter — stream writer for callbacks +// --------------------------------------------------------------------------- + +/** + * A stream writer passed to chat lifecycle callbacks (`onPreload`, `onChatStart`, + * `onTurnStart`, `onTurnComplete`, `onCompacted`). + * + * Write custom `UIMessageChunk` parts (e.g. `data-*` parts) directly to the chat + * stream without the ceremony of `chat.stream.writer({ execute })`. + * + * The writer is lazy — no stream overhead if you don't call `write()` or `merge()`. + * + * @example + * ```ts + * onTurnStart: async ({ writer }) => { + * writer.write({ type: "data-status", data: { loading: true } }); + * }, + * onTurnComplete: async ({ writer, uiMessages }) => { + * writer.write({ type: "data-analytics", data: { messageCount: uiMessages.length } }); + * }, + * ``` + */ +export type ChatWriter = { + /** Write a single UIMessageChunk to the chat stream. */ + write(part: UIMessageChunk): void; + /** Merge another stream's chunks into the chat stream. */ + merge(stream: ReadableStream): void; +}; + +/** + * Creates a lazy ChatWriter that only opens a realtime stream on first use. + * Call `flush()` after the callback returns to await stream completion. + * @internal + */ +function createLazyChatWriter(): { writer: ChatWriter; flush: () => Promise } { + let writeImpl: ((part: UIMessageChunk) => void) | null = null; + let mergeImpl: ((stream: ReadableStream) => void) | null = null; + let waitPromise: (() => Promise) | null = null; + let resolveExecute: (() => void) | null = null; + + function ensureInitialized() { + if (writeImpl) return; + + const executePromise = new Promise((resolve) => { + resolveExecute = resolve; + }); + + const { waitUntilComplete } = chatStream.writer({ + collapsed: true, + spanName: "callback writer", + execute: ({ write, merge }) => { + writeImpl = write; + mergeImpl = merge; + return executePromise; // Keep execute alive until flush() + }, + }); + waitPromise = waitUntilComplete; + } + + return { + writer: { + write(part: UIMessageChunk) { + ensureInitialized(); + queueResponsePart(part); + writeImpl!(part); + }, + merge(stream: ReadableStream) { + ensureInitialized(); + mergeImpl!(stream); + }, + }, + async flush() { + if (resolveExecute) { + resolveExecute(); // Signal execute to complete + await waitPromise!(); // Wait for stream to finish piping + } + }, + }; +} + +/** + * Runs a callback with a lazy ChatWriter, flushing the stream after completion. + * @internal + */ +async function withChatWriter(fn: (writer: ChatWriter) => Promise | T): Promise { + const { writer, flush } = createLazyChatWriter(); + const result = await fn(writer); + await flush(); + return result; +} + +// `ChatTaskWirePayload` and `ChatInputChunk` live in `./ai-shared.ts` so +// browser bundles (which import them via `chat-client.ts` / `chat.ts`) +// can pull the types without dragging `ai.ts` into the client graph. +// Re-exported here so `@trigger.dev/sdk/ai` consumers see them. +import type { ChatTaskWirePayload, ChatInputChunk } from "./ai-shared.js"; +export type { ChatTaskWirePayload, ChatInputChunk } from "./ai-shared.js"; + +/** + * The payload shape passed to the `chatAgent` run function. + * + * - `messages` contains model-ready messages (converted via `convertToModelMessages`) — + * pass these directly to `streamText`. + * - `clientData` contains custom data from the frontend (the `metadata` field from `sendMessage()`). + * + * The backend accumulates the full conversation history across turns, so the frontend + * only needs to send new messages after the first turn. + */ +export type ChatTaskPayload = { + /** Model-ready messages — pass directly to `streamText({ messages })`. */ + messages: ModelMessage[]; + + /** The unique identifier for the chat session */ + chatId: string; + + /** + * The trigger type: + * - `"submit-message"`: A new user message + * - `"regenerate-message"`: Regenerate the last assistant response + * - `"preload"`: Run was preloaded before the first message (only on turn 0) + * - `"action"`: A typed action from the frontend (see `actionSchema` + `onAction`). + * The action has already been applied before `run()` fires — check `trigger === "action"` + * to short-circuit the LLM call when an action doesn't need a response. + * - `"close"`: The chat session is being closed (internal; `run()` is not called). + */ + trigger: "submit-message" | "regenerate-message" | "preload" | "action" | "close"; + + /** The ID of the message to regenerate (only for `"regenerate-message"`) */ + messageId?: string; + + /** Custom data from the frontend (passed via `metadata` on `sendMessage()` or the transport). */ + clientData?: TClientData; + + /** Whether this run is continuing an existing chat (previous run timed out or was cancelled). False for brand new chats. */ + continuation: boolean; + /** The run ID of the previous run (only set when `continuation` is true). */ + previousRunId?: string; + /** Whether this run was preloaded before the first message. */ + preloaded: boolean; + /** + * The friendlyId of the Session primitive backing this chat. Use with + * `sessions.open(sessionId)` when you need direct access to the session's + * `.in` / `.out` channels outside the hooks the agent already wires for + * you. Undefined only for legacy transports that predate the sessions + * migration. + */ + sessionId?: string; +}; + +/** + * Abort signals provided to the `chatAgent` run function. + */ +export type ChatTaskSignals = { + /** Combined signal — fires on run cancel OR stop generation. Pass to `streamText`. */ + signal: AbortSignal; + /** Fires only when the run is cancelled, expired, or exceeds maxDuration. */ + cancelSignal: AbortSignal; + /** Fires only when the frontend stops generation for this turn (per-turn, reset each turn). */ + stopSignal: AbortSignal; +}; + +/** + * The full payload passed to a `chatAgent` run function. + * Extends `ChatTaskPayload` (the wire payload) with abort signals. + */ +export type ChatTaskRunPayload = ChatTaskPayload & + ChatTaskSignals & { + /** + * Task run context — same object as the `ctx` passed to a standard `task({ run })` handler’s second argument. + * Use for tags, metadata, parent run links, or any API that needs the full run record. + */ + ctx: TaskRunContext; + /** Token usage from the previous turn. Undefined on turn 0. */ + previousTurnUsage?: LanguageModelUsage; + /** Cumulative token usage across all completed turns so far. */ + totalUsage: LanguageModelUsage; + }; + +// Input streams for bidirectional chat communication +// +// Both `messagesInput` and `stopInput` are thin facades over the current +// run's Session `.in` channel. The Session carries a single tagged stream +// (`ChatInputChunk`); these facades filter by `kind` so existing call +// sites (both internal and exposed via `chat.messages` / `chat.createStopSignal`) +// keep their original shape. Each accessor resolves the session handle +// lazily via `getChatSession()` so the module-level references stay +// compatible with the pre-migration wiring. +const messagesInput: RealtimeDefinedInputStream = { + id: "chat-messages", + on(handler) { + return getChatSession().in.on((chunk) => { + if (chunk.kind === "message") { + return handler(chunk.payload); + } + }); + }, + once(options) { + const ctx = taskContext.ctx; + const runId = ctx?.run.id; + + return new InputStreamOncePromise((resolve, reject) => { + tracer + .startActiveSpan( + options?.spanName ?? `chat.messages.once()`, + async () => { + while (true) { + const result = await getChatSession().in.once(options); + if (!result.ok) { + resolve(result as InputStreamOnceResult); + return; + } + if (result.output.kind === "message") { + resolve({ ok: true, output: result.output.payload }); + return; + } + // Non-message chunks (stops) are handled by the stopInput + // facade's persistent listener; loop and wait for the next. + } + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "streams", + [SemanticInternalAttributes.ENTITY_TYPE]: "input-stream", + ...(runId + ? { + [SemanticInternalAttributes.ENTITY_ID]: `${runId}:chat-messages`, + } + : {}), + streamId: "chat-messages", + ...accessoryAttributes({ + items: [{ text: "chat-messages", variant: "normal" }], + style: "codepath", + }), + }, + } + ) + .catch(reject); + }); + }, + peek() { + const chunk = getChatSession().in.peek(); + if (chunk && chunk.kind === "message") return chunk.payload; + return undefined; + }, + wait(options) { + return new ManualWaitpointPromise(async (resolve, reject) => { + try { + while (true) { + const result = await getChatSession().in.wait(options); + if (!result.ok) { + resolve(result); + return; + } + if (result.output.kind === "message") { + resolve({ ok: true, output: result.output.payload }); + return; + } + // Stop chunks are handled by the stopInput facade's persistent + // listener; loop back into the suspending wait. + } + } catch (error) { + reject(error); + } + }); + }, + async waitWithIdleTimeout(options) { + while (true) { + const result = await getChatSession().in.waitWithIdleTimeout(options); + if (!result.ok) return result; + if (result.output.kind === "message") { + return { ok: true, output: result.output.payload }; + } + // Swallow stop-kind chunks — persistent stop listener already handled + // the abort; we just loop for the next message. + } + }, + async send(_runId, data, options) { + // The `runId` argument is kept for signature parity with + // `RealtimeDefinedInputStream` but ignored — sessions are addressed + // by sessionId, not runId. Callers producing messages from outside + // the run should prefer the transport's `session.in.send(...)` path. + await getChatSession().in.send( + { kind: "message", payload: data } satisfies ChatInputChunk, + options?.requestOptions + ); + }, +}; + +const stopInput: RealtimeDefinedInputStream<{ stop: true; message?: string }> = { + id: "chat-stop", + on(handler) { + return getChatSession().in.on((chunk) => { + if (chunk.kind === "stop") { + return handler({ stop: true, message: chunk.message }); + } + }); + }, + once(options) { + const ctx = taskContext.ctx; + const runId = ctx?.run.id; + + return new InputStreamOncePromise<{ stop: true; message?: string }>((resolve, reject) => { + tracer + .startActiveSpan( + options?.spanName ?? `chat.stop.once()`, + async () => { + while (true) { + const result = await getChatSession().in.once(options); + if (!result.ok) { + resolve(result as InputStreamOnceResult<{ stop: true; message?: string }>); + return; + } + if (result.output.kind === "stop") { + resolve({ + ok: true, + output: { stop: true, message: result.output.message }, + }); + return; + } + } + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "streams", + [SemanticInternalAttributes.ENTITY_TYPE]: "input-stream", + ...(runId + ? { + [SemanticInternalAttributes.ENTITY_ID]: `${runId}:chat-stop`, + } + : {}), + streamId: "chat-stop", + ...accessoryAttributes({ + items: [{ text: "chat-stop", variant: "normal" }], + style: "codepath", + }), + }, + } + ) + .catch(reject); + }); + }, + peek() { + const chunk = getChatSession().in.peek(); + if (chunk && chunk.kind === "stop") { + return { stop: true, message: chunk.message }; + } + return undefined; + }, + wait(options) { + return new ManualWaitpointPromise<{ stop: true; message?: string }>(async (resolve, reject) => { + try { + while (true) { + const result = await getChatSession().in.wait(options); + if (!result.ok) { + resolve(result); + return; + } + if (result.output.kind === "stop") { + resolve({ + ok: true, + output: { stop: true, message: result.output.message }, + }); + return; + } + } + } catch (error) { + reject(error); + } + }); + }, + async waitWithIdleTimeout(options) { + while (true) { + const result = await getChatSession().in.waitWithIdleTimeout(options); + if (!result.ok) return result; + if (result.output.kind === "stop") { + return { ok: true, output: { stop: true, message: result.output.message } }; + } + } + }, + async send(_runId, data, options) { + await getChatSession().in.send( + { kind: "stop", message: data?.message } satisfies ChatInputChunk, + options?.requestOptions + ); + }, +}; + +/** + * Signal received by a `handover-prepare` agent run waiting on + * `session.in`. Either the customer's first-turn `streamText` finished + * with pending tool calls (`"handover"` — agent picks up from tool + * execution), or it finished pure-text (`"handover-skip"` — agent + * exits cleanly without making an LLM call). + * @internal + */ +type HandoverSignal = + | { + kind: "handover"; + partialAssistantMessage: ModelMessage[]; + messageId?: string; + /** + * Whether the customer's step 1 is the final response. When + * true, the agent's turn loop runs hooks but skips the LLM + * call (the partial IS the response). When false, the agent + * runs `streamText` which executes pending tool-calls via the + * approval round and continues from step 2. + */ + isFinal: boolean; + } + | { kind: "handover-skip" }; + +/** + * Internal facade for waiting on the handover signal. Mirrors + * `messagesInput` / `stopInput` so the wait paths and tracing + * attributes stay consistent across all input-stream branches. + * @internal + */ +const handoverInput = { + async waitWithIdleTimeout(options: { + idleTimeoutInSeconds: number; + timeout?: string; + spanName?: string; + skipSuspend?: boolean; + }) { + while (true) { + const result = await getChatSession().in.waitWithIdleTimeout(options); + if (!result.ok) return result; + if ( + result.output.kind === "handover" || + result.output.kind === "handover-skip" + ) { + return { ok: true as const, output: result.output as HandoverSignal }; + } + // Other kinds (message, stop) are not expected during handover-prepare. + // Loop back; the message and stop facades have their own listeners + // running so signals on those kinds aren't lost. + } + }, +}; + +/** + * Per-turn deferred promises. Registered via `chat.defer()`, awaited + * before `onTurnComplete` fires. Reset each turn. + * @internal + */ +const chatDeferKey = locals.create>>("chat.defer"); + +/** + * Run-scoped slot holding the partial assistant message handed over by + * `chat.handover` from a customer's first-turn `streamText`. Appended + * to `accumulatedMessages` during turn 0 setup so `streamText` resumes + * at tool execution. Cleared (read once) after consumption. + * @internal + */ +const chatHandoverPartialKey = locals.create("chat.handoverPartial"); + +/** + * Run-scoped slot holding the assistant `messageId` the customer's + * `chat.handover` handler used for its step-1 stream. The agent reuses + * it on the agent-side `toUIMessageStream` (and the synthesized + * partial UIMessage in `originalMessages`) so all chunks merge into a + * single assistant message on the browser side. + * @internal + */ +const chatHandoverMessageIdKey = locals.create("chat.handoverMessageId"); + +/** + * Run-scoped slot indicating that the customer's step-1 head-start + * response is the FINAL turn response. When true, turn 0 runs through + * the full turn-loop hooks but SKIPS the `userRun` / `streamText` + * call — the customer's partial already IS the response. The agent's + * `onTurnComplete` fires with that partial so persistence + any + * post-turn work happens normally. Cleared after consumption. + * @internal + */ +const chatHandoverIsFinalKey = locals.create("chat.handoverIsFinal"); + +/** + * Build a UIMessage representation of a `chat.handover` partial so AI + * SDK's `processUIMessageStream` can transition `tool-output-available` + * chunks (emitted by the initial-tool-execution branch when the + * approval round runs) onto the existing tool-call. Without this, + * `state.message.parts` is empty when the agent's `streamText` + * finishes, and AI SDK throws + * `UIMessageStreamError: No tool invocation found`. + * + * Only the assistant message matters — the synthesized + * `tool-approval-response` rows are AI-SDK-internal and don't need a + * UIMessage representation. We map: + * - `text` parts → `{ type: "text", text }` + * - `tool-call` parts → `{ type: "tool-${name}", toolCallId, + * state: "input-available", input }` + * - `tool-approval-request` parts → skipped (AI SDK derives the + * approval state from chunks during processing) + * + * @internal + */ +function synthesizeHandoverUIMessage( + partial: ModelMessage[], + messageId?: string +): UIMessage | undefined { + const assistant = partial.find((m) => m.role === "assistant"); + if (!assistant || typeof assistant.content === "string") return undefined; + + const parts: UIMessage["parts"] = []; + for (const part of assistant.content as Array<{ + type: string; + text?: string; + toolCallId?: string; + toolName?: string; + input?: unknown; + }>) { + if (part.type === "text" && typeof part.text === "string") { + parts.push({ type: "text", text: part.text } as UIMessage["parts"][number]); + } else if (part.type === "tool-call" && part.toolCallId && part.toolName) { + parts.push({ + type: `tool-${part.toolName}`, + toolCallId: part.toolCallId, + state: "input-available", + input: part.input, + } as unknown as UIMessage["parts"][number]); + } + // tool-approval-request parts intentionally skipped — they're an + // AI-SDK protocol detail, not a UI surface. + } + + if (parts.length === 0) return undefined; + + // Use the customer's step-1 messageId if provided (so the agent's + // post-handover chunks merge into the same assistant message on the + // browser). Fall back to a fresh id only if the handover signal + // didn't carry one. + return { + id: messageId ?? generateMessageId(), + role: "assistant", + parts, + } as UIMessage; +} + +/** + * Per-turn background context queue. Messages added via `chat.backgroundWork.inject()` + * are drained at the next `prepareStep` boundary and appended to the model messages. + * @internal + */ +const chatBackgroundQueueKey = locals.create("chat.backgroundQueue"); + +/** + * Run-scoped pipe counter. Stored in locals so concurrent runs in the + * same worker don't share state. + * @internal + */ +const chatPipeCountKey = locals.create("chat.pipeCount"); +const chatStopControllerKey = locals.create("chat.stopController"); +/** Static (task-level) UIMessageStream options, set once during chatAgent setup. @internal */ +const chatUIStreamStaticKey = locals.create>( + "chat.uiMessageStreamOptions.static" +); +/** Per-turn UIMessageStream options, set via chat.setUIMessageStreamOptions(). @internal */ +const chatUIStreamPerTurnKey = locals.create>( + "chat.uiMessageStreamOptions.perTurn" +); + +/** + * Run-scoped `toolCallId → assistant messageId` map. Records the head + * assistant id whenever the accumulator absorbs an assistant message + * containing tool parts. Used as a fallback in the id-merge for + * incoming tool-answer messages — if the AI SDK regenerates the + * assistant id on a HITL `addToolOutput` resume, we look up the + * original head id by `toolCallId` and rewrite it before the merge. + * + * Customer-side workaround for the same case is documented in Arena + * AI's chat-agent task; lifting it into the SDK so customers don't + * have to. See TRI-9137. + * @internal + */ +const chatToolCallToMessageIdKey = locals.create>( + "chat.toolCallToMessageId" +); + +function recordToolCallIdsFromMessage(message: { id?: string; role?: string; parts?: unknown[] } | undefined) { + if (!message || message.role !== "assistant" || !message.id) return; + let map = locals.get(chatToolCallToMessageIdKey); + if (!map) { + map = new Map(); + locals.set(chatToolCallToMessageIdKey, map); + } + for (const part of message.parts ?? []) { + if (typeof part !== "object" || part == null) continue; + const toolCallId = (part as { toolCallId?: unknown }).toolCallId; + if (typeof toolCallId === "string" && toolCallId.length > 0) { + map.set(toolCallId, message.id); + } + } +} + +function rewriteIncomingIdViaToolCallMap( + incoming: T +): T { + const map = locals.get(chatToolCallToMessageIdKey); + if (!map || map.size === 0) return incoming; + for (const part of incoming.parts ?? []) { + if (typeof part !== "object" || part == null) continue; + const toolCallId = (part as { toolCallId?: unknown }).toolCallId; + if (typeof toolCallId !== "string" || toolCallId.length === 0) continue; + const headId = map.get(toolCallId); + if (headId && headId !== incoming.id) { + return { ...incoming, id: headId }; + } + } + return incoming; +} + +// --------------------------------------------------------------------------- +// Token usage helpers (internal) +// --------------------------------------------------------------------------- + +/** Convenience re-export of the AI SDK's `LanguageModelUsage` type. */ +export type ChatTurnUsage = LanguageModelUsage; + +function emptyUsage(): LanguageModelUsage { + return { + inputTokens: undefined, + outputTokens: undefined, + totalTokens: undefined, + inputTokenDetails: { + noCacheTokens: undefined, + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + }, + outputTokenDetails: { textTokens: undefined, reasoningTokens: undefined }, + }; +} + +function addUsage(a: LanguageModelUsage, b: LanguageModelUsage): LanguageModelUsage { + const add = (x: number | undefined, y: number | undefined) => + x != null || y != null ? (x ?? 0) + (y ?? 0) : undefined; + return { + inputTokens: add(a.inputTokens, b.inputTokens), + outputTokens: add(a.outputTokens, b.outputTokens), + totalTokens: add(a.totalTokens, b.totalTokens), + inputTokenDetails: { + noCacheTokens: add(a.inputTokenDetails?.noCacheTokens, b.inputTokenDetails?.noCacheTokens), + cacheReadTokens: add( + a.inputTokenDetails?.cacheReadTokens, + b.inputTokenDetails?.cacheReadTokens + ), + cacheWriteTokens: add( + a.inputTokenDetails?.cacheWriteTokens, + b.inputTokenDetails?.cacheWriteTokens + ), + }, + outputTokenDetails: { + textTokens: add(a.outputTokenDetails?.textTokens, b.outputTokenDetails?.textTokens), + reasoningTokens: add( + a.outputTokenDetails?.reasoningTokens, + b.outputTokenDetails?.reasoningTokens + ), + }, + }; +} + +// --------------------------------------------------------------------------- +// chat.setMessages — replace accumulated messages for compaction +// --------------------------------------------------------------------------- + +/** @internal */ +const chatOverrideMessagesKey = locals.create("chat.overrideMessages"); + +/** + * Tracks the current accumulated UI messages so chat.history.all() can + * read them from outside the chatAgent closure. + * @internal + */ +const chatCurrentUIMessagesKey = locals.create("chat.currentUIMessages"); + +/** + * Replace the accumulated conversation messages for the current run. + * + * Call from `onTurnStart` to compact before `run()` executes, or from + * `onTurnComplete` to compact before the next turn. Takes `UIMessage[]` + * and converts to `ModelMessage[]` internally. + */ +function setChatMessages(uiMessages: TUIM[]): void { + locals.set(chatOverrideMessagesKey, uiMessages); +} + +// --------------------------------------------------------------------------- +// chat.history — imperative message history mutations +// --------------------------------------------------------------------------- + +/** + * Read the current message history state, accounting for pending overrides. + * @internal + */ +function getChatHistoryState(): UIMessage[] { + const pending = locals.get(chatOverrideMessagesKey); + if (pending) return pending; + return locals.get(chatCurrentUIMessagesKey) ?? []; +} + +/** + * A tool call surfaced by `chat.history.getPendingToolCalls()` / + * `getResolvedToolCalls()`. Identifies the call by its `toolCallId` plus + * the `messageId` of the assistant message that hosts it, so callers can + * locate the part precisely without re-walking the chain. + */ +export type ChatToolCallRef = { + toolCallId: string; + toolName: string; + messageId: string; +}; + +/** + * A new tool result surfaced by `chat.history.extractNewToolResults()`. + * `errorText` is set iff the part is in `output-error` state; otherwise + * `output` carries the resolved value. + */ +export type ChatNewToolResult = { + toolCallId: string; + toolName: string; + output: unknown; + errorText?: string; }; + +/** + * Tool parts that are "done" — either succeeded with a value or failed + * with an error. Excludes pending (`input-streaming`/`input-available`) + * and approval (`approval-requested`/`approval-responded`) states. + * @internal + */ +function isResolvedToolState(state: unknown): state is "output-available" | "output-error" { + return state === "output-available" || state === "output-error"; +} + +/** @internal */ +function isPendingToolState(state: unknown): state is "input-available" { + return state === "input-available"; +} + +/** + * Walk an assistant message and yield each tool part with its callId, + * name, and state. Skips non-assistant messages and non-tool parts. + * @internal + */ +function* iterateToolParts( + message: UIMessage +): Generator<{ part: any; toolCallId: string; toolName: string; state: unknown }> { + if (message.role !== "assistant") return; + for (const part of (message.parts ?? []) as any[]) { + if (!isToolUIPart(part)) continue; + const toolCallId = part.toolCallId; + if (typeof toolCallId !== "string" || toolCallId.length === 0) continue; + yield { + part, + toolCallId, + toolName: getToolName(part), + state: part.state, + }; + } +} + +/** + * Tool parts on the *leaf* assistant message that are still waiting on + * an answer (`input-available` state). Used to gate fresh user turns + * during HITL flows. + * @internal + */ +function getPendingToolCallsFromHistory(messages: UIMessage[]): ChatToolCallRef[] { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]!; + if (msg.role !== "assistant") continue; + const pending: ChatToolCallRef[] = []; + for (const { toolCallId, toolName, state } of iterateToolParts(msg)) { + if (isPendingToolState(state)) { + pending.push({ toolCallId, toolName, messageId: msg.id }); + } + } + return pending; + } + return []; +} + +/** + * All tool parts across the chain that have already produced an output + * (`output-available` or `output-error`). Used to dedup re-saves when + * the AI SDK resends an assistant with progressively more answered + * parts. + * @internal + */ +function getResolvedToolCallsFromHistory(messages: UIMessage[]): ChatToolCallRef[] { + const out: ChatToolCallRef[] = []; + for (const msg of messages) { + for (const { toolCallId, toolName, state } of iterateToolParts(msg)) { + if (isResolvedToolState(state)) { + out.push({ toolCallId, toolName, messageId: msg.id }); + } + } + } + return out; +} + +/** + * Pure helper: tool parts in `message` that have a fresh result not + * already represented by the resolved toolCallIds in `messages`. The + * `errorText` field is present only for `output-error` parts. + * + * Within a single `message`, duplicate `toolCallId`s emit only once + * (first occurrence wins). This guards against malformed assistants + * with repeated tool parts. + * @internal + */ +function extractNewToolResultsFromHistory( + message: UIMessage, + messages: UIMessage[] +): ChatNewToolResult[] { + const resolved = new Set( + getResolvedToolCallsFromHistory(messages).map((r) => r.toolCallId) + ); + const seen = new Set(); + const out: ChatNewToolResult[] = []; + for (const { part, toolCallId, toolName, state } of iterateToolParts(message)) { + if (!isResolvedToolState(state)) continue; + if (resolved.has(toolCallId)) continue; + if (seen.has(toolCallId)) continue; + seen.add(toolCallId); + if (state === "output-error") { + out.push({ toolCallId, toolName, output: undefined, errorText: part.errorText }); + } else { + out.push({ toolCallId, toolName, output: part.output }); + } + } + return out; +} + +/** + * Imperative API for reading and modifying the accumulated message history. + * + * Mutations use the same deferred override mechanism as `chat.setMessages()`: + * they are applied at lifecycle checkpoints (after hooks return). Reads are + * synchronous against the current accumulator state. + * + * Can be called from `onTurnStart`, `onBeforeTurnComplete`, `onTurnComplete`, + * `run()`, `onAction`, or AI SDK tools. + */ +const chatHistory = { + /** Read the current accumulated UI messages (copy). */ + all(): UIMessage[] { + return [...getChatHistoryState()]; + }, + + /** + * Read the current chain as an ordered `UIMessage[]`. Identical to + * `all()`; use whichever name reads better in context. + */ + getChain(): UIMessage[] { + return chatHistory.all(); + }, + + /** + * Find a message by id. Returns `undefined` if no message with that id + * is present in the current chain. + */ + findMessage(messageId: string): UIMessage | undefined { + return getChatHistoryState().find((m) => m.id === messageId); + }, + + /** + * Tool calls on the *most recent* assistant message that are still in + * `input-available` state (waiting on an `addToolOutput` answer). The + * scan walks back from the tail and stops at the first assistant + * message it finds, so a trailing user message does not change the + * result — pending tool calls remain pending until they're resolved + * on that assistant or the assistant is removed. + * + * Use this to gate fresh user turns or actions during HITL flows: if + * `getPendingToolCalls().length > 0`, an `addToolOutput` is expected. + * + * Returns `[]` if there is no assistant message yet, or if the most + * recent assistant has no pending tool calls. + * + * Approval flows (`approval-requested` / `approval-responded` states) + * are not surfaced here. Those are about the user authorizing a tool + * to run; "pending" is about the user *answering* a tool call. + */ + getPendingToolCalls(): ChatToolCallRef[] { + return getPendingToolCallsFromHistory(getChatHistoryState()); + }, + + /** + * Tool calls across the chain with a final result (`output-available` + * or `output-error`). Use this to dedup re-saves when the AI SDK + * resends an assistant message with progressively more answered parts. + */ + getResolvedToolCalls(): ChatToolCallRef[] { + return getResolvedToolCallsFromHistory(getChatHistoryState()); + }, + + /** + * Pure helper: returns the tool parts in `message` whose results are + * not already represented in the current chain. Use this when + * persisting tool results to your own store: each call surfaces only + * the *new* answers, so writes stay idempotent across re-streams. + * Duplicate `toolCallId`s within `message` itself are also collapsed + * to a single entry. + */ + extractNewToolResults(message: UIMessage): ChatNewToolResult[] { + return extractNewToolResultsFromHistory(message, getChatHistoryState()); + }, + + /** Replace all accumulated messages. Same as `chat.setMessages()`. */ + set(messages: UIMessage[]): void { + locals.set(chatOverrideMessagesKey, messages); + }, + + /** Remove a specific message by ID. */ + remove(messageId: string): void { + chatHistory.set(getChatHistoryState().filter((m) => m.id !== messageId)); + }, + + /** Keep messages up to and including the given ID (undo/rollback). */ + rollbackTo(messageId: string): void { + const current = getChatHistoryState(); + const idx = current.findIndex((m) => m.id === messageId); + if (idx !== -1) { + chatHistory.set(current.slice(0, idx + 1)); + } + }, + + /** Replace a specific message by ID (edit). */ + replace(messageId: string, message: UIMessage): void { + chatHistory.set(getChatHistoryState().map((m) => (m.id === messageId ? message : m))); + }, + + /** Keep only messages in the given range. */ + slice(start: number, end?: number): void { + chatHistory.set(getChatHistoryState().slice(start, end)); + }, +}; + +/** + * Model-only message override. Set by compaction to replace only the model + * messages (what goes to the LLM) without affecting UI messages (what gets + * persisted and displayed). This preserves full conversation history for the + * user while keeping LLM context compact. + * @internal + */ +const chatOverrideModelMessagesKey = locals.create("chat.overrideModelMessages"); + +// --------------------------------------------------------------------------- +// chat.compaction — prepareStep compaction API +// --------------------------------------------------------------------------- + +/** State stored in locals during prepareStep compaction. */ +interface CompactionState { + summary: string; + baseResponseMessageCount: number; +} + +/** @internal */ +const chatCompactionStateKey = locals.create("chat.compaction"); +const chatOnCompactedKey = + locals.create<(event: CompactedEvent) => Promise | void>("chat.onCompacted"); +/** @internal Full task `ctx` for the active `chat.agent` run (for hooks invoked from nested compaction). */ +const chatAgentRunContextKey = locals.create("chat.agentRunContext"); +const chatPrepareMessagesKey = + locals.create<(event: PrepareMessagesEvent) => ModelMessage[] | Promise>( + "chat.prepareMessages" + ); + +/** @internal Flag set by `chat.requestUpgrade()` to exit the loop after the current turn. */ +const chatUpgradeRequestedKey = locals.create("chat.upgradeRequested"); + +/** + * @internal Flag set by `chat.endRun()` to exit the loop after the current + * turn completes, without any upgrade semantics. Checked at the same + * post-turn / pre-wait sites as `chatUpgradeRequestedKey`. + */ +const chatEndRunRequestedKey = locals.create("chat.endRunRequested"); + +/** + * Event passed to `summarize` callbacks. + */ +export type SummarizeEvent = { + /** The current model messages to summarize. */ + messages: ModelMessage[]; + /** Full usage object from the triggering step/turn. */ + usage?: LanguageModelUsage; + /** Cumulative token usage across all completed turns. Present in chat.agent contexts. */ + totalUsage?: LanguageModelUsage; + /** The chat session ID (if running inside a chat.agent). */ + chatId?: string; + /** The current turn number (0-indexed, if inside a chat.agent). */ + turn?: number; + /** Custom data from the frontend (if inside a chat.agent). */ + clientData?: unknown; + /** + * Where compaction is running: + * - `"inner"` — between tool-call steps (prepareStep) + * - `"outer"` — between turns + */ + source?: "inner" | "outer"; + /** The step number (0-indexed). Only present when `source` is `"inner"`. */ + stepNumber?: number; +}; + +/** + * Event passed to `compactUIMessages` and `compactModelMessages` callbacks. + */ +export type CompactMessagesEvent = { + /** The generated summary text. */ + summary: string; + /** The current UI messages (full conversation). */ + uiMessages: TUIM[]; + /** The current model messages (full conversation). */ + modelMessages: ModelMessage[]; + /** The chat session ID. */ + chatId: string; + /** The current turn number (0-indexed). */ + turn: number; + /** Custom data from the frontend. */ + clientData?: unknown; + /** + * Where compaction is running: + * - `"inner"` — between tool-call steps (prepareStep) + * - `"outer"` — between turns + */ + source: "inner" | "outer"; +}; + +/** + * Options for the `compaction` field on `chat.agent()`. + * + * Handles compaction automatically in both the inner loop (prepareStep, between + * tool-call steps) and the outer loop (between turns, for single-step responses + * where prepareStep never fires). + */ +export type ChatAgentCompactionOptions = { + /** Decide whether to compact. Return true to trigger compaction. */ + shouldCompact: (event: ShouldCompactEvent) => boolean | Promise; + /** Generate a summary from the current messages. Return the summary text. */ + summarize: (event: SummarizeEvent) => Promise; + /** + * Transform UI messages after compaction (what gets persisted and displayed). + * Default: preserve all UI messages unchanged. + * + * @example + * ```ts + * // Flatten to summary + * compactUIMessages: ({ summary }) => [{ + * id: generateId(), role: "assistant", + * parts: [{ type: "text", text: `[Summary]\n\n${summary}` }], + * }], + * + * // Summary + keep last 4 messages + * compactUIMessages: ({ uiMessages, summary }) => [ + * { id: generateId(), role: "assistant", + * parts: [{ type: "text", text: `[Summary]\n\n${summary}` }] }, + * ...uiMessages.slice(-4), + * ], + * ``` + */ + compactUIMessages?: (event: CompactMessagesEvent) => TUIM[] | Promise; + /** + * Transform model messages after compaction (what gets sent to the LLM). + * Default: replace all with a single summary message. + * + * @example + * ```ts + * // Summary + keep last 2 model messages + * compactModelMessages: ({ modelMessages, summary }) => [ + * { role: "user", content: summary }, + * ...modelMessages.slice(-2), + * ], + * ``` + */ + compactModelMessages?: ( + event: CompactMessagesEvent + ) => ModelMessage[] | Promise; +}; + +/** @internal */ +const chatAgentCompactionKey = + locals.create>("chat.agentCompaction"); + +// --------------------------------------------------------------------------- +// Pending messages — mid-execution message injection via prepareStep +// --------------------------------------------------------------------------- + +/** + * Event passed to `shouldInject` and `prepareMessages` callbacks. + */ +export type PendingMessagesBatchEvent = { + /** All pending UI messages that arrived during streaming (batch). */ + messages: TUIM[]; + /** Current model messages in the conversation. */ + modelMessages: ModelMessage[]; + /** Completed steps so far. */ + steps: CompactionStep[]; + /** Current step number (0-indexed). */ + stepNumber: number; + /** Chat session ID. */ + chatId: string; + /** Current turn number (0-indexed). */ + turn: number; + /** Custom data from the frontend. */ + clientData?: unknown; +}; + +/** + * Event passed to `onReceived` callback (per-message, as they arrive). + */ +export type PendingMessageReceivedEvent = { + /** The UI message that arrived during streaming. */ + message: TUIM; + /** Chat session ID. */ + chatId: string; + /** Current turn number (0-indexed). */ + turn: number; +}; + +/** + * Event passed to `onInjected` callback (batch, after injection). + */ +export type PendingMessagesInjectedEvent = { + /** All UI messages that were injected. */ + messages: TUIM[]; + /** The model messages that were injected. */ + injectedModelMessages: ModelMessage[]; + /** Chat session ID. */ + chatId: string; + /** Current turn number (0-indexed). */ + turn: number; + /** Step number where injection occurred. */ + stepNumber: number; +}; + +/** + * Options for the `pendingMessages` field on `chat.agent()`, `chat.createSession()`, + * or `ChatMessageAccumulator`. + * + * Configures how messages that arrive during streaming are handled. When + * `shouldInject` is provided and returns `true`, the full batch of pending + * messages is injected between tool-call steps via `prepareStep`. + * Otherwise, messages queue for the next turn. + */ +export type PendingMessagesOptions = { + /** + * Decide whether to inject pending messages between tool-call steps. + * Called once per step boundary with the full batch of pending messages. + * If absent, no injection happens — messages only queue for the next turn. + */ + shouldInject?: (event: PendingMessagesBatchEvent) => boolean | Promise; + /** + * Transform the batch of pending messages before injection. + * Return the model messages to inject. + * Default: convert each UI message via `convertToModelMessages`. + */ + prepare?: (event: PendingMessagesBatchEvent) => ModelMessage[] | Promise; + /** Called when a message arrives during streaming (per-message). */ + onReceived?: (event: PendingMessageReceivedEvent) => void | Promise; + /** Called after a batch of messages is injected via `prepareStep`. */ + onInjected?: (event: PendingMessagesInjectedEvent) => void | Promise; +}; + +/** + * The data part type used to signal that pending messages were injected + * between tool-call steps. The frontend can match on this to render + * injection points inline in the assistant response. + */ +// `PENDING_MESSAGE_INJECTED_TYPE` lives in `./ai-shared.ts` so the chat +// React hooks (`@trigger.dev/sdk/chat/react`) can import it without +// dragging `ai.ts` into the browser graph. Re-exported here so +// `@trigger.dev/sdk/ai` consumers still see it. +export { PENDING_MESSAGE_INJECTED_TYPE } from "./ai-shared.js"; +import { PENDING_MESSAGE_INJECTED_TYPE } from "./ai-shared.js"; + +/** @internal */ +type SteeringQueueEntry = { uiMessage: UIMessage; modelMessages: ModelMessage[] }; +/** @internal */ +const chatPendingMessagesKey = locals.create("chat.pendingMessages"); +/** @internal */ +const chatSteeringQueueKey = locals.create("chat.steeringQueue"); +/** @internal — IDs of messages that were successfully injected via prepareStep */ +const chatInjectedMessageIdsKey = locals.create>("chat.injectedMessageIds"); +/** @internal — non-transient data parts queued via chat.response or writer.write() for accumulation into the response message */ +const chatResponsePartsKey = locals.create("chat.responseParts"); + +/** + * Check if a chunk is a non-transient data part that should persist to the response message. + * @internal + */ +function isNonTransientDataPart(part: unknown): boolean { + if (typeof part !== "object" || part === null) return false; + const p = part as Record; + return typeof p.type === "string" && p.type.startsWith("data-") && p.transient !== true; +} + +/** + * Queue a chunk for accumulation into the response message (if it's a non-transient data part). + * Called by `chat.response.write()` and `ChatWriter.write()`. + * @internal + */ +function queueResponsePart(part: unknown): void { + if (!isNonTransientDataPart(part)) return; + const parts = locals.get(chatResponsePartsKey) ?? []; + parts.push(part); + locals.set(chatResponsePartsKey, parts); +} + +/** + * Event passed to the `prepareMessages` hook. + */ +export type PrepareMessagesEvent = { + /** The messages to transform. Return the transformed array. */ + messages: ModelMessage[]; + /** Why messages are being prepared. */ + reason: + | "run" // Messages being passed to run() for streamText + | "compaction-rebuild" // Rebuilding from a previous compaction summary + | "compaction-result"; // Fresh compaction just produced these messages + /** The chat session ID. */ + chatId: string; + /** The current turn number (0-indexed). */ + turn: number; + /** Custom data from the frontend. */ + clientData?: TClientData; +}; + +/** + * Data shape for `data-compaction` stream chunks emitted during compaction. + * Use to type the `data` field when rendering compaction parts in the frontend. + */ +export type CompactionChunkData = { + status: "compacting" | "complete"; + totalTokens: number | undefined; +}; + +/** + * Event passed to the `onCompacted` callback. + */ +export type CompactedEvent = { + /** Task run context — same as `task` lifecycle hooks and `chat.agent` `run({ ctx })`. */ + ctx: TaskRunContext; + /** The generated summary text. */ + summary: string; + /** The messages that were compacted (pre-compaction). */ + messages: ModelMessage[]; + /** Number of messages before compaction. */ + messageCount: number; + /** Token usage from the step that triggered compaction. */ + usage: LanguageModelUsage; + /** Total token count that triggered compaction. */ + totalTokens: number | undefined; + /** Input token count from the triggering step. */ + inputTokens: number | undefined; + /** Output token count from the triggering step. */ + outputTokens: number | undefined; + /** The step number where compaction occurred (0-indexed). */ + stepNumber: number; + /** The chat session ID (if running inside a chat.agent). */ + chatId?: string; + /** The current turn number (if running inside a chat.agent). */ + turn?: number; + /** Stream writer — write custom `UIMessageChunk` parts to the chat stream. Lazy: no overhead if unused. */ + writer: ChatWriter; +}; + +/** + * Event passed to `shouldCompact` callbacks. + */ +export type ShouldCompactEvent = { + /** The current model messages (full conversation). */ + messages: ModelMessage[]; + /** Total token count from the triggering step/turn. */ + totalTokens: number | undefined; + /** Input token count from the triggering step/turn. */ + inputTokens: number | undefined; + /** Output token count from the triggering step/turn. */ + outputTokens: number | undefined; + /** Full usage object from the triggering step/turn. */ + usage?: LanguageModelUsage; + /** Cumulative token usage across all completed turns. Present in chat.agent contexts. */ + totalUsage?: LanguageModelUsage; + /** The chat session ID (if running inside a chat.agent). */ + chatId?: string; + /** The current turn number (0-indexed, if inside a chat.agent). */ + turn?: number; + /** Custom data from the frontend (if inside a chat.agent). */ + clientData?: unknown; + /** + * Where this check is running: + * - `"inner"` — between tool-call steps (prepareStep) + * - `"outer"` — between turns (after response, before onBeforeTurnComplete) + */ + source?: "inner" | "outer"; + /** The step number (0-indexed). Only present when `source` is `"inner"`. */ + stepNumber?: number; + /** The steps array from prepareStep. Only present when `source` is `"inner"`. */ + steps?: CompactionStep[]; +}; + +/** + * Options for `chat.compaction()` — the high-level prepareStep factory. + */ +export type CompactionOptions = { + /** Generate a summary from the current messages. Return the summary text. */ + summarize: (messages: ModelMessage[]) => Promise; + /** Token threshold — compact when totalTokens exceeds this. Ignored if `shouldCompact` is provided. */ + threshold?: number; + /** Custom compaction trigger. When provided, used instead of `threshold`. */ + shouldCompact?: (event: ShouldCompactEvent) => boolean | Promise; +}; + +/** A step object as received in prepareStep's `steps` array. */ +export type CompactionStep = { + usage: LanguageModelUsage; + finishReason: string; + content: Array<{ type: string; toolCallId?: string }>; + response: { messages: Array }; +}; + +/** + * Result of `chat.compact()`. Discriminated union so you can inspect + * what happened, but also directly compatible with prepareStep's return type. + * + * - `"skipped"` — no compaction needed (first step, boundary unsafe, or under threshold). Return `undefined` to prepareStep. + * - `"rebuilt"` — previous compaction exists, messages rebuilt from summary + new response messages. + * - `"compacted"` — compaction just happened, includes the generated summary. + */ +export type CompactResult = + | { type: "skipped" } + | { type: "rebuilt"; messages: ModelMessage[] } + | { type: "compacted"; messages: ModelMessage[]; summary: string }; + +/** + * Options for `chat.compact()` — the low-level compaction function. + */ +export type CompactOptions = { + /** Generate a summary from the current messages. Return the summary text. */ + summarize: (messages: ModelMessage[]) => Promise; + /** Token threshold — compact when totalTokens exceeds this. Ignored if `shouldCompact` is provided. */ + threshold?: number; + /** Custom compaction trigger. When provided, used instead of `threshold`. */ + shouldCompact?: (event: ShouldCompactEvent) => boolean | Promise; +}; + +/** + * Check that no tool calls are in-flight in a step's content. + * Used before compaction to avoid losing tool state mid-execution. + * @internal + */ +function isStepBoundarySafe(step: { + finishReason: string; + content: Array<{ type: string; toolCallId?: string }>; +}): boolean { + if (step.finishReason === "error") return false; + const callIds = new Set( + step.content.filter((p) => p.type === "tool-call").map((p) => p.toolCallId) + ); + const settledIds = new Set( + step.content + .filter((p) => p.type === "tool-result" || p.type === "tool-error") + .map((p) => p.toolCallId) + ); + return ![...callIds].some((id) => !settledIds.has(id)); +} + +/** + * Apply the prepareMessages hook if one is set in locals. + * @internal + */ +async function applyPrepareMessages( + messages: ModelMessage[], + reason: PrepareMessagesEvent["reason"] +): Promise { + const hook = locals.get(chatPrepareMessagesKey); + if (!hook) return messages; + + const turnCtx = locals.get(chatTurnContextKey); + + return tracer.startActiveSpan( + "prepareMessages()", + async () => { + return hook({ + messages, + reason, + chatId: turnCtx?.chatId ?? "", + turn: turnCtx?.turn ?? 0, + clientData: turnCtx?.clientData, + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.prepareMessages.reason": reason, + "chat.prepareMessages.messageCount": messages.length, + }, + } + ); +} + +/** + * Read the current compaction state. Returns the summary and base message count + * if compaction has occurred in this turn, or `undefined` if not. + * + * Use in a custom `prepareStep` to rebuild from a previous compaction: + * ```ts + * const state = chat.getCompactionState(); + * if (state) { + * return { messages: [{ role: "user", content: state.summary }, ...newMsgs] }; + * } + * ``` + */ +function getCompactionState(): CompactionState | undefined { + return locals.get(chatCompactionStateKey); +} + +/** + * Low-level compaction for use inside a custom `prepareStep`. + * + * Handles the full decision tree: first step, already-compacted rebuild, + * boundary safety, threshold check, summarization, stream chunks, state + * storage, and accumulator update. + * + * Returns a `CompactResult` — inspect `result.type` to see what happened, + * or convert to a prepareStep return with `result.type === "skipped" ? undefined : result`. + * + * @example + * ```ts + * prepareStep: async ({ messages, steps }) => { + * // your custom logic here... + * const result = await chat.compact(messages, steps, { + * threshold: 80_000, + * summarize: async (msgs) => generateText({ model, messages: msgs }).then(r => r.text), + * }); + * if (result.type === "compacted") { + * logger.info("Compacted!", { summary: result.summary }); + * } + * return result.type === "skipped" ? undefined : result; + * }, + * ``` + */ +async function chatCompact( + messages: ModelMessage[], + steps: CompactionStep[], + options: CompactOptions +): Promise { + const currentStep = steps.at(-1); + + // First step — nothing to check + if (!currentStep) { + return { type: "skipped" }; + } + + // Already compacted — rebuild from summary + new response messages + const state = locals.get(chatCompactionStateKey); + if (state && isStepBoundarySafe(currentStep)) { + return { + type: "rebuilt", + messages: await applyPrepareMessages( + [ + { role: "user" as const, content: state.summary }, + ...currentStep.response.messages.slice(state.baseResponseMessageCount), + ], + "compaction-rebuild" + ), + }; + } + + // Boundary unsafe — skip + if (!isStepBoundarySafe(currentStep)) { + return { type: "skipped" }; + } + + const totalTokens = currentStep.usage.totalTokens; + const inputTokens = currentStep.usage.inputTokens; + const outputTokens = currentStep.usage.outputTokens; + + const turnCtx = locals.get(chatTurnContextKey); + const stepNumber = steps.length - 1; + + const shouldTrigger = options.shouldCompact + ? await options.shouldCompact({ + messages, + totalTokens, + inputTokens, + outputTokens, + usage: currentStep.usage, + source: "inner", + stepNumber, + steps, + chatId: turnCtx?.chatId, + turn: turnCtx?.turn, + clientData: turnCtx?.clientData, + }) + : totalTokens != null && options.threshold != null && totalTokens > options.threshold; + + if (!shouldTrigger) { + return { type: "skipped" }; + } + + const result = await tracer.startActiveSpan( + "context compaction", + async (span) => { + const compactionId = generateMessageId(); + let summary!: string; + + const { waitUntilComplete } = chatStream.writer({ + spanName: "stream compaction chunks", + collapsed: true, + execute: async ({ write, merge }) => { + // Control chunks aren't part of UIMessageChunk's discriminated + // union but flow on the same session.out so subscribers can + // intercept them — cast on the way out. + write({ type: "step-start" } as unknown as UIMessageChunk); + write({ + type: "data-compaction", + id: compactionId, + data: { status: "compacting", totalTokens }, + transient: true, + }); + + // Generate summary + summary = await options.summarize(messages); + + // Store state in locals for subsequent steps + locals.set(chatCompactionStateKey, { + summary, + baseResponseMessageCount: currentStep.response.messages.length, + }); + + // Set model-only override — UI messages stay intact for persistence. + // The summary becomes the model message history for the next turn, + // while accumulatedUIMessages keeps the full conversation for display. + locals.set(chatOverrideModelMessagesKey, [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: `[Conversation summary]\n\n${summary}` }], + }, + ]); + + // Fire onCompacted hook — pass the existing writer so the callback + // can write custom chunks without creating a separate stream. + const onCompactedHook = locals.get(chatOnCompactedKey); + if (onCompactedHook) { + await onCompactedHook({ + ctx: locals.get(chatAgentRunContextKey)!, + summary, + messages, + messageCount: messages.length, + usage: currentStep.usage, + totalTokens, + inputTokens, + outputTokens, + stepNumber, + chatId: turnCtx?.chatId, + turn: turnCtx?.turn, + writer: { write, merge }, + }); + } + + write({ + type: "data-compaction", + id: compactionId, + data: { status: "complete", totalTokens }, + transient: true, + }); + write({ type: "finish-step" }); + }, + }); + await waitUntilComplete(); + + // Set attributes after we have the summary + span.setAttribute("compaction.summary_length", summary.length); + + return { + type: "compacted" as const, + messages: await applyPrepareMessages( + [{ role: "user" as const, content: summary }], + "compaction-result" + ), + summary, + }; + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "tabler-scissors", + "compaction.threshold": options.threshold, + "compaction.total_tokens": totalTokens ?? 0, + "compaction.input_tokens": inputTokens ?? 0, + "compaction.message_count": messages.length, + "compaction.step_number": stepNumber, + ...(turnCtx?.chatId ? { "compaction.chat_id": turnCtx.chatId } : {}), + ...(turnCtx?.turn != null ? { "compaction.turn": turnCtx.turn } : {}), + ...accessoryAttributes({ + items: [ + { text: `${totalTokens ?? 0} tokens`, variant: "normal" }, + { text: `${messages.length} msgs`, variant: "normal" }, + ], + style: "codepath", + }), + }, + } + ); + + return result; +} + +/** + * Returns a `prepareStep` function that handles context compaction automatically. + * + * Monitors token usage between tool-call steps. When `totalTokens` exceeds + * the threshold, generates a summary via `summarize()`, replaces the message + * history, and emits `data-compaction` stream chunks for the frontend. + * + * @example + * ```ts + * return streamText({ + * ...chat.toStreamTextOptions({ registry }), + * messages: chat.addCacheBreaks(messages), + * prepareStep: chat.compactionStep({ + * threshold: 80_000, + * summarize: async (messages) => { + * return generateText({ model, messages: [...messages, { role: "user", content: "Summarize." }] }) + * .then((r) => r.text); + * }, + * }), + * tools: { ... }, + * }); + * ``` + */ +function chatCompactionStep( + options: CompactionOptions +): (args: { + messages: ModelMessage[]; + steps: CompactionStep[]; +}) => Promise<{ messages: ModelMessage[] } | undefined> { + return async ({ messages, steps }) => { + const result = await chatCompact(messages, steps, options); + return result.type === "skipped" ? undefined : result; + }; +} + +// --------------------------------------------------------------------------- +// Steering queue drain — shared by toStreamTextOptions, session, accumulator +// --------------------------------------------------------------------------- + +/** + * Drain the steering queue as a batch. Calls `shouldInject` once with all + * pending messages. If it returns true, calls `prepareMessages` once to + * transform the batch, then clears the queue. + * Returns the model messages to inject (empty if none). + * @internal + */ +async function drainSteeringQueue( + config: PendingMessagesOptions, + messages: ModelMessage[], + steps: CompactionStep[], + queueOverride?: SteeringQueueEntry[] +): Promise { + const queue = queueOverride ?? locals.get(chatSteeringQueueKey); + if (!queue || queue.length === 0) return []; + + const ctx = locals.get(chatTurnContextKey); + const stepNumber = steps.length - 1; + const uiMessages = queue.map((e) => e.uiMessage); + + const batchEvent: PendingMessagesBatchEvent = { + messages: uiMessages, + modelMessages: messages, + steps, + stepNumber, + chatId: ctx?.chatId ?? "", + turn: ctx?.turn ?? 0, + clientData: ctx?.clientData, + }; + + // Call shouldInject once for the whole batch + const shouldInject = config.shouldInject ? await config.shouldInject(batchEvent) : false; + + if (!shouldInject) return []; + + // Extract message texts for span attributes + const messageTexts = uiMessages.map( + (m) => + (m.parts ?? []) + .filter((p: any) => p.type === "text") + .map((p: any) => p.text) + .join("") || "" + ); + const previewText = + messageTexts.length === 1 ? messageTexts[0]!.slice(0, 80) : `${queue.length} messages`; + + return tracer.startActiveSpan( + "pending message injected", + async () => { + // Transform the batch — default: concatenate all pre-converted model messages + const injected = config.prepare + ? await config.prepare(batchEvent) + : queue.flatMap((e) => e.modelMessages); + + // Clear the queue and record injected IDs + queue.length = 0; + const injectedIds = locals.get(chatInjectedMessageIdsKey); + if (injectedIds) { + for (const m of uiMessages) injectedIds.add(m.id); + } + + // Write injection confirmation chunk to the stream so the frontend + // knows which messages were injected and where in the response. + if (injected.length > 0) { + try { + const { waitUntilComplete } = chatStream.writer({ + collapsed: true, + execute: ({ write }) => { + write({ + type: PENDING_MESSAGE_INJECTED_TYPE, + id: generateMessageId(), + data: { + messageIds: uiMessages.map((m) => m.id), + messages: uiMessages.map((m, idx) => ({ + id: m.id, + text: messageTexts[idx] ?? "", + })), + }, + }); + }, + }); + await waitUntilComplete(); + } catch { + /* non-fatal — stream write failed */ + } + } + + // Fire onInjected callback + if (config.onInjected && injected.length > 0) { + try { + await config.onInjected({ + messages: uiMessages, + injectedModelMessages: injected, + chatId: ctx?.chatId ?? "", + turn: ctx?.turn ?? 0, + stepNumber, + }); + } catch { + /* non-fatal */ + } + } + + return injected; + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "tabler-message-forward", + "pending.message_count": uiMessages.length, + "pending.step_number": stepNumber, + "pending.messages": messageTexts, + ...(ctx?.chatId ? { "pending.chat_id": ctx.chatId } : {}), + ...(ctx?.turn != null ? { "pending.turn": ctx.turn } : {}), + ...accessoryAttributes({ + items: [ + { + text: `${uiMessages.length} message${uiMessages.length === 1 ? "" : "s"}`, + variant: "normal", + }, + { text: `between steps ${stepNumber} and ${stepNumber + 1}`, variant: "normal" }, + ], + style: "codepath", + }), + }, + } + ); +} + +// --------------------------------------------------------------------------- +// chat.isCompactionSafe — check if it's safe to compact messages +// --------------------------------------------------------------------------- + +/** + * Checks whether it's safe to compact the message history. Returns `false` + * if any tool calls are in-flight (incomplete tool invocations without results). + * + * Call before `chat.setMessages()` to avoid corrupting tool-call state. + */ +function isCompactionSafe(messages: UIMessage[]): boolean { + for (const msg of messages) { + if (msg.role !== "assistant") continue; + for (const part of msg.parts as any[]) { + if (part.type === "tool-invocation") { + const state = part.toolInvocation?.state ?? part.state; + if (state !== "result" && state !== "error") { + return false; + } + } + } + } + return true; +} + +// --------------------------------------------------------------------------- +// chat.prompt — store and retrieve a resolved prompt for the current run +// --------------------------------------------------------------------------- + +/** + * A resolved prompt stored via `chat.prompt.set()`. Either a full `ResolvedPrompt` + * from `prompts.define().resolve()`, or a lightweight wrapper around a plain string. + */ +export type ChatPromptValue = + | ResolvedPrompt + | { + text: string; + model: undefined; + config: undefined; + promptId: string; + version: number; + labels: string[]; + toAISDKTelemetry: (additionalMetadata?: Record) => { + experimental_telemetry: { isEnabled: true; metadata: Record }; + }; + }; + +/** @internal */ +const chatPromptKey = locals.create("chat.prompt"); + +/** + * Store a resolved prompt (or plain string) for the current run. + * Call from any hook (`onPreload`, `onChatStart`, `onTurnStart`) or `run()`. + */ +function setChatPrompt(resolved: ResolvedPrompt | string): void { + if (typeof resolved === "string") { + locals.set(chatPromptKey, { + text: resolved, + model: undefined, + config: undefined, + promptId: "", + version: 0, + labels: [], + toAISDKTelemetry: () => ({ + experimental_telemetry: { isEnabled: true, metadata: {} }, + }), + }); + } else { + locals.set(chatPromptKey, resolved); + } +} + +/** + * Read the stored prompt. Throws if `chat.prompt.set()` has not been called. + */ +function getChatPrompt(): ChatPromptValue { + const prompt = locals.get(chatPromptKey); + if (!prompt) { + throw new Error( + "chat.prompt() called before chat.prompt.set(). Set a prompt in onPreload, onChatStart, onTurnStart, or run() first." + ); + } + return prompt; +} + +// --------------------------------------------------------------------------- +// chat.skills — store resolved agent skills and inject them into streamText +// --------------------------------------------------------------------------- + +/** @internal */ +const chatSkillsKey = locals.create("chat.skills"); + +/** + * Store resolved skills for the current run. Call from any hook + * (`onPreload`, `onChatStart`, `onTurnStart`) or `run()`. + */ +function setChatSkills(skills: ResolvedSkill[]): void { + locals.set(chatSkillsKey, skills); +} + +/** Read the stored skills. Returns `undefined` if none set. */ +function getChatSkills(): ResolvedSkill[] | undefined { + return locals.get(chatSkillsKey); +} + +/** + * Build the system-prompt preamble advertising available skills. Only the + * frontmatter description surfaces here — full SKILL.md body is loaded + * on-demand via the `loadSkill` tool. + */ +function buildSkillsSystemPrompt(skills: ResolvedSkill[]): string { + if (skills.length === 0) return ""; + const lines = skills.map( + (s) => `- ${s.frontmatter.name}: ${s.frontmatter.description}` + ); + return [ + "Available skills (call `loadSkill` to read the full instructions before using one):", + ...lines, + ].join("\n"); +} + +/** Resolve a skill by its frontmatter `name`. */ +function findSkillByName(skills: ResolvedSkill[], name: string): ResolvedSkill | undefined { + return skills.find((s) => s.frontmatter.name === name); +} + +/** + * Build the three tools we auto-inject into `streamText` when skills are + * set: `loadSkill`, `readFile`, `bash`. Scoped per-skill by name. + * + * Exported so callers can use the same tools outside the auto-wired path + * (e.g. in a `chat.createSession` loop with custom streamText). + */ +export function buildSkillTools(skills: ResolvedSkill[]): Record { + const loadSkill = aiTool({ + description: + "Load the full instructions for a skill by its name. Call this first before using a skill.", + inputSchema: jsonSchema<{ name: string }>({ + type: "object", + properties: { + name: { + type: "string", + description: "The `name` field from the skill's frontmatter.", + }, + }, + required: ["name"], + additionalProperties: false, + } as JSONSchema7), + execute: async ({ name }: { name: string }) => { + const skill = findSkillByName(skills, name); + if (!skill) { + return { + error: `Skill "${name}" not found. Available: ${skills + .map((s) => s.frontmatter.name) + .join(", ")}`, + }; + } + return { + name: skill.frontmatter.name, + description: skill.frontmatter.description, + body: skill.body, + path: skill.path, + }; + }, + }); + + const readFile = aiTool({ + description: + "Read a file from a skill's bundled folder. Paths must be relative to the skill's root.", + inputSchema: jsonSchema<{ skill: string; path: string }>({ + type: "object", + properties: { + skill: { type: "string", description: "The skill's name (from frontmatter)." }, + path: { + type: "string", + description: "Relative path inside the skill folder (e.g. `references/citation-style.md`).", + }, + }, + required: ["skill", "path"], + additionalProperties: false, + } as JSONSchema7), + execute: async ({ skill: skillName, path: relPath }: { skill: string; path: string }) => { + const skill = findSkillByName(skills, skillName); + if (!skill) { + return { error: `Skill "${skillName}" not found.` }; + } + try { + return await readFileInSkill({ + skillPath: skill.path, + relativePath: relPath, + }); + } catch (err) { + return { error: (err as Error).message }; + } + }, + }); + + const bash = aiTool({ + description: + "Run a bash command inside a skill's bundled folder. Use this to invoke the skill's scripts. The working directory is the skill's root.", + inputSchema: jsonSchema<{ skill: string; command: string }>({ + type: "object", + properties: { + skill: { type: "string", description: "The skill's name (from frontmatter)." }, + command: { + type: "string", + description: "Bash command to run. Relative script paths resolve against the skill's root.", + }, + }, + required: ["skill", "command"], + additionalProperties: false, + } as JSONSchema7), + execute: async ( + { skill: skillName, command }: { skill: string; command: string }, + { abortSignal }: { abortSignal?: AbortSignal } = {} + ) => { + const skill = findSkillByName(skills, skillName); + if (!skill) { + return { error: `Skill "${skillName}" not found.` }; + } + try { + return await runBashInSkill({ + skillPath: skill.path, + command, + abortSignal, + }); + } catch (err) { + return { error: (err as Error).message }; + } + }, + }); + + return { loadSkill, readFile, bash }; +} + +/** + * Options for {@link toStreamTextOptions}. + */ +export type ToStreamTextOptionsOptions = { + /** Additional telemetry metadata merged into `experimental_telemetry.metadata`. */ + telemetry?: Record; + /** + * An AI SDK provider registry (from `createProviderRegistry`) or any object + * with a `languageModel(id)` method. When provided and the stored prompt has + * a `model` string, the resolved `LanguageModel` is included in the returned + * options so `streamText` uses it directly. + * + * The model string should use the `"provider:model-id"` format + * (e.g. `"openai:gpt-4o"`, `"anthropic:claude-sonnet-4-6"`). + */ + registry?: { languageModel(modelId: string): unknown }; + /** + * User-defined tools to merge alongside the auto-injected skill tools + * (`loadSkill`, `readFile`, `bash`). User tools win on name conflicts. + * + * If you don't pass `tools` here and skills are set, the returned options + * will include just the skill tools — spread after any `tools` you pass + * directly to `streamText` and they'll be replaced. Easiest: pass all + * your tools here. + */ + tools?: Record; +}; + +/** + * Returns an options object ready to spread into `streamText()`. + * + * Includes `system`, `experimental_telemetry`, and any config fields + * (temperature, maxTokens, etc.) from the stored prompt. + * + * When a `registry` is provided and the prompt has a `model` string, + * the resolved `LanguageModel` is included as `model`. + * + * If no prompt has been set, returns `{}` (no-op spread). + */ +function toStreamTextOptions(options?: ToStreamTextOptionsOptions): Record { + const prompt = locals.get(chatPromptKey); + const skills = locals.get(chatSkillsKey); + const result: Record = {}; + + // Build the combined system prompt: stored prompt + skills preamble. + const promptText = prompt?.text ?? ""; + const skillsText = skills && skills.length > 0 ? buildSkillsSystemPrompt(skills) : ""; + if (promptText || skillsText) { + result.system = [promptText, skillsText].filter(Boolean).join("\n\n"); + } + + // Prompt-related options (only if chat.prompt.set() was called) + if (prompt) { + // Resolve model via registry if both are present + if (options?.registry && prompt.model) { + result.model = options.registry.languageModel(prompt.model); + } + + // Spread config (temperature, maxTokens, etc.) + if (prompt.config) { + Object.assign(result, prompt.config); + } + + // Add telemetry (forward additional metadata from caller) + const telemetry = prompt.toAISDKTelemetry(options?.telemetry); + Object.assign(result, telemetry); + } + + // Skills: merge auto-injected tools with any user-provided tools. + // User tools override on name conflict (though we namespace ours). + if (skills && skills.length > 0) { + const skillTools = buildSkillTools(skills); + result.tools = { ...skillTools, ...(options?.tools ?? {}) }; + } else if (options?.tools) { + result.tools = options.tools; + } + + // Auto-inject prepareStep for compaction, pending messages, and background context injection. + // This runs regardless of whether a prompt is set — these features are independent. + const taskCompaction = locals.get(chatAgentCompactionKey); + const taskPendingMessages = locals.get(chatPendingMessagesKey); + + { + result.prepareStep = async ({ + messages, + steps, + }: { + messages: ModelMessage[]; + steps: CompactionStep[]; + }) => { + let resultMessages: ModelMessage[] | undefined; + + // 1. Compaction + if (taskCompaction) { + const compactResult = await chatCompact(messages, steps, { + shouldCompact: taskCompaction.shouldCompact, + summarize: (msgs) => { + const ctx = locals.get(chatTurnContextKey); + const lastStep = steps.at(-1); + return taskCompaction.summarize({ + messages: msgs, + usage: lastStep?.usage, + source: "inner", + stepNumber: steps.length - 1, + chatId: ctx?.chatId, + turn: ctx?.turn, + clientData: ctx?.clientData, + }); + }, + }); + if (compactResult.type !== "skipped") { + resultMessages = compactResult.messages; + } + } + + // 2. Pending message injection (steering) + if (taskPendingMessages) { + const injected = await drainSteeringQueue( + taskPendingMessages, + resultMessages ?? messages, + steps + ); + if (injected.length > 0) { + resultMessages = [...(resultMessages ?? messages), ...injected]; + } + } + + // 3. Background context injection + const bgQueue = locals.get(chatBackgroundQueueKey); + if (bgQueue && bgQueue.length > 0) { + const injected = bgQueue.splice(0); // drain + resultMessages = [...(resultMessages ?? messages), ...injected]; + } + + return resultMessages ? { messages: resultMessages } : undefined; + }; + } + + return result; +} + +/** + * Options for `pipeChat`. + */ +export type PipeChatOptions = { + /** + * Override the stream key. Must match the `streamKey` on `TriggerChatTransport`. + * @default "chat" + */ + streamKey?: string; + + /** An AbortSignal to cancel the stream. */ + signal?: AbortSignal; + + /** + * The target run ID to pipe to. + * @default "self" (current run) + */ + target?: string; + + /** Override the default span name for this operation. */ + spanName?: string; +}; + +/** + * Options for customizing the `toUIMessageStream()` call used when piping + * `streamText` results to the frontend. + * + * Set static defaults via `uiMessageStreamOptions` on `chat.agent()`, or + * override per-turn via `chat.setUIMessageStreamOptions()`. + * + * `onFinish` is omitted because it is managed internally for response capture. + * Use `streamText`'s `onFinish` for custom finish handling, or drop down to + * raw task mode with `chat.pipe()` for full control. + * + * `originalMessages` is omitted because it is automatically set from the + * accumulated conversation history, ensuring message IDs are reused across + * turns (e.g. for tool approval continuations). + * + * `generateMessageId` can be set to control ID generation for response + * messages (e.g. UUID-v7). If not set, the AI SDK's default `generateId` is used. + */ +export type ChatUIMessageStreamOptions = Omit< + UIMessageStreamOptions, + "onFinish" | "originalMessages" +>; + +/** + * An object with a `toUIMessageStream()` method (e.g. `StreamTextResult` from `streamText()`). + */ +type UIMessageStreamable = { + toUIMessageStream: (...args: any[]) => AsyncIterable | ReadableStream; +}; + +function isUIMessageStreamable(value: unknown): value is UIMessageStreamable { + return ( + typeof value === "object" && + value !== null && + "toUIMessageStream" in value && + typeof (value as any).toUIMessageStream === "function" + ); +} + +let warnedMissingOnAction = false; +function warnMissingOnActionOnce() { + if (warnedMissingOnAction) return; + warnedMissingOnAction = true; + console.warn( + "[chat.agent] Received an action but no `onAction` handler is configured. " + + "The action is being ignored. Define `onAction` (and optionally `actionSchema`) on " + + "your agent to handle it." + ); +} + +function isAsyncIterable(value: unknown): value is AsyncIterable { + return typeof value === "object" && value !== null && Symbol.asyncIterator in value; +} + +function isReadableStream(value: unknown): value is ReadableStream { + return ( + typeof value === "object" && value !== null && typeof (value as any).getReader === "function" + ); +} + +/** + * Pipes a chat stream to the realtime stream, making it available to the + * `TriggerChatTransport` on the frontend. + * + * Accepts: + * - A `StreamTextResult` from `streamText()` (has `.toUIMessageStream()`) + * - An `AsyncIterable` of `UIMessageChunk`s + * - A `ReadableStream` of `UIMessageChunk`s + * + * Must be called from inside a Trigger.dev task's `run` function. + * + * @example + * ```ts + * import { task } from "@trigger.dev/sdk"; + * import { chat, type ChatTaskPayload } from "@trigger.dev/sdk/ai"; + * import { streamText, convertToModelMessages } from "ai"; + * + * export const myChatTask = task({ + * id: "my-chat-task", + * run: async (payload: ChatTaskPayload) => { + * const result = streamText({ + * model: openai("gpt-4o"), + * messages: payload.messages, + * }); + * + * await chat.pipe(result); + * }, + * }); + * ``` + * + * @example + * ```ts + * // Works from anywhere inside a task — even deep in your agent code + * async function runAgentLoop(messages: CoreMessage[]) { + * const result = streamText({ model, messages }); + * await chat.pipe(result); + * } + * ``` + */ +async function pipeChat( + source: UIMessageStreamable | AsyncIterable | ReadableStream, + options?: PipeChatOptions +): Promise { + locals.set(chatPipeCountKey, (locals.get(chatPipeCountKey) ?? 0) + 1); + + let stream: AsyncIterable | ReadableStream; + + if (isUIMessageStreamable(source)) { + stream = source.toUIMessageStream(); + } else if (isAsyncIterable(source) || isReadableStream(source)) { + stream = source; + } else { + throw new Error( + "pipeChat: source must be a StreamTextResult (with .toUIMessageStream()), " + + "an AsyncIterable, or a ReadableStream" + ); + } + + const pipeOptions: SessionPipeStreamOptions = {}; + if (options?.signal) { + pipeOptions.signal = options.signal; + } + if (options?.spanName) { + pipeOptions.spanName = options.spanName; + } + // `options.target` / `options.streamKey` are accepted for API parity + // with the pre-migration run-scoped pipe but no longer have meaning — + // sessions are the address (single stream per session, no sub-run + // targeting). Sub-agents that need to write into a parent's chat now + // open that session explicitly via `sessions.open(parentSessionId).out.pipe`. + + // The generic is typed for `UIMessageChunk`, but `pipeChat` also + // accepts opaque UIMessageStreamable / raw iterables whose element + // type we don't know at compile time. Cast — runtime behaviour is + // identical (bytes go to session.out either way). + const { waitUntilComplete } = chatStream.pipe( + stream as ReadableStream | AsyncIterable, + pipeOptions + ); + await waitUntilComplete(); +} + +/** + * Options for defining a chat task. + * + * Extends the standard `TaskOptions` but pre-types the payload as `ChatTaskPayload` + * and overrides `run` to accept `ChatTaskRunPayload` (with abort signals). + * + * **Auto-piping:** If the `run` function returns a value with `.toUIMessageStream()` + * (like a `StreamTextResult`), the stream is automatically piped to the frontend. + * + * **Single-run mode:** By default, the task uses input streams so that the + * entire conversation lives inside one run. After each AI response, the task + * emits a control chunk and suspends via `messagesInput.wait()`. The frontend + * transport resumes the same run by sending the next message via input streams. + */ +/** + * Event passed to the `onPreload` callback. + */ +export type PreloadEvent = { + /** Task run context — same as `task({ run })` second-argument `ctx`. */ + ctx: TaskRunContext; + /** The unique identifier for the chat session. */ + chatId: string; + /** The Trigger.dev run ID for this conversation. */ + runId: string; + /** A scoped access token for this chat run. */ + chatAccessToken: string; + /** Custom data from the frontend. */ + clientData?: TClientData; + /** Stream writer — write custom `UIMessageChunk` parts to the chat stream. Lazy: no overhead if unused. */ + writer: ChatWriter; +}; + +/** + * Event passed to the `onBoot` callback. + * + * Fires once at the start of every run boot — for the initial first-message + * run, for preloaded runs, AND for reactive continuation runs (post-cancel, + * post-crash, post-`endRun`, `chat.requestUpgrade`, OOM-retry attempts). + * Does NOT fire when the SAME run resumes from snapshot via the + * idle-window suspend/resume path — use `onChatResume` for that. + * + * Use this for per-process setup that needs to run every time a fresh + * worker picks up the chat: initialize `chat.local` state, open + * per-process resources (DB connections, sandboxes, etc.), or + * re-hydrate customer state from your DB on continuation. + * + * Ordering: + * - First message of a chat: `onBoot` → `onChatStart` → `onTurnStart` → `run()` + * - Preloaded run: `onBoot` → `onPreload` → (wait) → `onChatStart` → ... + * - Continuation run: `onBoot` → (wait for first message) → `onTurnStart` → `run()` + * (`onChatStart` does NOT fire on continuation runs) + */ +export type BootEvent = { + /** Task run context — same as `task({ run })` second-argument `ctx`. */ + ctx: TaskRunContext; + /** The unique identifier for the chat session. */ + chatId: string; + /** The Trigger.dev run ID for this run boot. */ + runId: string; + /** A scoped access token for this chat run. Persist this for frontend reconnection. */ + chatAccessToken: string; + /** Custom data from the frontend (passed via `metadata` on `sendMessage()` or the transport). */ + clientData: TClientData; + /** + * True when this run is a reactive continuation — the prior run died + * (cancel / crash / `endRun` / `requestUpgrade` / OOM retry) and this + * fresh worker is taking over the chat. Branch on this to re-load + * customer-owned state from your DB. + */ + continuation: boolean; + /** Public id of the prior run when `continuation` is true. */ + previousRunId?: string; + /** Whether this run was triggered as a preload. */ + preloaded: boolean; +}; + +/** + * Event passed to the `onChatStart` callback. + * + * Fires exactly once per chat, on the very first user message of the chat's + * lifetime. Does NOT fire on continuation runs (post-`endRun`, + * post-waitpoint-timeout, `chat.requestUpgrade`) or on OOM-retry attempts — + * those are runs of an already-started chat. + */ +export type ChatStartEvent = { + /** Task run context — same as `task({ run })` second-argument `ctx`. */ + ctx: TaskRunContext; + /** The unique identifier for the chat session. */ + chatId: string; + /** + * The initial model-ready messages for this conversation. Typically just + * the first user message (or empty if `chat.headStart` is in play and the + * seed message is supplied elsewhere). Since this hook only fires for the + * chat's very first message, there's no prior history to load here. + */ + messages: ModelMessage[]; + /** Custom data from the frontend (passed via `metadata` on `sendMessage()` or the transport). */ + clientData: TClientData; + /** The Trigger.dev run ID for this conversation. */ + runId: string; + /** A scoped access token for this chat run. Persist this for frontend reconnection. */ + chatAccessToken: string; + /** + * @deprecated Always `false` — `onChatStart` no longer fires on continuation + * runs. Kept for backward compatibility; remove your `continuation` checks + * from `onChatStart` and rely on the contract (this hook fires exactly once + * per chat, on the very first message). + */ + continuation: boolean; + /** + * @deprecated Always `undefined` — `onChatStart` no longer fires on + * continuation runs. + */ + previousRunId?: string; + /** Whether this run was preloaded before the first message. */ + preloaded: boolean; + /** Stream writer — write custom `UIMessageChunk` parts to the chat stream. Lazy: no overhead if unused. */ + writer: ChatWriter; +}; + +/** + * Event passed to the `hydrateMessages` callback. + */ +export type HydrateMessagesEvent = { + /** The unique identifier for the chat session. */ + chatId: string; + /** The turn number (0-indexed). */ + turn: number; + /** The trigger type for this turn. */ + trigger: "submit-message" | "regenerate-message" | "action"; + /** Validated incoming UI messages from the wire payload (what the frontend sent). Empty for actions. */ + incomingMessages: TUIM[]; + /** The accumulated UI messages before this turn (empty on turn 0). */ + previousMessages: TUIM[]; + /** Parsed client data from the transport metadata. */ + clientData?: TClientData; + /** Whether this run is continuing from a previous run. */ + continuation: boolean; + /** The ID of the previous run (if continuation). */ + previousRunId?: string; +}; + +/** + * Event passed to the `onValidateMessages` callback. + */ +export type ValidateMessagesEvent = { + /** The incoming UI messages for this turn (after cleanup of aborted tool parts). */ + messages: TUIM[]; + /** The unique identifier for the chat session. */ + chatId: string; + /** The turn number (0-indexed). */ + turn: number; + /** The trigger type for this turn. */ + trigger: "submit-message" | "regenerate-message" | "preload" | "close"; +}; + +/** + * Event passed to the `onAction` callback. + */ +export type ActionEvent< + TAction = unknown, + TClientData = unknown, + TUIM extends UIMessage = UIMessage, +> = { + /** The parsed and validated action payload. */ + action: TAction; + /** The unique identifier for the chat session. */ + chatId: string; + /** The turn number (0-indexed). */ + turn: number; + /** Parsed client data from the transport metadata. */ + clientData?: TClientData; + /** The accumulated UI messages (after hydration, if set). */ + uiMessages: TUIM[]; + /** The accumulated model messages (after hydration, if set). */ + messages: ModelMessage[]; +}; + +/** + * Event passed to the `onTurnStart` callback. + */ +export type TurnStartEvent = { + /** Task run context — same as `task({ run })` second-argument `ctx`. */ + ctx: TaskRunContext; + /** The unique identifier for the chat session. */ + chatId: string; + /** The accumulated model-ready messages (all turns so far, including new user message). */ + messages: ModelMessage[]; + /** The accumulated UI messages (all turns so far, including new user message). */ + uiMessages: TUIM[]; + /** The turn number (0-indexed). */ + turn: number; + /** The Trigger.dev run ID for this conversation. */ + runId: string; + /** A scoped access token for this chat run. */ + chatAccessToken: string; + /** Custom data from the frontend. */ + clientData?: TClientData; + /** Whether this run is continuing an existing chat (previous run timed out or was cancelled). False for brand new chats. */ + continuation: boolean; + /** The run ID of the previous run (only set when `continuation` is true). */ + previousRunId?: string; + /** Whether this run was preloaded before the first message. */ + preloaded: boolean; + /** Token usage from the previous turn. Undefined on turn 0. */ + previousTurnUsage?: LanguageModelUsage; + /** Cumulative token usage across all completed turns so far. */ + totalUsage: LanguageModelUsage; + /** Stream writer — write custom `UIMessageChunk` parts to the chat stream. Lazy: no overhead if unused. */ + writer: ChatWriter; +}; + +/** + * Event passed to the `onTurnComplete` callback. + */ +export type TurnCompleteEvent = { + /** Task run context — same as `task({ run })` second-argument `ctx`. */ + ctx: TaskRunContext; + /** The unique identifier for the chat session. */ + chatId: string; + /** The full accumulated conversation in model format (all turns so far). */ + messages: ModelMessage[]; + /** + * The full accumulated conversation in UI format (all turns so far). + * This is the format expected by `useChat` — store this for persistence. + */ + uiMessages: TUIM[]; + /** + * Only the new model messages from this turn (user message(s) + assistant response). + * Useful for appending to an existing conversation record. + */ + newMessages: ModelMessage[]; + /** + * Only the new UI messages from this turn (user message(s) + assistant response). + * Useful for inserting individual message records instead of overwriting the full history. + */ + newUIMessages: TUIM[]; + /** The assistant's response for this turn, with aborted parts cleaned up when `stopped` is true. Undefined if `pipeChat` was used manually. */ + responseMessage: TUIM | undefined; + /** + * The raw assistant response before abort cleanup. Includes incomplete tool parts + * (`input-available`, `partial-call`) and streaming reasoning/text parts. + * Use this if you need custom cleanup logic. Same as `responseMessage` when not stopped. + */ + rawResponseMessage: TUIM | undefined; + /** The turn number (0-indexed). */ + turn: number; + /** The Trigger.dev run ID for this conversation. */ + runId: string; + /** A fresh scoped access token for this chat run (renewed each turn). Persist this for frontend reconnection. */ + chatAccessToken: string; + /** The last event ID from the stream writer. Use this with `resume: true` to avoid replaying events after refresh. */ + lastEventId?: string; + /** Custom data from the frontend. */ + clientData?: TClientData; + /** Whether the user stopped generation during this turn. */ + stopped: boolean; + /** Whether this run is continuing an existing chat (previous run timed out or was cancelled). False for brand new chats. */ + continuation: boolean; + /** The run ID of the previous run (only set when `continuation` is true). */ + previousRunId?: string; + /** Whether this run was preloaded before the first message. */ + preloaded: boolean; + /** Token usage for this turn. Undefined if usage couldn't be captured (e.g. manual pipeChat). */ + usage?: LanguageModelUsage; + /** Cumulative token usage across all turns in this run (including this turn). */ + totalUsage: LanguageModelUsage; + /** + * Why the LLM stopped generating this turn: + * - `"stop"` — model generated a stop sequence (normal completion) + * - `"tool-calls"` — model stopped on one or more tool calls. If any tool + * has no `execute` function (e.g. an `ask_user` HITL tool), the turn is + * paused awaiting user input; inspect `responseMessage.parts` for tool + * parts in `input-available` state to distinguish. + * - `"length"` — max tokens reached + * - `"content-filter"` — content filter stopped the model + * - `"error"` — model errored + * - `"other"` — provider-specific reason + * + * Undefined if the underlying stream didn't provide a finish reason (e.g. + * manual `pipeChat()` or an aborted stream). + */ + finishReason?: FinishReason; +}; + +/** + * Event passed to the `onBeforeTurnComplete` callback. + * Same as `TurnCompleteEvent` but includes a `writer` since the stream is still open. + */ +export type BeforeTurnCompleteEvent< + TClientData = unknown, + TUIM extends UIMessage = UIMessage, +> = TurnCompleteEvent & { + /** Stream writer — write custom `UIMessageChunk` parts to the chat stream. Lazy: no overhead if unused. */ + writer: ChatWriter; +}; + +/** + * Discriminated event passed to the `onChatSuspend` callback. + * Use `phase` to distinguish preload vs turn suspension. + */ +export type ChatSuspendEvent = + | { + /** Suspend is happening after onPreload, before the first message. */ + phase: "preload"; + /** Task run context. */ + ctx: TaskRunContext; + /** The chat session ID. */ + chatId: string; + /** The Trigger.dev run ID. */ + runId: string; + /** Custom data from the frontend. */ + clientData?: TClientData; + } + | { + /** + * Suspend is happening on a continuation run that booted with no incoming + * message (post-`endRun`, post-waitpoint-timeout, etc.) and is waiting + * for the next session.in record before running any turn. Distinct from + * `phase: "preload"` — the chat already started; `onPreload` has not + * fired and will not fire on this run. + */ + phase: "continuation"; + /** Task run context. */ + ctx: TaskRunContext; + /** The chat session ID. */ + chatId: string; + /** The Trigger.dev run ID. */ + runId: string; + /** Custom data from the frontend. */ + clientData?: TClientData; + } + | { + /** Suspend is happening after a completed turn, waiting for the next message. */ + phase: "turn"; + /** Task run context. */ + ctx: TaskRunContext; + /** The chat session ID. */ + chatId: string; + /** The Trigger.dev run ID. */ + runId: string; + /** The turn number (0-indexed) that just completed. */ + turn: number; + /** The accumulated model messages after the completed turn. */ + messages: ModelMessage[]; + /** The accumulated UI messages after the completed turn. */ + uiMessages: TUIM[]; + /** Custom data from the frontend. */ + clientData?: TClientData; + }; + +/** + * Discriminated event passed to the `onChatResume` callback. + * Use `phase` to distinguish preload vs turn resumption. + */ +export type ChatResumeEvent = + | { + /** First message arrived after preload suspension. */ + phase: "preload"; + /** Task run context. */ + ctx: TaskRunContext; + /** The chat session ID. */ + chatId: string; + /** The Trigger.dev run ID. */ + runId: string; + /** Custom data from the frontend. */ + clientData?: TClientData; + } + | { + /** + * First message arrived after continuation-wait suspension. Distinct + * from `phase: "preload"` — the chat already started; this is a new + * run picking up after a prior run ended (`endRun`, waitpoint timeout, + * etc.). + */ + phase: "continuation"; + /** Task run context. */ + ctx: TaskRunContext; + /** The chat session ID. */ + chatId: string; + /** The Trigger.dev run ID. */ + runId: string; + /** Custom data from the frontend. */ + clientData?: TClientData; + } + | { + /** Next message arrived after turn suspension. */ + phase: "turn"; + /** Task run context. */ + ctx: TaskRunContext; + /** The chat session ID. */ + chatId: string; + /** The Trigger.dev run ID. */ + runId: string; + /** The turn number that was completed before suspension. */ + turn: number; + /** The accumulated model messages (from before suspension). */ + messages: ModelMessage[]; + /** The accumulated UI messages (from before suspension). */ + uiMessages: TUIM[]; + /** Custom data from the frontend. */ + clientData?: TClientData; + }; + +export type ChatAgentOptions< + TIdentifier extends string, + TClientDataSchema extends TaskSchema | undefined = undefined, + TUIMessage extends UIMessage = UIMessage, + TActionSchema extends TaskSchema | undefined = undefined, +> = Omit< + TaskOptions< + TIdentifier, + ChatTaskWirePayload>, + unknown + >, + "run" | "retry" +> & { + /** + * Fallback machine preset to use when an attempt fails with an + * out-of-memory (OOM) error. Setting this enables a single OOM retry: + * the next attempt boots on the larger machine, and the chat picks + * up via the standard continuation path (same `chatId` / Session, + * accumulator rebuilds via `hydrateMessages` or post-`onTurnStart` + * persisted state). + * + * Set `machine` (top-level `TaskOptions`) to control the *default* + * machine the agent runs on. `oomMachine` is the *retry-only* swap. + * + * Note: an OOM retry restarts the entire turn from the top — the + * model call and any in-flight tool executes re-run on the larger + * machine. Make tool executes idempotent or persist results before + * returning if you can't tolerate re-execution. + * + * Generic `retry` options are not exposed on `chat.agent` because + * arbitrary retries against an LLM-driven loop tend to be expensive + * and side-effecting. If you need richer retry semantics, drop down + * to `chat.task` (the raw primitive). + * + * @example + * ```ts + * chat.agent({ + * id: "my-chat", + * machine: "small-1x", + * oomMachine: "medium-2x", + * run: async ({ messages, signal }) => + * streamText({ model, messages, abortSignal: signal }), + * }); + * ``` + */ + oomMachine?: MachinePresetName; + + /** + * Schema for validating `clientData` from the frontend. + * Accepts Zod, ArkType, Valibot, or any supported schema library. + * When provided, `clientData` is parsed and typed in all hooks and `run`. + * + * @example + * ```ts + * import { z } from "zod"; + * + * chat.agent({ + * id: "my-chat", + * clientDataSchema: z.object({ model: z.string().optional(), userId: z.string() }), + * run: async ({ messages, clientData, ctx, signal }) => { + * // clientData is typed as { model?: string; userId: string } + * // ctx is the same TaskRunContext as in task({ run: (payload, { ctx }) => ... }) + * }, + * }); + * ``` + */ + clientDataSchema?: TClientDataSchema; + + /** + * Schema for validating custom actions sent via `transport.sendAction()`. + * + * When the frontend sends `trigger: "action"`, the `action` payload is + * parsed against this schema before reaching `onAction`. Invalid actions + * throw and abort the turn. + * + * @example + * ```ts + * import { z } from "zod"; + * + * chat.agent({ + * id: "my-chat", + * actionSchema: z.discriminatedUnion("type", [ + * z.object({ type: z.literal("undo") }), + * z.object({ type: z.literal("rollback"), targetMessageId: z.string() }), + * ]), + * onAction: async ({ action }) => { + * if (action.type === "undo") chat.history.slice(0, -2); + * if (action.type === "rollback") chat.history.rollbackTo(action.targetMessageId); + * }, + * run: async ({ messages, signal }) => { ... }, + * }); + * ``` + */ + actionSchema?: TActionSchema; + + /** + * Called when the frontend sends a custom action via `transport.sendAction()`. + * + * Actions are not turns. They fire `hydrateMessages` (if configured) and + * `onAction` only — no `onTurnStart` / `prepareMessages` / + * `onBeforeTurnComplete` / `onTurnComplete`, no `run()`. Use + * `chat.history.*` inside `onAction` to mutate state. + * + * To produce a model response from an action, return a + * `StreamTextResult` (auto-piped), `string`, or `UIMessage`. Returning + * `void` or nothing is the side-effect-only default. + */ + onAction?: ( + event: ActionEvent< + [TActionSchema] extends [TaskSchema] ? inferSchemaOut : unknown, + inferSchemaOut, + TUIMessage + > + ) => Promise | unknown; + + /** + * The run function for the chat task. + * + * Receives a `ChatTaskRunPayload` with the conversation messages, chat session ID, + * trigger type, task `ctx` (same as `task({ run })`’s second argument), and abort signals + * (`signal`, `cancelSignal`, `stopSignal`). + * + * **Auto-piping:** If this function returns a value with `.toUIMessageStream()`, + * the stream is automatically piped to the frontend. + */ + run: (payload: ChatTaskRunPayload>) => Promise; + + /** + * Called once at the start of every run boot — for the initial run, for + * preloaded runs, AND for reactive continuation runs (post-cancel / + * crash / `endRun` / `requestUpgrade` / OOM retry). + * + * Use this for per-process setup that needs to run every time a fresh + * worker picks up the chat: initialize `chat.local` state, open + * per-process resources, or re-hydrate customer state from your DB + * on continuation. Branch on `continuation` to decide whether to load + * existing state vs. start fresh. + * + * Fires BEFORE `onPreload` (preloaded path) and `onChatStart` + * (first-message path), so `chat.local` is safe to read in those hooks. + * + * Does NOT fire when the same run resumes from snapshot via the + * idle-window suspend/resume path — use `onChatResume` for that. + * + * @example + * ```ts + * onBoot: async ({ chatId, clientData, continuation }) => { + * const user = await db.user.findFirst({ where: { id: clientData.userId } }); + * userContext.init({ name: user.name, plan: user.plan }); + * if (continuation) { + * // re-hydrate any per-chat in-memory state from your DB + * } + * } + * ``` + */ + onBoot?: (event: BootEvent>) => Promise | void; + + /** + * Called when a preloaded run starts, before the first message arrives. + * + * Use this to initialize state, create DB records, and load context early — + * so everything is ready when the user's first message comes through. + * + * @example + * ```ts + * onPreload: async ({ ctx, chatId, clientData }) => { + * await db.chat.create({ data: { id: chatId } }); + * userContext.init(await loadUser(clientData.userId)); + * } + * ``` + */ + onPreload?: (event: PreloadEvent>) => Promise | void; + + /** + * Called exactly once per chat, on the very first user message of the + * chat's lifetime. Does NOT fire on continuation runs (post-`endRun`, + * post-waitpoint-timeout, `chat.requestUpgrade`) or on OOM-retry attempts — + * those are runs of an already-started chat. + * + * Use this for one-time chat-setup work — creating the Chat DB row, + * initializing per-chat in-memory state, minting resources tied to the + * chat's lifetime. Safe to assume no prior history exists here. + * + * For per-turn work, use `onTurnStart`. + * + * @example + * ```ts + * onChatStart: async ({ ctx, chatId, messages, clientData }) => { + * await db.chat.create({ data: { id: chatId, userId: clientData.userId } }); + * } + * ``` + */ + onChatStart?: (event: ChatStartEvent>) => Promise | void; + + /** + * Validate or transform incoming UI messages before they are converted to model + * messages and accumulated. Fires once per turn with the raw `UIMessage[]` from + * the wire payload (after cleanup of aborted tool parts). + * + * Return the validated messages array. Throw to abort the turn with an error. + * + * This is the right place to call the AI SDK's `validateUIMessages` to catch + * malformed messages from storage or untrusted input before they reach the model. + * + * @example + * ```ts + * import { validateUIMessages } from "ai"; + * + * chat.agent({ + * id: "my-chat", + * onValidateMessages: async ({ messages }) => { + * return validateUIMessages({ messages, tools: chatTools }); + * }, + * run: async ({ messages }) => { + * return streamText({ model, messages, tools: chatTools }); + * }, + * }); + * ``` + */ + onValidateMessages?: ( + event: ValidateMessagesEvent + ) => TUIMessage[] | Promise; + + /** + * Load the full message history from your backend on every turn, + * replacing the built-in linear accumulator. + * + * When set, the returned messages become the accumulated state for this turn. + * The normal accumulation logic (append for submit, replace for regenerate) + * is skipped entirely — the hook is the source of truth. + * + * After the hook returns, any incoming wire messages with matching IDs are + * auto-merged (handles tool approval responses transparently). + * + * Use cases: + * - Backend trust: prevent clients from injecting fabricated history + * - Branching conversations (DAGs): load only the active branch + * - Rollback/undo: exclude undone messages from history + * + * @example + * ```ts + * chat.agent({ + * id: "my-chat", + * hydrateMessages: async ({ chatId, trigger, incomingMessages }) => { + * // Persist the new message + * const newMsg = incomingMessages[incomingMessages.length - 1]; + * if (newMsg && trigger === "submit-message") { + * await db.chatMessages.create({ chatId, message: newMsg }); + * } + * // Return the full authoritative history + * return db.chatMessages.findMany({ where: { chatId } }); + * }, + * run: async ({ messages, signal }) => { + * return streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + * }, + * }); + * ``` + */ + hydrateMessages?: ( + event: HydrateMessagesEvent, TUIMessage> + ) => TUIMessage[] | Promise; + + /** + * Called at the start of every turn, after message accumulation and `onChatStart` (turn 0), + * but before the `run` function executes. + * + * Use this to persist messages before streaming begins, so a mid-stream page refresh + * still shows the user's message. + * + * @example + * ```ts + * onTurnStart: async ({ ctx, chatId, uiMessages }) => { + * await db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } }); + * } + * ``` + */ + onTurnStart?: ( + event: TurnStartEvent, TUIMessage> + ) => Promise | void; + + /** + * Called after the response is captured but before the stream closes. + * The stream is still open, so you can write custom chunks to the frontend + * (e.g. compaction progress). Use this for compaction, post-processing, + * or any work where the user should see real-time status updates. + * + * @example + * ```ts + * onBeforeTurnComplete: async ({ ctx, writer, usage }) => { + * if (usage?.inputTokens && usage.inputTokens > 5000) { + * writer.write({ type: "data-compaction", id: generateId(), data: { status: "compacting" } }); + * // ... compact messages ... + * chat.setMessages(compactedMessages); + * writer.write({ type: "data-compaction", id: generateId(), data: { status: "complete" } }); + * } + * } + * ``` + */ + onBeforeTurnComplete?: ( + event: BeforeTurnCompleteEvent, TUIMessage> + ) => Promise | void; + + /** + * Called when conversation compaction occurs (via `chat.compact()` or + * `chat.compactionStep()`). Use for logging, billing, or persisting the summary. + * + * @example + * ```ts + * onCompacted: async ({ ctx, summary, totalTokens, chatId }) => { + * logger.info("Compacted", { totalTokens, chatId }); + * await db.compactionLog.create({ data: { chatId, summary } }); + * } + * ``` + */ + onCompacted?: (event: CompactedEvent) => Promise | void; + + /** + * Automatic context compaction. When provided, compaction runs automatically + * in both the inner loop (prepareStep, between tool-call steps) and the + * outer loop (between turns, for single-step responses where prepareStep + * never fires). + * + * The `shouldCompact` callback decides when to compact, and `summarize` + * generates the summary. The prepareStep is auto-injected into + * `chat.toStreamTextOptions()` — if you provide your own `prepareStep` + * after spreading, it overrides the auto-injected one. + * + * @example + * ```ts + * chat.agent({ + * id: "my-chat", + * compaction: { + * shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000, + * summarize: async (messages) => + * generateText({ model, messages: [...messages, { role: "user", content: "Summarize." }] }) + * .then((r) => r.text), + * }, + * run: async ({ messages, signal }) => { + * return streamText({ ...chat.toStreamTextOptions({ registry }), messages }); + * }, + * }); + * ``` + */ + compaction?: ChatAgentCompactionOptions; + + /** + * Configure how messages that arrive during streaming are handled. + * + * By default, messages queue for the next turn. When `shouldInject` is provided + * and returns `true`, messages are injected between tool-call steps via + * `prepareStep` — allowing users to steer the agent mid-execution. + * + * @example + * ```ts + * pendingMessages: { + * shouldInject: ({ steps }) => steps.length > 0, + * onReceived: ({ message }) => logger.info("Steering message received"), + * }, + * ``` + */ + pendingMessages?: PendingMessagesOptions; + + /** + * Called after each assistant response completes. Use to persist the + * conversation to your database after each assistant response. + * + * @example + * ```ts + * onTurnComplete: async ({ ctx, chatId, messages }) => { + * await db.chat.update({ where: { id: chatId }, data: { messages } }); + * } + * ``` + */ + onTurnComplete?: ( + event: TurnCompleteEvent, TUIMessage> + ) => Promise | void; + + /** + * Maximum number of conversational turns (message round-trips) a single run + * will handle before ending. After this many turns the run completes + * normally and the next message will start a fresh run. + * + * @default 100 + */ + maxTurns?: number; + + /** + * How long to wait for the next message before timing out and ending the run. + * Accepts any duration string (e.g. `"1h"`, `"30m"`). + * + * @default "1h" + */ + turnTimeout?: string; + + /** + * How long (in seconds) the run stays idle (active, using compute) after each + * turn, waiting for the next message. During this window responses are instant. + * After this timeout the run suspends (frees compute) and waits via + * `inputStream.wait()`. + * + * Set to `0` to suspend immediately after each turn. + * + * @default 30 + */ + idleTimeoutInSeconds?: number; + + /** + * How long the `chatAccessToken` (scoped to this run) remains valid. + * A fresh token is minted after each turn, so this only needs to cover + * the gap between turns. + * + * Accepts a duration string (e.g. `"1h"`, `"30m"`, `"2h"`). + * + * @default "1h" + */ + chatAccessTokenTTL?: string; + + /** + * How long (in seconds) the run stays idle after `onPreload` fires, + * waiting for the first message before suspending. + * + * Only applies to preloaded runs (triggered via `transport.preload()`). + * Takes precedence over `transport.preload(..., { idleTimeoutInSeconds })` + * and over {@link ChatAgentOptions.idleTimeoutInSeconds}. + * + * @default Same as `idleTimeoutInSeconds` + */ + preloadIdleTimeoutInSeconds?: number; + + /** + * How long to wait (suspended) for the first message after a preloaded run starts. + * If no message arrives within this time, the run ends. + * + * Only applies to preloaded runs. + * + * @default Same as `turnTimeout` + */ + preloadTimeout?: string; + + /** + * Transform model messages before they're used anywhere — in `run()`, + * in compaction rebuilds, and in compaction results. + * + * Define once, applied everywhere. Use for Anthropic cache breaks, + * injecting system context, stripping PII, etc. + * + * @example + * ```ts + * prepareMessages: async ({ messages, reason }) => { + * // Add Anthropic cache breaks to the last message + * if (messages.length === 0) return messages; + * const last = messages[messages.length - 1]; + * return [...messages.slice(0, -1), { + * ...last, + * providerOptions: { ...last.providerOptions, anthropic: { cacheControl: { type: "ephemeral" } } }, + * }]; + * } + * ``` + */ + prepareMessages?: ( + event: PrepareMessagesEvent> + ) => ModelMessage[] | Promise; + + /** + * Default options for `toUIMessageStream()` when auto-piping or using + * `turn.complete()` / `chat.pipeAndCapture()`. + * + * Controls how the `StreamTextResult` is converted to a `UIMessageChunk` + * stream — error handling, reasoning/source visibility, metadata, etc. + * + * Can be overridden per-turn by calling `chat.setUIMessageStreamOptions()` + * inside `run()` or lifecycle hooks. Per-turn values are merged on top + * of these defaults (per-turn wins on conflicts). + * + * `onFinish` and `originalMessages` are managed internally and cannot be + * overridden here. Use `streamText`'s `onFinish` for custom finish + * handling. `generateMessageId` can be set to control response message + * ID generation (e.g. UUID-v7). + * + * @example + * ```ts + * chat.agent({ + * id: "my-chat", + * uiMessageStreamOptions: { + * sendReasoning: true, + * onError: (error) => error instanceof Error ? error.message : "An error occurred.", + * }, + * run: async ({ messages, signal }) => { ... }, + * }); + * ``` + */ + uiMessageStreamOptions?: ChatUIMessageStreamOptions; + + /** + * Called right before the run suspends to wait for a message. + * + * The `phase` discriminator tells you when the suspend happened: + * - `"preload"`: after `onPreload`, waiting for the first message + * - `"turn"`: after `onTurnComplete`, waiting for the next message + * + * Use this for cleanup before suspension (e.g. disposing sandboxes, closing connections). + * + * @example + * ```ts + * onChatSuspend: async (event) => { + * await disposeExpensiveResources(event.ctx.run.id); + * if (event.phase === "turn") { + * logger.info("Suspending after turn", { turn: event.turn }); + * } + * } + * ``` + */ + onChatSuspend?: ( + event: ChatSuspendEvent, TUIMessage> + ) => Promise | void; + + /** + * Called right after the run resumes from suspension with a new message. + * + * The `phase` discriminator tells you when the resume happened: + * - `"preload"`: first message arrived after preload suspension + * - `"turn"`: next message arrived after turn suspension + * + * Use this for re-initialization after wake (e.g. warming caches, reconnecting). + * + * @example + * ```ts + * onChatResume: async (event) => { + * warmCache(event.ctx.run.id); + * if (event.phase === "turn") { + * logger.info("Resumed after turn", { turn: event.turn }); + * } + * } + * ``` + */ + onChatResume?: ( + event: ChatResumeEvent, TUIMessage> + ) => Promise | void; + + /** + * When `true`, the run exits successfully after the preload idle timeout + * instead of suspending and waiting. The run completes with no turn executed. + * + * Use this for "fire and forget" preloads where you only want to do eager + * initialization. If the user doesn't send a message during the idle window, + * the run ends cleanly. + * + * Only applies to preloaded runs (triggered via `transport.preload()`). + * + * @default false + */ + exitAfterPreloadIdle?: boolean; +}; + +/** + * Creates a Trigger.dev task pre-configured for AI SDK chat. + * + * - **Pre-types the payload** as `ChatTaskRunPayload` — includes abort signals + * - **Auto-pipes the stream** if `run` returns a `StreamTextResult` + * - **Multi-turn**: keeps the conversation in a single run using input streams + * - **Stop support**: frontend can stop generation mid-stream via the stop input stream + * - For complex flows, use `pipeChat()` from anywhere inside your task code + * + * @example + * ```ts + * import { chat } from "@trigger.dev/sdk/ai"; + * import { streamText, convertToModelMessages } from "ai"; + * import { openai } from "@ai-sdk/openai"; + * + * export const myChat = chat.agent({ + * id: "my-chat", + * run: async ({ messages, signal }) => { + * return streamText({ + * model: openai("gpt-4o"), + * messages, // already converted via convertToModelMessages + * abortSignal: signal, + * }); + * }, + * }); + * ``` + */ +// ─── chat.customAgent ────────────────────────────────────────────── +// A thin wrapper around createTask that marks the task as an agent +// (triggerSource: "agent") so it appears in the playground, but does +// NOT implement the managed lifecycle (no turn loop, no preload, etc.). +// The user's run function receives the raw ChatTaskWirePayload and +// uses composable primitives (chat.messages, chat.MessageAccumulator, etc.). +// ──────────────────────────────────────────────────────────────────── + +type ChatCustomAgentOptions< + TIdentifier extends string, + TClientDataSchema extends TaskSchema | undefined = undefined, + TUIMessage extends UIMessage = UIMessage, +> = Omit< + TaskOptions< + TIdentifier, + ChatTaskWirePayload>, + unknown + >, + "triggerSource" | "agentConfig" +> & { + clientDataSchema?: TClientDataSchema; +}; + +function chatCustomAgent< + TIdentifier extends string, + TClientDataSchema extends TaskSchema | undefined = undefined, + TUIMessage extends UIMessage = UIMessage, +>( + options: ChatCustomAgentOptions +): Task>, unknown> { + const { clientDataSchema, run: userRun, ...restOptions } = options; + + const task = createTask< + TIdentifier, + ChatTaskWirePayload>, + unknown + >({ + ...restOptions, + triggerSource: "agent", + agentConfig: { type: "ai-sdk-chat" }, + run: async ( + payload: ChatTaskWirePayload>, + runOptions + ) => { + // Bind the run to its backing Session so module-level helpers + // (chat.messages, chat.stream, chat.createStopSignal, chat.createSession) + // resolve to this chat's `.in` / `.out` channels. Address + // everywhere by `payload.chatId` (the session externalId) so the + // agent's writes and the transport's reads converge on the same + // S2 stream key + waitpoint key. + // + // The Session row is created server-side by `POST /sessions` (or + // `chat.createStartSessionAction`) before this run is triggered. + // No client-side upsert needed. + locals.set(chatSessionHandleKey, sessions.open(payload.chatId)); + locals.set(chatAgentRunContextKey, runOptions.ctx); + taskContext.setConversationId(payload.chatId); + stampConversationIdOnActiveSpan(payload.chatId); + return userRun(payload, runOptions); + }, + }); + + // Register clientDataSchema so the CLI converts it to JSONSchema + // and stores it as payloadSchema — used by the Playground UI + if (clientDataSchema) { + resourceCatalog.updateTaskMetadata(options.id, { + schema: clientDataSchema as any, + }); + } + + return task; +} + +function chatAgent< + TIdentifier extends string, + TClientDataSchema extends TaskSchema | undefined = undefined, + TUIMessage extends UIMessage = UIMessage, + TActionSchema extends TaskSchema | undefined = undefined, +>( + options: ChatAgentOptions +): Task>, unknown> { + const { + run: userRun, + clientDataSchema, + onBoot, + onPreload, + onChatStart, + onValidateMessages, + hydrateMessages, + actionSchema, + onAction, + onTurnStart, + onBeforeTurnComplete, + onCompacted, + compaction, + pendingMessages: pendingMessagesConfig, + prepareMessages, + onTurnComplete, + maxTurns = 100, + turnTimeout = "1h", + idleTimeoutInSeconds = 30, + chatAccessTokenTTL = "1h", + preloadIdleTimeoutInSeconds, + preloadTimeout, + uiMessageStreamOptions, + onChatSuspend, + onChatResume, + exitAfterPreloadIdle = false, + oomMachine, + ...restOptions + } = options; + + const parseClientData = clientDataSchema ? getSchemaParseFn(clientDataSchema) : undefined; + const parseAction = actionSchema ? getSchemaParseFn(actionSchema) : undefined; + + // chat.agent does not expose generic retry options (see docstring on + // `oomMachine`). The only opt-in is an OOM-triggered machine swap. If + // `oomMachine` is set we allow one retry on a larger machine; otherwise + // we keep the historical no-retry default. + const retry = oomMachine + ? { maxAttempts: 2, outOfMemory: { machine: oomMachine } } + : { maxAttempts: 1 }; + + const task = createTask< + TIdentifier, + ChatTaskWirePayload>, + unknown + >({ + ...restOptions, + retry, + triggerSource: "agent", + agentConfig: { type: "ai-sdk-chat" }, + run: async ( + payload: ChatTaskWirePayload>, + { signal: runSignal, ctx } + ) => { + locals.set(chatAgentRunContextKey, ctx); + + // Bind the run to its backing Session so every module-level helper + // (chat.stream, chat.messages, chat.stopSignal) resolves to this + // chat's `.in` / `.out` channels. + // + // Address everywhere by `payload.chatId` (the session externalId): + // matches what the transport puts in URL paths and waitpoint keys, + // and what the server-side trigger flow uses as the session + // identity. The Session row is created by `POST /sessions` (via + // `chat.createStartSessionAction` or browser-direct) before this + // run is triggered — no client-side upsert needed here. + locals.set(chatSessionHandleKey, sessions.open(payload.chatId)); + taskContext.setConversationId(payload.chatId); + + // Stamp `gen_ai.conversation.id` on the run-level span. Every + // nested span inherits the same attribute via + // `TaskContextSpanProcessor.onStart`. + const activeSpan = trace.getActiveSpan(); + stampConversationIdOnActiveSpan(payload.chatId, activeSpan); + + // Store static UIMessageStream options in locals so resolveUIMessageStreamOptions() can read them + if (uiMessageStreamOptions) { + locals.set(chatUIStreamStaticKey, uiMessageStreamOptions); + } + + // Store onCompacted hook in locals so chat.compact() can call it + if (onCompacted) { + locals.set(chatOnCompactedKey, onCompacted); + } + + if (prepareMessages) { + locals.set(chatPrepareMessagesKey, prepareMessages); + } + + if (compaction) { + locals.set( + chatAgentCompactionKey, + compaction as unknown as ChatAgentCompactionOptions + ); + } + + if (pendingMessagesConfig) { + locals.set(chatPendingMessagesKey, pendingMessagesConfig); + } + + let currentWirePayload = payload; + const continuation = payload.continuation ?? false; + const previousRunId = payload.previousRunId; + const preloaded = payload.trigger === "preload"; + + // Accumulated model messages across turns. Seeded at boot from a + // durable snapshot + `session.out` replay (or `hydrateMessages` if + // registered) — the wire is delta-only now, no longer a seed. + let accumulatedMessages: ModelMessage[] = []; + + // Accumulated UI messages for persistence. Mirrors the model accumulator + // but in frontend-friendly UIMessage format (with parts, id, etc.). + let accumulatedUIMessages: TUIMessage[] = []; + + // ── Snapshot + replay (gated on prior-state signals) ───────────── + // + // With `hydrateMessages` registered the customer owns history — the + // hook fires per-turn and produces the canonical chain from their DB. + // Skip both reads entirely: no need to load a blob the customer's + // hook will overwrite. + // + // Without it, both reads are gated on `couldHavePriorState`. A fresh + // chat (no continuation, attempt 1) can't have a snapshot OR replay + // records by definition — `readChatSnapshot` would 404 and + // `replaySessionOutTail` would return [], and the round-trips + // collectively cost ~600ms on every first-message TTFC. Both reads + // swallow errors internally; the agent stays available either way. + const sessionIdForSnapshot = payload.sessionId ?? payload.chatId; + let bootSnapshot: ChatSnapshotV1 | undefined; + let replayed: TUIMessage[] = []; + const couldHavePriorState = + payload.continuation === true || ctx.attempt.number > 1; + + if (!hydrateMessages && couldHavePriorState) { + try { + bootSnapshot = await tracer.startActiveSpan( + "chat.boot.snapshot.read", + async () => readChatSnapshot(sessionIdForSnapshot) + ); + } catch (error) { + // `readChatSnapshot` already swallows + warns internally; this catch + // is just belt-and-suspenders against tracer/span errors. + logger.warn("chat.agent: snapshot read failed; continuing without snapshot", { + error: error instanceof Error ? error.message : String(error), + sessionId: sessionIdForSnapshot, + }); + } + + try { + replayed = await tracer.startActiveSpan("chat.boot.replay", async () => + replaySessionOutTail(sessionIdForSnapshot, { + lastEventId: bootSnapshot?.lastOutEventId, + }) + ); + } catch (error) { + logger.warn("chat.agent: session.out replay failed; using snapshot only", { + error: error instanceof Error ? error.message : String(error), + sessionId: sessionIdForSnapshot, + }); + } + } + + // ── session.in dedup cutoff ──────────────────────────────────── + // + // A fresh worker subscribes to `session.in` from seq 0 and would + // re-deliver every record ever appended — including user messages + // from turns already completed on a prior run. Without dedup, the + // loop would re-process them as fresh turns and the slim-wire merge + // would replace-by-id against the snapshot-restored copies, yielding + // no-op replaces while the customer's actual new message waits in + // the queue. + // + // The cutoff is the timestamp of the last `trigger:turn-complete` + // chunk on `session.out`. When we have a snapshot, that timestamp is + // already in `lastOutTimestamp` — use it directly to skip the + // O(stream-length) scan. Fall back to the scan only when no snapshot + // is available (first-ever OOM retry, or `hydrateMessages` + // short-circuited the snapshot read). + // + // Applies in three cases (any of which means session.in has records + // belonging to completed turns the new run should skip): + // - OOM retry (`ctx.attempt.number > 1`) + // - Continuation run (`payload.continuation === true`) — prior run + // crashed / was canceled / requested upgrade + // - Snapshot exists at all (catches edge cases where the wire + // didn't set `continuation` but a snapshot indicates prior turns) + const needsDedupCutoff = + ctx.attempt.number > 1 || + payload.continuation === true || + bootSnapshot !== undefined; + + if (needsDedupCutoff) { + try { + let cutoff = bootSnapshot?.lastOutTimestamp; + if (cutoff === undefined) { + cutoff = await findLatestTurnCompleteTimestamp(payload.chatId); + } + if (cutoff !== undefined) { + sessionStreams.setMinTimestamp(payload.chatId, "in", cutoff); + } + } catch (error) { + logger.warn( + "chat.agent: session.in dedup cutoff lookup failed; old messages may replay", + { error: error instanceof Error ? error.message : String(error) } + ); + } + } + + // ── Merge + head-start bootstrap ──────────────────────────────── + if (!hydrateMessages) { + accumulatedUIMessages = mergeByIdReplaceWins( + (bootSnapshot?.messages as TUIMessage[]) ?? [], + replayed + ); + + // ── Head-start bootstrap ───────────────────────────────────── + // + // The very-first turn of a head-start handover has no snapshot + // (it doesn't exist yet) and no `session.out` history (the run + // just woke up). The customer's HTTP route handler ships full + // UIMessage history via `headStartMessages` — that's the only + // path where wire-borne UIMessage[] still seeds the accumulator, + // and it's safe because the route handler isn't subject to the + // `/in/append` 512 KiB cap. + if ( + accumulatedUIMessages.length === 0 && + payload.trigger === "handover-prepare" && + Array.isArray(payload.headStartMessages) && + payload.headStartMessages.length > 0 + ) { + accumulatedUIMessages = [...(payload.headStartMessages as TUIMessage[])]; + } + + if (accumulatedUIMessages.length > 0) { + try { + accumulatedMessages = await toModelMessages(accumulatedUIMessages); + } catch (error) { + logger.warn("chat.agent: toModelMessages failed at boot; starting empty", { + error: error instanceof Error ? error.message : String(error), + sessionId: sessionIdForSnapshot, + }); + accumulatedMessages = []; + } + } + + // Make the seeded UI accumulator visible to `chat.history.*` + // before any hook (`onChatStart`, `onTurnStart`, etc.) fires. + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + + } + + // Token usage tracking across turns + let previousTurnUsage: LanguageModelUsage | undefined; + let cumulativeUsage: LanguageModelUsage = emptyUsage(); + + // Mutable reference to the current turn's stop controller so the + // stop input stream listener (registered once) can abort the right turn. + let currentStopController: AbortController | undefined; + + // Stop-input subscription is registered AFTER preload's wait resolves + // (see the post-preload block below). Registering it earlier would + // cause it to drain any session.in records buffered before the runtime + // started — including the customer's first user message arriving on + // `kind: "message"`. The persistent-listener semantics of session.in + // pop the buffer when a handler attaches; the stop listener filters + // out anything that isn't `kind: "stop"` and silently swallows the + // user message instead of leaving it for `messagesInput.waitWithIdleTimeout` + // to pick up. + let stopSub: { off: () => void } | undefined; + + try { + // ── onBoot — fires once per fresh worker process picking up the chat. + // + // Runs BEFORE `onPreload`, `onChatStart`, the continuation-wait + // branch, and any turn. This is the hook customers use to do + // per-process setup that has to repeat for every boot (initial + // run, preloaded run, AND reactive continuation): `chat.local` + // initialization, opening per-process resources, re-hydrating + // customer state from their DB on continuation. + // + // `onChatStart` is once-per-chat (`!couldHavePriorState` gate); + // when the prior run dies and a fresh worker boots, `onChatStart` + // does NOT fire — which is why customers initializing + // `chat.local` only in `onChatStart` previously crashed in + // `run()` on continuation. `onBoot` fills that gap. + if (onBoot) { + const bootClientData = ( + parseClientData ? await parseClientData(payload.metadata) : payload.metadata + ) as inferSchemaOut; + + let bootAccessToken = ""; + const bootRunId = ctx.run.id; + if (bootRunId) { + try { + bootAccessToken = await auth.createPublicToken({ + scopes: { + read: { + runs: bootRunId, + sessions: payload.chatId, + }, + write: { + inputStreams: bootRunId, + sessions: payload.chatId, + }, + }, + expirationTime: chatAccessTokenTTL, + }); + } catch { + // Token mint is best-effort — customers can re-mint from + // their own backend if they need one. + } + } + + await tracer.startActiveSpan( + "onBoot()", + async () => { + await onBoot({ + ctx, + chatId: payload.chatId, + runId: bootRunId, + chatAccessToken: bootAccessToken, + clientData: bootClientData, + continuation, + previousRunId, + preloaded, + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": payload.chatId, + "chat.continuation": continuation, + "chat.preloaded": preloaded, + }, + } + ); + } + + // Handle preloaded runs — fire onPreload, then wait for the first real message + if (preloaded) { + if (activeSpan) { + activeSpan.setAttribute("chat.preloaded", true); + } + + const currentRunId = ctx.run.id; + let preloadAccessToken = ""; + if (currentRunId) { + try { + preloadAccessToken = await auth.createPublicToken({ + scopes: { + read: { + runs: currentRunId, + sessions: payload.chatId, + }, + write: { + inputStreams: currentRunId, + sessions: payload.chatId, + }, + }, + expirationTime: chatAccessTokenTTL, + }); + } catch { + // Token creation failed + } + } + + // Parse client data for the preload hook + const preloadClientData = ( + parseClientData ? await parseClientData(payload.metadata) : payload.metadata + ) as inferSchemaOut; + + // Fire onPreload hook + if (onPreload) { + await tracer.startActiveSpan( + "onPreload()", + async () => { + await withChatWriter(async (writer) => { + await onPreload({ + ctx, + chatId: payload.chatId, + runId: currentRunId, + chatAccessToken: preloadAccessToken, + clientData: preloadClientData, + writer, + }); + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": payload.chatId, + "chat.preloaded": true, + }, + } + ); + } + + // Wait for the first real message — task-level idle settings win over + // `transport.preload(..., { idleTimeoutInSeconds })` / wire payload so + // `chat.agent({ idleTimeoutInSeconds, preloadIdleTimeoutInSeconds })` is authoritative. + const effectivePreloadIdleTimeout = + preloadIdleTimeoutInSeconds ?? + idleTimeoutInSeconds ?? + payload.idleTimeoutInSeconds; + + const effectivePreloadTimeout = + (metadata.get(TURN_TIMEOUT_METADATA_KEY) as string | undefined) ?? + preloadTimeout ?? + turnTimeout; + + const preloadResult = await messagesInput.waitWithIdleTimeout({ + idleTimeoutInSeconds: effectivePreloadIdleTimeout, + timeout: effectivePreloadTimeout, + spanName: "waiting for first message", + skipSuspend: exitAfterPreloadIdle, + onSuspend: onChatSuspend + ? async () => { + await tracer.startActiveSpan( + "onChatSuspend()", + async () => { + await onChatSuspend({ + phase: "preload", + ctx, + chatId: payload.chatId, + runId: currentRunId, + clientData: preloadClientData, + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": payload.chatId, + "chat.suspend.phase": "preload", + }, + } + ); + } + : undefined, + onResume: onChatResume + ? async () => { + await tracer.startActiveSpan( + "onChatResume()", + async () => { + await onChatResume({ + phase: "preload", + ctx, + chatId: payload.chatId, + runId: currentRunId, + clientData: preloadClientData, + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": payload.chatId, + "chat.resume.phase": "preload", + }, + } + ); + } + : undefined, + }); + + if (!preloadResult.ok) { + return; // Timed out waiting for first message — end run + } + + let firstMessage = preloadResult.output; + + currentWirePayload = firstMessage as ChatTaskWirePayload< + TUIMessage, + inferSchemaIn + >; + + // Close signal during preload — exit before first turn + if (currentWirePayload.trigger === "close") { + return; + } + } + + // Listen for stop signals for the rest of the run. Registered AFTER + // the preload wait resolves (or skipped immediately for non-preload + // triggers) so the persistent-listener semantics on session.in + // don't drain the buffered user message before + // `messagesInput.waitWithIdleTimeout` can pick it up. A stop signal + // that lands during the preload window is dropped — acceptable, the + // customer can't reasonably stop a chat that hasn't started. + stopSub = stopInput.on((data) => { + currentStopController?.abort(data?.message || "stopped"); + }); + + // Handle handover-prepare runs — wait on session.in for the + // customer's `chat.handover` route handler to either hand off + // mid-turn (tool calls) or signal pure-text completion. + if (payload.trigger === "handover-prepare") { + if (activeSpan) { + activeSpan.setAttribute("chat.handoverPreparing", true); + } + + const handoverResult = await handoverInput.waitWithIdleTimeout({ + idleTimeoutInSeconds: idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds ?? 60, + spanName: "waiting for handover signal", + }); + + if (!handoverResult.ok) { + // Handler crashed before signaling — exit cleanly. + return; + } + + if (handoverResult.output.kind === "handover-skip") { + // Sent only when the customer's handler aborts before + // producing a finishReason. Normal pure-text and + // tool-call finishes go through `kind: "handover"` with + // `isFinal: true | false`. Exit without firing any turn + // hooks. + return; + } + + // kind === "handover": stash the partial assistant message + // so turn-0 setup can append it after loading user + // messages. Two branches downstream, switched by `isFinal`: + // - `false`: customer's step 1 ended with `tool-calls`. + // The agent's `streamText` sees pending tool-calls (via + // the approval round in the partial) and executes them, + // then runs step 2's LLM call. + // - `true`: customer's step 1 ended pure-text. The agent + // runs the turn-loop hooks but SKIPS the `streamText` + // call entirely (the response is already complete). + // `onTurnComplete` fires with the partial as + // `responseMessage` so persistence works normally. + locals.set( + chatHandoverPartialKey, + handoverResult.output.partialAssistantMessage + ); + // Stash the customer-side step-1 messageId. Turn-0 setup + // uses it to seed the synthesized partial UIMessage with the + // SAME id, so the agent's post-handover chunks merge into + // the same assistant message on the browser side. + if (handoverResult.output.messageId) { + locals.set(chatHandoverMessageIdKey, handoverResult.output.messageId); + } + locals.set(chatHandoverIsFinalKey, handoverResult.output.isFinal); + + // Synthesize a wire payload that the turn loop treats as a + // normal first-turn message. The accumulator was already seeded + // at boot from `payload.headStartMessages` (see B.3 head-start + // bootstrap), so the rewritten `submit-message` carries no + // delta — the loop runs streamText against the seeded state. + currentWirePayload = { + ...payload, + trigger: "submit-message", + message: undefined, + headStartMessages: undefined, + } as ChatTaskWirePayload>; + } + + // Continuation-wait: a continuation run (post-`endRun`, + // post-waitpoint-timeout, etc.) booted with no incoming message. + // The server strips the Session's basePayload `message` / + // `messages` / `trigger` on continuation so a stale one-shot boot + // payload doesn't re-fire on every resume. Without a real first + // message, we have nothing to process at turn 0 — wait silently + // for the next session.in record before entering the turn loop. + // Unlike preload, `onPreload` does NOT fire (the chat already + // started); `onChatStart` will fire on the first turn with + // `continuation: true, preloaded: false`. + if ( + !preloaded && + payload.continuation === true && + !payload.message && + payload.trigger !== "handover-prepare" && + payload.trigger !== "close" + ) { + if (activeSpan) { + activeSpan.setAttribute("chat.continuationWaiting", true); + } + + const continuationClientData = ( + parseClientData ? await parseClientData(payload.metadata) : payload.metadata + ) as inferSchemaOut; + + const effectiveIdleTimeout = + idleTimeoutInSeconds ?? payload.idleTimeoutInSeconds; + const effectiveTurnTimeout = + (metadata.get(TURN_TIMEOUT_METADATA_KEY) as string | undefined) ?? turnTimeout; + + const continuationResult = await messagesInput.waitWithIdleTimeout({ + idleTimeoutInSeconds: effectiveIdleTimeout, + timeout: effectiveTurnTimeout, + spanName: "waiting for first message (continuation)", + onSuspend: onChatSuspend + ? async () => { + await tracer.startActiveSpan( + "onChatSuspend()", + async () => { + await onChatSuspend({ + phase: "continuation", + ctx, + chatId: payload.chatId, + runId: ctx.run.id, + clientData: continuationClientData, + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": payload.chatId, + "chat.suspend.phase": "continuation", + }, + } + ); + } + : undefined, + onResume: onChatResume + ? async () => { + await tracer.startActiveSpan( + "onChatResume()", + async () => { + await onChatResume({ + phase: "continuation", + ctx, + chatId: payload.chatId, + runId: ctx.run.id, + clientData: continuationClientData, + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": payload.chatId, + "chat.resume.phase": "continuation", + }, + } + ); + } + : undefined, + }); + + if (!continuationResult.ok) { + // Timed out waiting for the customer's next message — exit. + return; + } + + currentWirePayload = continuationResult.output as ChatTaskWirePayload< + TUIMessage, + inferSchemaIn + >; + + if (currentWirePayload.trigger === "close") { + return; + } + } + + for (let turn = 0; turn < maxTurns; turn++) { + try { + // Extract turn-level context before entering the span. Slim + // wire: at most one delta message per record. `headStartMessages` + // is consumed at boot only (via `payload.headStartMessages`) + // and intentionally discarded here. + const { + metadata: wireMetadata, + message: incomingMessage, + headStartMessages: _hsm, + ...restWire + } = currentWirePayload; + void _hsm; + const incomingMessages: TUIMessage[] = incomingMessage + ? [incomingMessage as TUIMessage] + : []; + // Cleaning happens once here so `extractLastUserMessageText` and + // every downstream consumer see the same message shape — and + // `cleanupAbortedParts` no longer has to be re-applied below. + const cleanedIncomingMessages: TUIMessage[] = incomingMessages.map((msg) => + msg.role === "assistant" ? cleanupAbortedParts(msg) : msg + ); + const clientData = ( + parseClientData ? await parseClientData(wireMetadata) : wireMetadata + ) as inferSchemaOut; + const lastUserMessage = extractLastUserMessageText(cleanedIncomingMessages); + + // Actions are not turns. They use a different span name + // and don't carry a turn.number. Branched on at `isAction`. + const isAction = currentWirePayload.trigger === "action"; + const spanName = isAction ? "chat action" : `chat turn ${turn + 1}`; + + const turnAttributes: Attributes = { + ...(isAction ? {} : { "turn.number": turn + 1 }), + "gen_ai.conversation.id": currentWirePayload.chatId, + "gen_ai.operation.name": "chat", + "chat.trigger": currentWirePayload.trigger, + [SemanticInternalAttributes.STYLE_ICON]: isAction + ? "tabler-bolt" + : "tabler-message-chatbot", + [SemanticInternalAttributes.ENTITY_TYPE]: isAction ? "chat-action" : "chat-turn", + }; + + if (lastUserMessage) { + turnAttributes["chat.user_message"] = lastUserMessage; + + // Show a truncated preview of the user message as an accessory + const preview = + lastUserMessage.length > 80 ? lastUserMessage.slice(0, 80) + "..." : lastUserMessage; + Object.assign( + turnAttributes, + accessoryAttributes({ + items: [{ text: preview, variant: "normal" }], + style: "codepath", + }) + ); + } + + if (wireMetadata !== undefined) { + turnAttributes["chat.client_data"] = + typeof wireMetadata === "string" ? wireMetadata : JSON.stringify(wireMetadata); + } + + const turnResult = await tracer.startActiveSpan( + spanName, + async (turnSpan) => { + // (errors are caught by the outer try/catch which writes an error chunk) + locals.set(chatPipeCountKey, 0); + locals.set(chatDeferKey, new Set()); + locals.set(chatCompactionStateKey, undefined); + locals.set(chatSteeringQueueKey, []); + locals.set(chatResponsePartsKey, []); + // NOTE: chatBackgroundQueueKey is NOT reset here — messages injected + // by deferred work from the previous turn's onTurnComplete need to + // survive into the next turn. The queue is drained before run(). + locals.set(chatInjectedMessageIdsKey, new Set()); + + // Store chat context for auto-detection by task-tool subtasks (ai.toolExecute / legacy ai.tool) + locals.set(chatTurnContextKey, { + chatId: currentWirePayload.chatId, + turn, + continuation, + clientData, + }); + + // Per-turn stop controller (reset each turn) + const stopController = new AbortController(); + currentStopController = stopController; + locals.set(chatStopControllerKey, stopController); + + // Three signals for the user's run function + const stopSignal = stopController.signal; + const cancelSignal = runSignal; + const combinedSignal = AbortSignal.any([runSignal, stopController.signal]); + + // Buffer messages that arrive during streaming + const pendingMessages: ChatTaskWirePayload< + TUIMessage, + inferSchemaIn + >[] = []; + const pmConfig = locals.get(chatPendingMessagesKey); + const msgSub = messagesInput.on(async (msg) => { + // If pendingMessages is configured, route to the steering queue + // instead of the wire buffer. The frontend handles re-sending + // non-injected messages via sendMessage on turn complete. + if (pmConfig) { + // Slim wire: at most one delta message per record. The + // pendingMessages handler reads `msg.message` directly + // instead of slicing an array — a wire record arrives + // with the new user message in `.message`, or no message + // at all (regenerate / preload / close / handover-prepare). + const lastUIMessage = msg.message as TUIMessage | undefined; + if (lastUIMessage) { + if (pmConfig.onReceived) { + try { + await pmConfig.onReceived({ + message: lastUIMessage as TUIMessage, + chatId: currentWirePayload.chatId, + turn, + }); + } catch { + /* non-fatal */ + } + } + + try { + const queue = locals.get(chatSteeringQueueKey) ?? []; + // Deduplicate by message ID — guards against double-sends + if ( + lastUIMessage.id && + queue.some((e) => e.uiMessage.id === lastUIMessage.id) + ) { + return; + } + const modelMsgs = await toModelMessages([lastUIMessage]); + queue.push({ + uiMessage: lastUIMessage as UIMessage, + modelMessages: modelMsgs, + }); + locals.set(chatSteeringQueueKey, queue); + } catch { + /* conversion failed — skip steering queue */ + } + } + return; // Don't add to wire buffer — frontend handles non-injected case + } + + // No pendingMessages config — standard wire buffer for next turn + pendingMessages.push( + msg as ChatTaskWirePayload> + ); + }); + + // Track new messages for this turn (user input + assistant response). + const turnNewModelMessages: ModelMessage[] = []; + const turnNewUIMessages: TUIMessage[] = []; + + // ── Action handling ────────────────────────────────────── + // Actions arrive on the same input stream but with + // trigger === "action". They are NOT turns — only + // `hydrateMessages` and `onAction` fire. No turn lifecycle + // hooks (`onTurnStart` / `prepareMessages` / + // `onBeforeTurnComplete` / `onTurnComplete`) and no + // `run()` invocation. To produce a model response from + // an action, return a `StreamTextResult` (auto-piped), + // string, or UIMessage from `onAction`. Turn counter + // does not advance. + let actionStreamResult: unknown = undefined; + if (isAction) { + // Parse and validate the action payload + const parsedAction = parseAction + ? await parseAction(currentWirePayload.action) + : currentWirePayload.action; + + // Hydrate messages from backend if configured + if (hydrateMessages) { + const hydrated = await tracer.startActiveSpan( + "hydrateMessages()", + async () => { + return hydrateMessages({ + chatId: currentWirePayload.chatId, + turn, + trigger: "action", + incomingMessages: [] as TUIMessage[], + previousMessages: [...accumulatedUIMessages], + clientData, + continuation, + previousRunId, + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": currentWirePayload.chatId, + "chat.trigger": "action", + }, + } + ); + accumulatedUIMessages = [...hydrated] as TUIMessage[]; + accumulatedMessages = await toModelMessages(hydrated); + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + } + + // Fire onAction — handler may mutate state via + // `chat.history.*` and / or return a model response. + if (onAction) { + actionStreamResult = await tracer.startActiveSpan( + "onAction()", + async () => { + return await onAction({ + action: parsedAction as any, + chatId: currentWirePayload.chatId, + turn, + clientData, + uiMessages: accumulatedUIMessages, + messages: accumulatedMessages, + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": currentWirePayload.chatId, + "chat.action": + typeof parsedAction === "object" && parsedAction !== null + ? JSON.stringify(parsedAction) + : String(parsedAction), + }, + } + ); + + // Apply chat.history mutations from onAction + const actionOverride = locals.get(chatOverrideMessagesKey); + if (actionOverride) { + locals.set(chatOverrideMessagesKey, undefined); + accumulatedUIMessages = [...actionOverride] as TUIMessage[]; + accumulatedMessages = await toModelMessages(actionOverride); + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + } + } else { + warnMissingOnActionOnce(); + } + } + + // ── Message handling (non-action turns) ─────────────────── + // + // Slim wire: at most one delta message arrives per record. + // The accumulator was already seeded at boot from a durable + // snapshot + `session.out` replay (or `hydrateMessages`, + // which also fires per-turn below). Per-turn handling is + // therefore a delta merge, not a full-history reset. + if (currentWirePayload.trigger !== "action") { + + let cleanedUIMessages: TUIMessage[] = cleanedIncomingMessages; + + // Validate/transform UIMessages before conversion — catches malformed + // messages from storage or untrusted input before they reach the model. + // Slim wire: triggers like `regenerate-message` carry no incoming + // message; nothing to validate, so skip the hook to avoid feeding + // `[]` to validators (AI SDK's `validateUIMessages` rejects empty). + if (onValidateMessages && cleanedUIMessages.length > 0) { + cleanedUIMessages = (await tracer.startActiveSpan( + "onValidateMessages()", + async () => { + return onValidateMessages({ + messages: cleanedUIMessages as TUIMessage[], + chatId: currentWirePayload.chatId, + turn, + trigger: currentWirePayload.trigger as ValidateMessagesEvent["trigger"], + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": currentWirePayload.chatId, + "chat.turn": turn + 1, + "chat.messages.count": cleanedUIMessages.length, + }, + } + )) as TUIMessage[]; + } + + if (hydrateMessages) { + // Backend hydration: load the full message history from the user's + // backend, replacing the built-in accumulator entirely. With slim + // wire, `incomingMessages` is consistently 0-or-1-length — what + // was always true for `submit-message` is now true for every + // trigger. + const hydrated = await tracer.startActiveSpan( + "hydrateMessages()", + async () => { + return hydrateMessages({ + chatId: currentWirePayload.chatId, + turn, + trigger: currentWirePayload.trigger as + | "submit-message" + | "regenerate-message", + incomingMessages: cleanedUIMessages as TUIMessage[], + previousMessages: [...accumulatedUIMessages], + clientData, + continuation, + previousRunId, + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": currentWirePayload.chatId, + "chat.turn": turn + 1, + "chat.trigger": currentWirePayload.trigger, + "chat.incoming_messages.count": cleanedUIMessages.length, + }, + } + ); + + // Auto-merge tool approval updates: if any incoming wire message + // has an ID that matches a hydrated message, replace it. This makes + // tool approvals work transparently with backend hydration. + const merged = [...hydrated] as TUIMessage[]; + for (const incoming of cleanedUIMessages) { + if (!incoming.id) continue; + const idx = merged.findIndex((m) => m.id === incoming.id); + if (idx !== -1) { + merged[idx] = incoming as TUIMessage; + } + } + + accumulatedUIMessages = merged; + accumulatedMessages = await toModelMessages(merged); + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + + // Track new messages for onTurnComplete.newUIMessages + if ( + currentWirePayload.trigger === "submit-message" && + cleanedUIMessages.length > 0 + ) { + const lastUI = cleanedUIMessages[cleanedUIMessages.length - 1]!; + turnNewUIMessages.push(lastUI); + const lastModel = (await toModelMessages([lastUI]))[0]; + if (lastModel) turnNewModelMessages.push(lastModel); + } + } else { + // Default delta-merge accumulation. + // + // The accumulator was seeded at boot from snapshot+replay, + // so it already reflects prior history. Per-turn handling + // appends/replaces the single incoming delta message and + // (for regenerate) trims the tail. + if (currentWirePayload.trigger === "regenerate-message") { + // Regenerate: trim trailing assistant messages from the + // accumulator until the tail is a user message. AI SDK's + // frontend `regenerate()` already removed the trailing + // assistant from its local store; the wire signals "do + // the same here", and the agent re-runs from the new + // tail. No incoming message accompanies this trigger. + while ( + accumulatedUIMessages.length > 0 && + accumulatedUIMessages[accumulatedUIMessages.length - 1]!.role !== "user" + ) { + accumulatedUIMessages.pop(); + } + accumulatedMessages = await toModelMessages(accumulatedUIMessages); + } else if (cleanedUIMessages.length > 0) { + // Submit-message (and the special-cased + // handover-prepare → submit-message rewrite earlier in + // this scope): append-or-replace-by-id for the single + // delta message. + // + // Tool approval responses arrive as a single assistant + // message whose id collides with the existing assistant + // in the accumulator — we replace by id. The fallback + // for HITL `addToolOutput` continuations where AI SDK + // regenerates the id (TRI-9137) still applies via + // `rewriteIncomingIdViaToolCallMap`. + let replaced = false; + for (const raw of cleanedUIMessages) { + let incoming = raw; + let idx = accumulatedUIMessages.findIndex( + (m) => m.id === incoming.id + ); + if (idx === -1) { + const rewritten = rewriteIncomingIdViaToolCallMap(incoming); + if (rewritten.id !== incoming.id) { + incoming = rewritten as typeof raw; + idx = accumulatedUIMessages.findIndex( + (m) => m.id === incoming.id + ); + } + } + if (idx !== -1) { + accumulatedUIMessages[idx] = incoming as TUIMessage; + replaced = true; + } else { + accumulatedUIMessages.push(incoming as TUIMessage); + turnNewUIMessages.push(incoming as TUIMessage); + } + recordToolCallIdsFromMessage(incoming); + } + if (replaced) { + // Replacement changes structure — reconvert all model + // messages instead of appending. + accumulatedMessages = await toModelMessages(accumulatedUIMessages); + } else { + const incomingModelMessages = await toModelMessages(cleanedUIMessages); + accumulatedMessages.push(...incomingModelMessages); + } + if (turnNewUIMessages.length > 0) { + turnNewModelMessages.push( + ...(await toModelMessages(turnNewUIMessages)) + ); + } + } + // `preload` / `close` / `handover-prepare` and submits + // with no incoming message fall through with the boot- + // seeded accumulator unchanged. + + if (turn === 0) { + // Head-start handover splice (turn 0 only): the + // `chat.handover` route handler signalled a mid-turn + // handover, so splice its partial assistant response + // (text + pending tool-calls + the synthesized + // tool-approval round) onto the accumulator. + // `streamText` then hits AI SDK's initial-tool- + // execution branch, runs the agent-side tool executes, + // and resumes from step 2 — skipping the first model + // call (already done by the handler). + // + // We also synthesize a UIMessage form of the partial + // assistant and push it to `accumulatedUIMessages` so + // AI SDK's `processUIMessageStream` (invoked when the + // run loop calls `runResult.toUIMessageStream({ + // onFinish })`) can initialize `state.message` from + // the trailing assistant in `originalMessages`. Without + // that, the `tool-output-available` chunks emitted by + // the initial-tool-execution branch can't find their + // matching tool-call in state and AI SDK throws + // `UIMessageStreamError: No tool invocation found`. + const pendingHandoverPartial = locals.get(chatHandoverPartialKey); + if (pendingHandoverPartial && pendingHandoverPartial.length > 0) { + accumulatedMessages.push(...pendingHandoverPartial); + const handoverMessageId = locals.get(chatHandoverMessageIdKey); + const partialUI = synthesizeHandoverUIMessage( + pendingHandoverPartial, + handoverMessageId + ); + if (partialUI) { + accumulatedUIMessages.push(partialUI as TUIMessage); + } + locals.set(chatHandoverPartialKey, []); // consume once + } + } + + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + } + + } // end if (trigger !== "action") + + // ── Action result handling ────────────────────────────── + // For action turns, skip the turn machinery entirely. + // If `onAction` returned a stream / string / UIMessage, + // pipe it as the response. Either way, emit + // `trigger:turn-complete` and then fall through to the + // wait-for-next-message logic (shared with message turns). + // The turn counter is decremented so the next iteration + // sees the same `turn` value — actions don't count. + if (isAction) { + msgSub.off(); + + if ( + (locals.get(chatPipeCountKey) ?? 0) === 0 && + isUIMessageStreamable(actionStreamResult) + ) { + try { + const resolvedOptions = resolveUIMessageStreamOptions(); + const uiStream = ( + actionStreamResult as UIMessageStreamable + ).toUIMessageStream({ + ...resolvedOptions, + generateMessageId: + resolvedOptions.generateMessageId ?? generateMessageId, + }); + await pipeChat(uiStream, { + signal: combinedSignal, + spanName: "stream response", + }); + } catch (error) { + if ( + error instanceof Error && + error.name === "AbortError" && + runSignal.aborted + ) { + return "exit"; + } + throw error; + } + } + + await writeTurnCompleteChunk(currentWirePayload.chatId); + + // Don't consume a turn iteration — actions aren't turns. + turn--; + } + + if (!isAction) { + + // Mint a scoped public access token once per turn, reused for + // onChatStart, onTurnStart, onTurnComplete, and the turn-complete chunk. + const currentRunId = ctx.run.id; + let turnAccessToken = ""; + if (currentRunId) { + try { + turnAccessToken = await auth.createPublicToken({ + scopes: { + read: { + runs: currentRunId, + sessions: payload.chatId, + }, + write: { + inputStreams: currentRunId, + sessions: payload.chatId, + }, + }, + expirationTime: chatAccessTokenTTL, + }); + } catch { + // Token creation failed + } + } + + // Fire onChatStart on the very first message of a chat + // (across the chat's entire lifetime). Gated on + // `!couldHavePriorState` so it does NOT re-fire on + // continuation runs (post-`endRun`, post-waitpoint-timeout) + // or on OOM-retry attempts. Customers put one-time + // chat-setup work in `onChatStart` (e.g. create the Chat + // DB row, init user context) and that contract relies on + // it firing exactly once per chat. + if (turn === 0 && onChatStart && !couldHavePriorState) { + await tracer.startActiveSpan( + "onChatStart()", + async () => { + await withChatWriter(async (writer) => { + await onChatStart({ + ctx, + chatId: currentWirePayload.chatId, + messages: accumulatedMessages, + clientData, + runId: currentRunId, + chatAccessToken: turnAccessToken, + continuation, + previousRunId, + preloaded, + writer, + }); + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": currentWirePayload.chatId, + "chat.messages.count": accumulatedMessages.length, + "chat.continuation": continuation, + "chat.preloaded": preloaded, + ...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}), + }, + } + ); + } + + // Fire onTurnStart before running user code — persist messages + // so a mid-stream page refresh still shows the user's message. + if (onTurnStart) { + await tracer.startActiveSpan( + "onTurnStart()", + async () => { + await withChatWriter(async (writer) => { + await onTurnStart({ + ctx, + chatId: currentWirePayload.chatId, + messages: accumulatedMessages, + uiMessages: accumulatedUIMessages, + turn, + runId: currentRunId, + chatAccessToken: turnAccessToken, + clientData, + continuation, + previousRunId, + preloaded, + previousTurnUsage, + totalUsage: cumulativeUsage, + writer, + }); + }); + + // Check if onTurnStart replaced messages (compaction or chat.history) + const turnStartOverride = locals.get(chatOverrideMessagesKey); + if (turnStartOverride) { + locals.set(chatOverrideMessagesKey, undefined); + accumulatedUIMessages = [...turnStartOverride] as TUIMessage[]; + accumulatedMessages = await toModelMessages(turnStartOverride); + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + } + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": currentWirePayload.chatId, + "chat.turn": turn + 1, + "chat.messages.count": accumulatedMessages.length, + "chat.trigger": currentWirePayload.trigger, + "chat.continuation": continuation, + "chat.preloaded": preloaded, + ...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}), + }, + } + ); + } + + // chat.requestUpgrade() called in onTurnStart (or onValidateMessages) — + // skip run() and signal the transport to re-trigger the same message + // on the new version. + if (locals.get(chatUpgradeRequestedKey)) { + await writeUpgradeRequiredChunk(); + return "exit"; + } + + // Captured by the onFinish callback below — works even on abort/stop. + let capturedResponseMessage: TUIMessage | undefined; + let capturedFinishReason: FinishReason | undefined; + + // Promise that resolves when the AI SDK's onFinish fires. + // On abort, the stream's cancel() handler calls onFinish + // asynchronously AFTER pipeChat resolves, so we must await + // this to avoid a race where we check capturedResponseMessage + // before it's been set. + let resolveOnFinish: () => void; + const onFinishPromise = new Promise((r) => { + resolveOnFinish = r; + }); + let onFinishAttached = false; + let runResult: unknown; + + // Pure-text head-start: customer's step 1 IS the + // final response. Skip the user's `run` callback + // (no LLM call) and use the synthesized partial + // UIMessage as `capturedResponseMessage`. The post- + // turn flow (`onBeforeTurnComplete` → + // `onTurnComplete` → trigger:turn-complete) fires + // normally so persistence works. + const headStartIsFinal = locals.get(chatHandoverIsFinalKey); + const isHeadStartFinalTurn = turn === 0 && headStartIsFinal === true; + if (isHeadStartFinalTurn) { + locals.set(chatHandoverIsFinalKey, undefined); // consume once + } + + try { + // Drain any messages injected by background work (e.g. self-review from previous turn). + // Skip if the last message is a tool message — appending after it would + // prevent streamText from finding pending tool approvals (it checks + // the last message). The queued messages will be picked up by prepareStep + // at the next step boundary instead. + const lastAccumulated = accumulatedMessages[accumulatedMessages.length - 1]; + const bgQueue = locals.get(chatBackgroundQueueKey); + if (bgQueue && bgQueue.length > 0 && lastAccumulated?.role !== "tool") { + accumulatedMessages.push(...bgQueue.splice(0)); + } + + if (isHeadStartFinalTurn) { + // The synthesized partial UIMessage IS the response. + // It was pushed to `accumulatedUIMessages` during the + // submit-message branch's splice; recover it as the + // last assistant. + const lastUI = accumulatedUIMessages[accumulatedUIMessages.length - 1]; + if (lastUI && lastUI.role === "assistant") { + capturedResponseMessage = lastUI; + capturedFinishReason = "stop"; + } + // Don't call userRun. Don't pipe. Skip directly + // to the post-turn flow below. + } else { + const preparedMessages = await applyPrepareMessages(accumulatedMessages, "run"); + runResult = await userRun({ + ...restWire, + messages: preparedMessages, + clientData, + continuation, + previousRunId, + preloaded, + previousTurnUsage, + totalUsage: cumulativeUsage, + ctx, + signal: combinedSignal, + cancelSignal, + stopSignal, + } as any); + } + + // Auto-pipe if the run function returned a StreamTextResult or similar, + // but only if pipeChat() wasn't already called manually during this turn. + // We call toUIMessageStream ourselves to attach onFinish for response capture. + // Pass originalMessages so the AI SDK reuses message IDs across turns + // (e.g. for tool approval continuations / HITL flows). + if ((locals.get(chatPipeCountKey) ?? 0) === 0 && isUIMessageStreamable(runResult)) { + onFinishAttached = true; + const resolvedOptions = resolveUIMessageStreamOptions(); + // For action turns, don't pass originalMessages: the response + // should always be a fresh assistant message, not a continuation + // of whatever trailing assistant was left after chat.history + // mutations. + const isActionTurn = currentWirePayload.trigger === "action"; + const uiStream = runResult.toUIMessageStream({ + ...resolvedOptions, + // Pass originalMessages so the AI SDK reuses message IDs across + // turns (e.g. for tool approval continuations / HITL flows). + // Omit for action turns to force a fresh response ID. + ...(isActionTurn + ? {} + : { originalMessages: accumulatedUIMessages }), + // Always provide generateMessageId so the start chunk carries a + // messageId. Without this, the frontend and backend generate IDs + // independently and they won't match for ID-based dedup. + generateMessageId: resolvedOptions.generateMessageId ?? generateMessageId, + onFinish: ({ + responseMessage, + finishReason, + }: { + responseMessage: UIMessage; + finishReason?: FinishReason; + }) => { + capturedResponseMessage = responseMessage as TUIMessage; + capturedFinishReason = finishReason; + resolveOnFinish!(); + }, + }); + await pipeChat(uiStream, { signal: combinedSignal, spanName: "stream response" }); + } + } catch (error) { + // Handle AbortError from streamText gracefully + if (error instanceof Error && error.name === "AbortError") { + if (runSignal.aborted) { + return "exit"; // Full run cancellation — exit + } + // Stop generation — fall through to continue the loop + } else { + throw error; + } + } finally { + msgSub.off(); + } + + // Wait for onFinish to fire — on abort this may resolve slightly + // after pipeChat, since the stream's cancel() handler is async. + // Race with a timeout so a stop-abort that prevents onFinish from + // firing doesn't hang the turn loop indefinitely. + if (onFinishAttached) { + await Promise.race([ + onFinishPromise, + new Promise((r) => setTimeout(r, 2_000)), + ]); + } + + // Capture token usage from the streamText result (if available). + // totalUsage is a PromiseLike that resolves after the stream is consumed. + // Race with a 2s timeout — on stop-abort the AI SDK's totalUsage + // promise can hang indefinitely (the underlying provider stream + // never reports final usage), which would block the turn loop + // from ever firing onTurnComplete / writeTurnComplete. + let turnUsage: LanguageModelUsage | undefined; + if (runResult != null && typeof (runResult as any).totalUsage?.then === "function") { + try { + turnUsage = (await Promise.race([ + (runResult as any).totalUsage, + new Promise((r) => setTimeout(() => r(undefined), 2_000)), + ])) as LanguageModelUsage | undefined; + } catch { + /* non-fatal — usage capture failed */ + } + } + if (turnUsage) { + cumulativeUsage = addUsage(cumulativeUsage, turnUsage); + previousTurnUsage = turnUsage; + + // Add usage attributes to the turn span + if (turnUsage.inputTokens != null) { + turnSpan.setAttribute("gen_ai.usage.input_tokens", turnUsage.inputTokens); + } + if (turnUsage.outputTokens != null) { + turnSpan.setAttribute("gen_ai.usage.output_tokens", turnUsage.outputTokens); + } + if (turnUsage.totalTokens != null) { + turnSpan.setAttribute("gen_ai.usage.total_tokens", turnUsage.totalTokens); + } + if (cumulativeUsage.totalTokens != null) { + turnSpan.setAttribute( + "gen_ai.usage.cumulative_total_tokens", + cumulativeUsage.totalTokens + ); + } + if (cumulativeUsage.inputTokens != null) { + turnSpan.setAttribute( + "gen_ai.usage.cumulative_input_tokens", + cumulativeUsage.inputTokens + ); + } + if (cumulativeUsage.outputTokens != null) { + turnSpan.setAttribute( + "gen_ai.usage.cumulative_output_tokens", + cumulativeUsage.outputTokens + ); + } + } + + // Check if run() (e.g. via prepareStep or chat.history) replaced messages + // during this turn. The updated messages become the new base, and the + // response gets appended on top. + const runOverride = locals.get(chatOverrideMessagesKey); + if (runOverride) { + locals.set(chatOverrideMessagesKey, undefined); + accumulatedUIMessages = [...runOverride] as TUIMessage[]; + accumulatedMessages = await toModelMessages(runOverride); + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + } + + // Check if compaction set a model-only override (preserves UI messages). + // Apply compactUIMessages/compactModelMessages callbacks if configured. + const modelOnlyOverride = locals.get(chatOverrideModelMessagesKey); + if (modelOnlyOverride) { + const compactionSummary = locals.get(chatCompactionStateKey)?.summary ?? ""; + const taskCompactionConfig = locals.get(chatAgentCompactionKey); + locals.set(chatOverrideModelMessagesKey, undefined); + + const compactEvent: CompactMessagesEvent = { + summary: compactionSummary, + uiMessages: accumulatedUIMessages, + modelMessages: accumulatedMessages, + chatId: currentWirePayload.chatId, + turn, + clientData, + source: "inner", + }; + + // Apply model messages: callback or default (use override) + accumulatedMessages = taskCompactionConfig?.compactModelMessages + ? await taskCompactionConfig.compactModelMessages(compactEvent) + : modelOnlyOverride; + + // Apply UI messages: callback or default (preserve all) + if (taskCompactionConfig?.compactUIMessages) { + accumulatedUIMessages = (await taskCompactionConfig.compactUIMessages( + compactEvent + )) as TUIMessage[]; + } + } + + // Determine if the user stopped generation this turn (not a full run cancel). + const wasStopped = stopController.signal.aborted && !runSignal.aborted; + + // Append the assistant's response (partial or complete) to the accumulator. + // The onFinish callback fires even on abort/stop, so partial responses + // from stopped generation are captured correctly. + let rawResponseMessage: TUIMessage | undefined; + if (capturedResponseMessage) { + // Keep the raw message before cleanup for users who want custom handling + rawResponseMessage = capturedResponseMessage; + // Clean up aborted parts (streaming tool calls, reasoning) when stopped + if (wasStopped) { + capturedResponseMessage = cleanupAbortedParts(capturedResponseMessage); + } + // Ensure the response message has an ID (the stream's onFinish + // may produce a message with an empty ID since IDs are normally + // assigned by the frontend's useChat). + if (!capturedResponseMessage.id) { + capturedResponseMessage = { ...capturedResponseMessage, id: generateMessageId() }; + } + // Append any non-transient data parts queued via chat.response or writer.write() + const queuedParts = locals.get(chatResponsePartsKey); + if (queuedParts && queuedParts.length > 0) { + capturedResponseMessage = { + ...capturedResponseMessage, + parts: [...capturedResponseMessage.parts, ...queuedParts], + } as TUIMessage; + locals.set(chatResponsePartsKey, []); + } + // Tool-approval continuations: the AI SDK reuses the trailing + // assistant's ID (via originalMessages) so the captured response + // carries the same ID as an existing message. Replace in place + // instead of pushing a duplicate. For action turns this never + // matches because originalMessages is omitted (fresh ID). + const existingIdx = capturedResponseMessage.id + ? accumulatedUIMessages.findIndex( + (m) => m.id === capturedResponseMessage!.id + ) + : -1; + if (existingIdx !== -1) { + accumulatedUIMessages[existingIdx] = capturedResponseMessage; + } else { + accumulatedUIMessages.push(capturedResponseMessage); + } + turnNewUIMessages.push(capturedResponseMessage); + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + // Record toolCallId → head messageId so a HITL + // continuation next turn can recover the head id + // even if the AI SDK regenerates it. See + // `chatToolCallToMessageIdKey` for the full + // rationale (TRI-9137). + recordToolCallIdsFromMessage(capturedResponseMessage); + try { + const responseModelMessages = await toModelMessages([ + stripProviderMetadata(capturedResponseMessage), + ]); + if (existingIdx !== -1) { + // Reconvert all model messages since we replaced rather than appended + accumulatedMessages = await toModelMessages(accumulatedUIMessages); + } else { + accumulatedMessages.push(...responseModelMessages); + } + turnNewModelMessages.push(...responseModelMessages); + } catch { + // Conversion failed — skip accumulation for this turn + } + } + // If there's no captured response (manual pipe mode) but there are + // queued data parts, create a minimal response message to hold them. + if (!capturedResponseMessage) { + const remainingParts = locals.get(chatResponsePartsKey); + if (remainingParts && remainingParts.length > 0) { + capturedResponseMessage = { + id: generateMessageId(), + role: "assistant" as const, + parts: [...remainingParts], + } as TUIMessage; + locals.set(chatResponsePartsKey, []); + accumulatedUIMessages.push(capturedResponseMessage); + turnNewUIMessages.push(capturedResponseMessage); + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + } + } + + if (runSignal.aborted) return "exit"; + + // Await deferred background work (e.g. DB writes from onTurnStart) + // before firing hooks so they can rely on the work being done. + const deferredWork = locals.get(chatDeferKey); + if (deferredWork && deferredWork.size > 0) { + await Promise.race([ + Promise.allSettled(deferredWork), + new Promise((r) => setTimeout(r, 5_000)), + ]); + } + + // Outer-loop compaction: runs between turns for single-step responses + // where prepareStep never fires (no tool calls = no step boundaries). + // Only triggers when: task has compaction configured, prepareStep didn't + // already compact this turn, and shouldCompact returns true. + const outerCompaction = locals.get(chatAgentCompactionKey); + const innerCompactionState = locals.get(chatCompactionStateKey); + + if (outerCompaction && !innerCompactionState && turnUsage && !wasStopped) { + const shouldTrigger = await outerCompaction.shouldCompact({ + messages: accumulatedMessages, + totalTokens: turnUsage.totalTokens, + inputTokens: turnUsage.inputTokens, + outputTokens: turnUsage.outputTokens, + usage: turnUsage, + totalUsage: cumulativeUsage, + chatId: currentWirePayload.chatId, + turn, + clientData, + source: "outer", + }); + + if (shouldTrigger) { + await tracer.startActiveSpan( + "context compaction (outer loop)", + async (compactionSpan) => { + const compactionId = generateMessageId(); + + const { waitUntilComplete } = chatStream.writer({ + spanName: "stream compaction chunks", + collapsed: true, + execute: async ({ write, merge }) => { + write({ + type: "data-compaction", + id: compactionId, + data: { status: "compacting", totalTokens: turnUsage.totalTokens }, + transient: true, + }); + + const summary = await outerCompaction.summarize({ + messages: accumulatedMessages, + usage: turnUsage, + totalUsage: cumulativeUsage, + chatId: currentWirePayload.chatId, + turn, + clientData, + source: "outer", + }); + + // Apply compactModelMessages/compactUIMessages callbacks, or defaults. + + const outerCompactEvent: CompactMessagesEvent = { + summary, + uiMessages: accumulatedUIMessages, + modelMessages: accumulatedMessages, + chatId: currentWirePayload.chatId, + turn, + clientData, + source: "outer", + }; + + // Model messages: callback or default (replace with summary) + accumulatedMessages = outerCompaction.compactModelMessages + ? await outerCompaction.compactModelMessages(outerCompactEvent) + : [ + { + role: "assistant" as const, + content: [ + { + type: "text" as const, + text: `[Conversation summary]\n\n${summary}`, + }, + ], + }, + ]; + + // UI messages: callback or default (preserve all) + if (outerCompaction.compactUIMessages) { + accumulatedUIMessages = (await outerCompaction.compactUIMessages( + outerCompactEvent + )) as TUIMessage[]; + } + + // Fire onCompacted hook + const onCompactedHook = locals.get(chatOnCompactedKey); + if (onCompactedHook) { + await onCompactedHook({ + ctx, + summary, + messages: accumulatedMessages, + messageCount: accumulatedMessages.length, + usage: turnUsage, + totalTokens: turnUsage.totalTokens, + inputTokens: turnUsage.inputTokens, + outputTokens: turnUsage.outputTokens, + stepNumber: -1, // outer loop, not a step + chatId: currentWirePayload.chatId, + turn, + writer: { write, merge }, + }); + } + + compactionSpan.setAttribute("compaction.summary_length", summary.length); + + write({ + type: "data-compaction", + id: compactionId, + data: { status: "complete", totalTokens: turnUsage.totalTokens }, + transient: true, + }); + }, + }); + await waitUntilComplete(); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "tabler-scissors", + "compaction.total_tokens": turnUsage.totalTokens ?? 0, + "compaction.input_tokens": turnUsage.inputTokens ?? 0, + "compaction.message_count": accumulatedMessages.length, + "compaction.outer_loop": true, + "compaction.turn": turn, + ...(currentWirePayload.chatId + ? { "compaction.chat_id": currentWirePayload.chatId } + : {}), + ...accessoryAttributes({ + items: [ + { text: `${turnUsage.totalTokens ?? 0} tokens`, variant: "normal" }, + { text: `${accumulatedMessages.length} msgs`, variant: "normal" }, + { text: "outer loop", variant: "normal" }, + ], + style: "codepath", + }), + }, + } + ); + } + } + + const turnCompleteEvent = { + ctx, + chatId: currentWirePayload.chatId, + messages: accumulatedMessages, + uiMessages: accumulatedUIMessages, + newMessages: turnNewModelMessages, + newUIMessages: turnNewUIMessages, + responseMessage: capturedResponseMessage, + rawResponseMessage, + turn, + runId: currentRunId, + chatAccessToken: turnAccessToken, + clientData, + stopped: wasStopped, + continuation, + previousRunId, + preloaded, + usage: turnUsage, + totalUsage: cumulativeUsage, + finishReason: capturedFinishReason, + }; + + // Fire onBeforeTurnComplete — stream is still open so the hook + // can write custom chunks to the frontend (e.g. compaction progress). + if (onBeforeTurnComplete) { + await tracer.startActiveSpan( + "onBeforeTurnComplete()", + async () => { + await withChatWriter(async (writer) => { + await onBeforeTurnComplete({ ...turnCompleteEvent, writer }); + }); + + // Check if the hook replaced messages (compaction or chat.history) + const override = locals.get(chatOverrideMessagesKey); + if (override) { + locals.set(chatOverrideMessagesKey, undefined); + accumulatedUIMessages = [...override] as TUIMessage[]; + accumulatedMessages = await toModelMessages(override); + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + // Update event so onTurnComplete sees compacted messages + turnCompleteEvent.messages = accumulatedMessages; + turnCompleteEvent.uiMessages = accumulatedUIMessages; + } + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": currentWirePayload.chatId, + "chat.turn": turn + 1, + }, + } + ); + } + + // Drain any late response parts added during onBeforeTurnComplete + const lateParts = locals.get(chatResponsePartsKey); + if (lateParts && lateParts.length > 0 && capturedResponseMessage) { + const idx = accumulatedUIMessages.findIndex((m) => m.id === capturedResponseMessage!.id); + if (idx !== -1) { + const msg = accumulatedUIMessages[idx]!; + accumulatedUIMessages[idx] = { + ...msg, + parts: [...(msg.parts ?? []), ...lateParts], + } as TUIMessage; + capturedResponseMessage = accumulatedUIMessages[idx] as TUIMessage; + turnCompleteEvent.responseMessage = capturedResponseMessage; + turnCompleteEvent.uiMessages = accumulatedUIMessages; + } + locals.set(chatResponsePartsKey, []); + } + + // Write turn-complete control chunk — closes the frontend stream. + const turnCompleteResult = await writeTurnCompleteChunk( + currentWirePayload.chatId, + turnAccessToken + ); + + // Fire onTurnComplete — stream is closed, use for persistence. + if (onTurnComplete) { + await tracer.startActiveSpan( + "onTurnComplete()", + async () => { + await onTurnComplete({ + ...turnCompleteEvent, + lastEventId: turnCompleteResult?.lastEventId, + }); + + // Check if onTurnComplete replaced messages (compaction or chat.history) + const turnCompleteOverride = locals.get(chatOverrideMessagesKey); + if (turnCompleteOverride) { + locals.set(chatOverrideMessagesKey, undefined); + accumulatedUIMessages = [...turnCompleteOverride] as TUIMessage[]; + accumulatedMessages = await toModelMessages(turnCompleteOverride); + locals.set(chatCurrentUIMessagesKey, accumulatedUIMessages); + } + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": currentWirePayload.chatId, + "chat.turn": turn + 1, + "chat.stopped": wasStopped, + "chat.continuation": continuation, + "chat.preloaded": preloaded, + ...(previousRunId ? { "chat.previous_run_id": previousRunId } : {}), + "chat.messages.count": accumulatedMessages.length, + "chat.response.parts.count": capturedResponseMessage?.parts?.length ?? 0, + "chat.new_messages.count": turnNewUIMessages.length, + ...(turnUsage?.inputTokens != null + ? { "gen_ai.usage.input_tokens": turnUsage.inputTokens } + : {}), + ...(turnUsage?.outputTokens != null + ? { "gen_ai.usage.output_tokens": turnUsage.outputTokens } + : {}), + ...(turnUsage?.totalTokens != null + ? { "gen_ai.usage.total_tokens": turnUsage.totalTokens } + : {}), + ...(cumulativeUsage.totalTokens != null + ? { "gen_ai.usage.cumulative_total_tokens": cumulativeUsage.totalTokens } + : {}), + }, + } + ); + } + + // ── Snapshot write ───────────────────────────────────── + // + // Persist the post-turn accumulator so the next run boot + // can replay history without the wire shipping it. Skipped + // when `hydrateMessages` is registered — those customers + // own persistence and would never read this blob. + // + // AWAITED, not fire-and-forget: the agent may suspend (idle + // timeout) immediately after this point, and in-flight + // promises don't reliably complete on suspend. The user- + // visible turn already finished (the turn-complete chunk + // closed the response stream above), so the await delay + // only affects "when can the NEXT turn start," gated by + // user typing — not TTFC. + // + // `writeChatSnapshot` swallows errors internally; this + // outer try/catch is just belt-and-suspenders against + // tracer/span failures. + if (!hydrateMessages) { + try { + await tracer.startActiveSpan( + "snapshot.write", + async () => { + await writeChatSnapshot(sessionIdForSnapshot, { + version: 1, + savedAt: Date.now(), + messages: accumulatedUIMessages, + // `StreamWriteResult` exposes `lastEventId` only; + // use the snapshot save time as the + // `lastOutTimestamp` cutoff hint. The OOM-retry + // optimization compares this to SSE chunk + // timestamps (ms epoch on the server) — Date.now() + // here is the closest cheap approximation + // available client-side and is consistent with + // the existing turn-complete chunk emission. + lastOutEventId: turnCompleteResult?.lastEventId, + lastOutTimestamp: Date.now(), + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": currentWirePayload.chatId, + "chat.turn": turn + 1, + "chat.messages.count": accumulatedUIMessages.length, + }, + } + ); + } catch (error) { + logger.warn( + "chat.agent: snapshot write failed; next run will replay further", + { + error: error instanceof Error ? error.message : String(error), + sessionId: sessionIdForSnapshot, + } + ); + } + } + + } // end if (!isAction) + + // NOTE: We intentionally do NOT await deferred work from onTurnComplete here. + // Promises deferred in onTurnComplete (e.g. background self-review via + // chat.defer + chat.inject) run during the idle wait. If they complete + // before the next message, their injected context is picked up in prepareStep. + // The pre-onBeforeTurnComplete drain handles promises from onTurnStart/run(). + + // If messages arrived during streaming (without pendingMessages config), + // use the first one immediately as the next turn. + if (pendingMessages.length > 0) { + currentWirePayload = pendingMessages[0]!; + return "continue"; + } + + // chat.requestUpgrade() was called — exit the loop so the + // transport triggers a new run on the latest version. + // chat.endRun() — same exit, no upgrade semantics. + if ( + locals.get(chatUpgradeRequestedKey) || + locals.get(chatEndRunRequestedKey) + ) { + return "exit"; + } + + // Wait for the next message — stay idle briefly, then suspend + const effectiveIdleTimeout = + (metadata.get(IDLE_TIMEOUT_METADATA_KEY) as number | undefined) ?? + idleTimeoutInSeconds; + const effectiveTurnTimeout = + (metadata.get(TURN_TIMEOUT_METADATA_KEY) as string | undefined) ?? turnTimeout; + + const next = await messagesInput.waitWithIdleTimeout({ + idleTimeoutInSeconds: effectiveIdleTimeout, + timeout: effectiveTurnTimeout, + spanName: "waiting for next message", + onSuspend: onChatSuspend + ? async () => { + await tracer.startActiveSpan( + "onChatSuspend()", + async () => { + await onChatSuspend({ + phase: "turn", + ctx, + chatId: currentWirePayload.chatId, + runId: ctx.run.id, + turn, + messages: accumulatedMessages, + uiMessages: accumulatedUIMessages, + clientData, + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onComplete", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": currentWirePayload.chatId, + "chat.suspend.phase": "turn", + "chat.turn": turn + 1, + }, + } + ); + } + : undefined, + onResume: onChatResume + ? async () => { + await tracer.startActiveSpan( + "onChatResume()", + async () => { + await onChatResume({ + phase: "turn", + ctx, + chatId: currentWirePayload.chatId, + runId: ctx.run.id, + turn, + messages: accumulatedMessages, + uiMessages: accumulatedUIMessages, + clientData, + }); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "task-hook-onStart", + [SemanticInternalAttributes.COLLAPSED]: true, + "chat.id": currentWirePayload.chatId, + "chat.resume.phase": "turn", + "chat.turn": turn + 1, + }, + } + ); + } + : undefined, + }); + + if (!next.ok) { + return "exit"; + } + + currentWirePayload = next.output as ChatTaskWirePayload< + TUIMessage, + inferSchemaIn + >; + + // Close signal — exit the loop gracefully + if (currentWirePayload.trigger === "close") { + return "exit"; + } + + return "continue"; + }, + { + attributes: turnAttributes, + } + ); + + if (turnResult === "exit") return; + // "continue" means proceed to next iteration + } catch (turnError) { + // Turn error handler: write an error chunk + turn-complete to the stream + // so the client sees the error, then wait for the next message instead + // of killing the entire run. This keeps the conversation alive. + if (turnError instanceof Error && turnError.name === "AbortError" && runSignal.aborted) { + // Full run cancellation — exit immediately + throw turnError; + } + + // OOM errors must escape the turn loop so the task runtime can + // honor `retry.outOfMemory.machine` (set on chat.agent via + // `oomMachine`). Catching them here would keep the dead worker + // alive and defeat the machine swap. Re-throw and let the + // runtime dispatch the retry on a larger machine; recovery on + // attempt 2 picks up via the standard continuation path + // (same chatId / Session, accumulator rehydrates). + if (turnError instanceof OutOfMemoryError) { + throw turnError; + } + + try { + await withChatWriter(async (writer) => { + const errorText = + turnError instanceof Error ? turnError.message : "An unexpected error occurred"; + writer.write({ type: "error", errorText } as any); + }); + // Signal turn complete so the client knows this turn is done + await writeTurnCompleteChunk(currentWirePayload.chatId); + } catch { + // Best-effort — if stream write fails, let the run continue anyway + } + + // chat.requestUpgrade() / chat.endRun() — exit after error turn too + if ( + locals.get(chatUpgradeRequestedKey) || + locals.get(chatEndRunRequestedKey) + ) { + return; + } + + // Wait for the next message — same as after a successful turn + const effectiveIdleTimeout = + (metadata.get(IDLE_TIMEOUT_METADATA_KEY) as number | undefined) ?? + idleTimeoutInSeconds; + const effectiveTurnTimeout = + (metadata.get(TURN_TIMEOUT_METADATA_KEY) as string | undefined) ?? turnTimeout; + + const next = await messagesInput.waitWithIdleTimeout({ + idleTimeoutInSeconds: effectiveIdleTimeout, + timeout: effectiveTurnTimeout, + spanName: "waiting for next message (after error)", + }); + + if (!next.ok) { + return; // Timed out — end run gracefully + } + + currentWirePayload = next.output as ChatTaskWirePayload< + TUIMessage, + inferSchemaIn + >; + // Continue to next iteration of the for loop + } + } + } finally { + // `stopSub` is registered post-preload so the close-during-preload + // early-return path may exit before it ever attached. Guard the + // cleanup so a missing subscription doesn't throw. + stopSub?.off(); + } + } + }); + + // Register clientDataSchema so the CLI converts it to JSONSchema + // and stores it as payloadSchema — used by the Playground UI + if (clientDataSchema) { + resourceCatalog.updateTaskMetadata(options.id, { + schema: clientDataSchema as any, + }); + } + + return task; +} + +/** + * Optional config for {@link chat.withUIMessage}. `streamOptions` become default + * static `toUIMessageStream()` settings; inner `chat.agent({ uiMessageStreamOptions })` + * shallow-merges on top (task wins on conflicts). + */ +export type ChatWithUIMessageConfig = { + streamOptions?: ChatUIMessageStreamOptions; +}; + +// --------------------------------------------------------------------------- +// Chat builder +// --------------------------------------------------------------------------- + +/** + * A chainable builder for configuring chat tasks with fixed UI message types, + * client data schemas, and builder-level hooks that compose with task-level hooks. + * + * Obtain a builder via {@link chat.withUIMessage} or {@link chat.withClientData}. + * + * @example + * ```ts + * export const myChat = chat + * .withUIMessage({ streamOptions: { sendReasoning: true } }) + * .withClientData({ schema: z.object({ userId: z.string() }) }) + * .onChatSuspend(async ({ ctx }) => { await disposeResources(ctx.run.id) }) + * .task({ + * id: "my-chat", + * run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), + * }); + * ``` + */ +export interface ChatBuilder< + TUIMessage extends UIMessage = UIMessage, + TClientDataSchema extends TaskSchema | undefined = undefined, +> { + /** Fix the UI message type. Returns a new builder preserving all accumulated state. */ + withUIMessage( + config?: ChatWithUIMessageConfig + ): ChatBuilder; + + /** Fix the client data schema. Returns a new builder preserving all accumulated state. */ + withClientData(config: { + schema: TSchema; + }): ChatBuilder; + + /** Register a builder-level `onBoot` hook. Runs before the task-level hook if both are set. */ + onBoot( + fn: (event: BootEvent>) => Promise | void + ): ChatBuilder; + + /** Register a builder-level `onPreload` hook. Runs before the task-level hook if both are set. */ + onPreload( + fn: (event: PreloadEvent>) => Promise | void + ): ChatBuilder; + + /** Register a builder-level `onChatStart` hook. Runs before the task-level hook if both are set. */ + onChatStart( + fn: (event: ChatStartEvent>) => Promise | void + ): ChatBuilder; + + /** Register a builder-level `onTurnStart` hook. Runs before the task-level hook if both are set. */ + onTurnStart( + fn: ( + event: TurnStartEvent, TUIMessage> + ) => Promise | void + ): ChatBuilder; + + /** Register a builder-level `onBeforeTurnComplete` hook. Runs before the task-level hook if both are set. */ + onBeforeTurnComplete( + fn: ( + event: BeforeTurnCompleteEvent, TUIMessage> + ) => Promise | void + ): ChatBuilder; + + /** Register a builder-level `onTurnComplete` hook. Runs before the task-level hook if both are set. */ + onTurnComplete( + fn: ( + event: TurnCompleteEvent, TUIMessage> + ) => Promise | void + ): ChatBuilder; + + /** Register a builder-level `onCompacted` hook. Runs before the task-level hook if both are set. */ + onCompacted(fn: (event: CompactedEvent) => Promise | void): ChatBuilder; + + /** Register a builder-level `onChatSuspend` hook. Runs before the task-level hook if both are set. */ + onChatSuspend( + fn: ( + event: ChatSuspendEvent, TUIMessage> + ) => Promise | void + ): ChatBuilder; + + /** Register a builder-level `onChatResume` hook. Runs before the task-level hook if both are set. */ + onChatResume( + fn: ( + event: ChatResumeEvent, TUIMessage> + ) => Promise | void + ): ChatBuilder; + + /** + * Create the chat agent with the accumulated builder configuration. + * + * When `withClientData` was called, `clientDataSchema` is injected automatically + * and omitted from options. Otherwise, it can still be set directly in options + * (backwards compatible). + */ + agent: [TClientDataSchema] extends [undefined] + ? ( + options: ChatAgentOptions + ) => Task>, unknown> + : ( + options: Omit, "clientDataSchema"> + ) => Task>, unknown>; + + /** + * Create a custom agent with manual lifecycle control. + * + * The agent appears in the playground but you manage the turn loop, + * message waiting, and streaming yourself using composable primitives + * (`chat.messages`, `chat.MessageAccumulator`, `chat.pipeAndCapture`, etc.). + * + * Builder hooks (`onPreload`, `onChatStart`, etc.) are not applied — + * those are managed-lifecycle concepts handled by `.agent()`. + */ + customAgent: [TClientDataSchema] extends [undefined] + ? ( + options: ChatCustomAgentOptions + ) => Task, unknown> + : ( + options: ChatCustomAgentOptions + ) => Task>, unknown>; +} + +/** @internal */ +type ChatBuilderHooks = { + onBoot?: (event: any) => Promise | void; + onPreload?: (event: any) => Promise | void; + onChatStart?: (event: any) => Promise | void; + onTurnStart?: (event: any) => Promise | void; + onBeforeTurnComplete?: (event: any) => Promise | void; + onTurnComplete?: (event: any) => Promise | void; + onCompacted?: (event: any) => Promise | void; + onChatSuspend?: (event: any) => Promise | void; + onChatResume?: (event: any) => Promise | void; +}; + +/** @internal */ +type ChatBuilderConfig = { + uiStreamOptions?: ChatUIMessageStreamOptions; + clientDataSchema?: TaskSchema; + hooks: ChatBuilderHooks; +}; + +function composeHooks( + builderHook: ((event: T) => Promise | void) | undefined, + taskHook: ((event: T) => Promise | void) | undefined +): ((event: T) => Promise) | undefined { + if (!builderHook) return taskHook as any; + if (!taskHook) return builderHook as any; + return async (event: T) => { + await builderHook(event); + await taskHook(event); + }; +} + +function createChatBuilder< + TUIMessage extends UIMessage = UIMessage, + TClientDataSchema extends TaskSchema | undefined = undefined, +>(config: ChatBuilderConfig): ChatBuilder { + return { + withUIMessage(uimConfig?: ChatWithUIMessageConfig) { + return createChatBuilder({ + ...config, + uiStreamOptions: uimConfig?.streamOptions ?? config.uiStreamOptions, + }); + }, + + withClientData(cdConfig: { schema: TSchema }) { + return createChatBuilder({ + ...config, + clientDataSchema: cdConfig.schema, + }); + }, + + onBoot( + fn: (event: BootEvent>) => Promise | void + ) { + return createChatBuilder({ + ...config, + hooks: { ...config.hooks, onBoot: fn }, + }); + }, + onPreload( + fn: (event: PreloadEvent>) => Promise | void + ) { + return createChatBuilder({ + ...config, + hooks: { ...config.hooks, onPreload: fn }, + }); + }, + onChatStart( + fn: (event: ChatStartEvent>) => Promise | void + ) { + return createChatBuilder({ + ...config, + hooks: { ...config.hooks, onChatStart: fn }, + }); + }, + onTurnStart( + fn: ( + event: TurnStartEvent, TUIMessage> + ) => Promise | void + ) { + return createChatBuilder({ + ...config, + hooks: { ...config.hooks, onTurnStart: fn }, + }); + }, + onBeforeTurnComplete( + fn: ( + event: BeforeTurnCompleteEvent, TUIMessage> + ) => Promise | void + ) { + return createChatBuilder({ + ...config, + hooks: { ...config.hooks, onBeforeTurnComplete: fn }, + }); + }, + onTurnComplete( + fn: ( + event: TurnCompleteEvent, TUIMessage> + ) => Promise | void + ) { + return createChatBuilder({ + ...config, + hooks: { ...config.hooks, onTurnComplete: fn }, + }); + }, + onCompacted(fn: (event: CompactedEvent) => Promise | void) { + return createChatBuilder({ + ...config, + hooks: { ...config.hooks, onCompacted: fn }, + }); + }, + onChatSuspend( + fn: ( + event: ChatSuspendEvent, TUIMessage> + ) => Promise | void + ) { + return createChatBuilder({ + ...config, + hooks: { ...config.hooks, onChatSuspend: fn }, + }); + }, + onChatResume( + fn: ( + event: ChatResumeEvent, TUIMessage> + ) => Promise | void + ) { + return createChatBuilder({ + ...config, + hooks: { ...config.hooks, onChatResume: fn }, + }); + }, + + agent(options: any) { + const mergedUiStream = + config.uiStreamOptions && options.uiMessageStreamOptions + ? { ...config.uiStreamOptions, ...options.uiMessageStreamOptions } + : options.uiMessageStreamOptions ?? config.uiStreamOptions; + + return chatAgent({ + ...options, + ...(config.clientDataSchema ? { clientDataSchema: config.clientDataSchema } : {}), + uiMessageStreamOptions: mergedUiStream, + onBoot: composeHooks(config.hooks.onBoot, options.onBoot), + onPreload: composeHooks(config.hooks.onPreload, options.onPreload), + onChatStart: composeHooks(config.hooks.onChatStart, options.onChatStart), + onTurnStart: composeHooks(config.hooks.onTurnStart, options.onTurnStart), + onBeforeTurnComplete: composeHooks( + config.hooks.onBeforeTurnComplete, + options.onBeforeTurnComplete + ), + onTurnComplete: composeHooks(config.hooks.onTurnComplete, options.onTurnComplete), + onCompacted: composeHooks(config.hooks.onCompacted, options.onCompacted), + onChatSuspend: composeHooks(config.hooks.onChatSuspend, options.onChatSuspend), + onChatResume: composeHooks(config.hooks.onChatResume, options.onChatResume), + }); + }, + + customAgent(options: any) { + return chatCustomAgent({ + ...options, + ...(config.clientDataSchema ? { clientDataSchema: config.clientDataSchema } : {}), + }); + }, + } as unknown as ChatBuilder; +} + +/** + * Fix the UI message type for a chat task (AI SDK `UIMessage` generics) while + * keeping `id` and `clientDataSchema` inference on the inner {@link chat.agent} call. + * + * Returns a {@link ChatBuilder} that supports chaining `.withClientData()`, + * hook methods (`.onPreload()`, `.onChatSuspend()`, etc.), and `.task()`. + * + * @example + * ```ts + * type AgentUiMessage = UIMessage; + * + * export const myChat = chat.withUIMessage({ + * streamOptions: { sendReasoning: true }, + * }).task({ + * id: "my-chat", + * run: async ({ messages, signal }) => { ... }, + * }); + * ``` + */ +function withUIMessage( + config?: ChatWithUIMessageConfig +): ChatBuilder { + return createChatBuilder({ + uiStreamOptions: config?.streamOptions, + hooks: {}, + }); +} + +/** + * Fix the client data schema for a chat task, providing typed `clientData` + * in all hooks and the `run` function. + * + * Returns a {@link ChatBuilder} that supports chaining `.withUIMessage()`, + * hook methods (`.onPreload()`, `.onChatSuspend()`, etc.), and `.task()`. + * + * @example + * ```ts + * export const myChat = chat + * .withClientData({ schema: z.object({ userId: z.string() }) }) + * .task({ + * id: "my-chat", + * onPreload: async ({ clientData }) => { + * // clientData is typed as { userId: string } + * }, + * run: async ({ messages, signal }) => { ... }, + * }); + * ``` + */ +function withClientData(config: { + schema: TSchema; +}): ChatBuilder { + return createChatBuilder({ + clientDataSchema: config.schema, + hooks: {}, + }); +} + +/** + * Namespace for AI SDK chat integration. + * + * @example + * ```ts + * import { chat } from "@trigger.dev/sdk/ai"; + * + * // Define a chat task + * export const myChat = chat.agent({ + * id: "my-chat", + * run: async ({ messages, signal }) => { + * return streamText({ model, messages, abortSignal: signal }); + * }, + * }); + * + * // Pipe a stream manually (from inside a task) + * await chat.pipe(streamTextResult); + * + * // Create an access token (from a server action) + * const token = await chat.createAccessToken("my-chat"); + * ``` + */ +// --------------------------------------------------------------------------- +// Runtime configuration helpers +// --------------------------------------------------------------------------- + +const TURN_TIMEOUT_METADATA_KEY = "chat.turnTimeout"; +const IDLE_TIMEOUT_METADATA_KEY = "chat.idleTimeout"; + +/** + * Override the turn timeout for subsequent turns in the current run. + * + * The turn timeout controls how long the run stays suspended (freeing compute) + * waiting for the next user message. When it expires, the run completes + * gracefully and the next message starts a fresh run. + * + * Call from inside a `chatAgent` run function to adjust based on context. + * + * @param duration - A duration string (e.g. `"5m"`, `"1h"`, `"30s"`) + * + * @example + * ```ts + * run: async ({ messages, signal }) => { + * chat.setTurnTimeout("2h"); + * return streamText({ model, messages, abortSignal: signal }); + * } + * ``` + */ +function setTurnTimeout(duration: string): void { + metadata.set(TURN_TIMEOUT_METADATA_KEY, duration); +} + +/** + * Override the turn timeout in seconds for subsequent turns in the current run. + * + * @param seconds - Number of seconds to wait for the next message before ending the run + * + * @example + * ```ts + * run: async ({ messages, signal }) => { + * chat.setTurnTimeoutInSeconds(3600); // 1 hour + * return streamText({ model, messages, abortSignal: signal }); + * } + * ``` + */ +function setTurnTimeoutInSeconds(seconds: number): void { + metadata.set(TURN_TIMEOUT_METADATA_KEY, `${seconds}s`); +} + +/** + * Override the idle timeout for subsequent turns in the current run. + * + * The idle timeout controls how long the run stays active (using compute) + * after each turn, waiting for the next message. During this window, + * responses are instant. After it expires, the run suspends. + * + * @param seconds - Number of seconds to stay idle (0 to suspend immediately) + * + * @example + * ```ts + * run: async ({ messages, signal }) => { + * chat.setIdleTimeoutInSeconds(60); + * return streamText({ model, messages, abortSignal: signal }); + * } + * ``` + */ +function setIdleTimeoutInSeconds(seconds: number): void { + metadata.set(IDLE_TIMEOUT_METADATA_KEY, seconds); +} + +/** + * Override the `toUIMessageStream()` options for the current turn. + * + * These options control how the `StreamTextResult` is converted to a + * `UIMessageChunk` stream — error handling, reasoning/source visibility, + * message metadata, etc. + * + * Per-turn options are merged on top of the static `uiMessageStreamOptions` + * set on `chat.agent()`. Per-turn values win on conflicts. + * + * @example + * ```ts + * run: async ({ messages, signal }) => { + * chat.setUIMessageStreamOptions({ + * sendReasoning: true, + * onError: (error) => error instanceof Error ? error.message : "An error occurred.", + * }); + * return streamText({ model, messages, abortSignal: signal }); + * } + * ``` + */ +function setUIMessageStreamOptions(options: ChatUIMessageStreamOptions): void { + locals.set(chatUIStreamPerTurnKey, options); +} + +/** + * Resolve the effective UIMessageStream options by merging: + * 1. Static task-level options (from `chat.agent({ uiMessageStreamOptions })`) + * 2. Per-turn overrides (from `chat.setUIMessageStreamOptions()`) + * + * Per-turn values win on conflicts. Clears the per-turn override after reading + * so it doesn't leak into subsequent turns. + * @internal + */ +function resolveUIMessageStreamOptions(): ChatUIMessageStreamOptions { + const staticOptions = locals.get(chatUIStreamStaticKey) ?? {}; + const perTurnOptions = locals.get(chatUIStreamPerTurnKey) ?? {}; + // Clear per-turn override so it doesn't leak into subsequent turns + locals.set(chatUIStreamPerTurnKey, undefined); + return { ...staticOptions, ...perTurnOptions }; +} + +// --------------------------------------------------------------------------- +// Stop detection +// --------------------------------------------------------------------------- + +/** + * Check whether the user stopped generation during the current turn. + * + * Works from **anywhere** inside a `chat.agent` run — including inside + * `streamText`'s `onFinish` callback — without needing to thread the + * `stopSignal` through closures. + * + * This is especially useful when the AI SDK's `isAborted` flag is unreliable + * (e.g. when using `createUIMessageStream` + `writer.merge()`). + * + * @example + * ```ts + * onFinish: ({ isAborted }) => { + * const wasStopped = isAborted || chat.isStopped(); + * if (wasStopped) { + * // handle stop + * } + * } + * ``` + */ +function isStopped(): boolean { + const controller = locals.get(chatStopControllerKey); + return controller?.signal.aborted ?? false; +} + +// --------------------------------------------------------------------------- +// Version upgrade +// --------------------------------------------------------------------------- + +/** + * Request that the current run exits so the next message starts on the latest + * deployed version (via the standard continuation mechanism). + * + * When called from `onTurnStart` or `onValidateMessages`, `run()` is skipped + * entirely — the run exits immediately and the transport re-triggers the + * same message on the new version. + * + * When called from `run()` or `chat.defer()`, the current turn completes + * normally and the run exits afterward instead of waiting for the next message. + * + * Call from `onTurnStart`, `onValidateMessages`, `onChatResume`, `run()`, + * or inside `chat.defer()`. + * + * @example + * ```ts + * const SUPPORTED_VERSIONS = new Set(["v2", "v3"]); + * + * chat.agent({ + * id: "my-chat", + * onTurnStart: async ({ clientData }) => { + * if (clientData?.protocolVersion && !SUPPORTED_VERSIONS.has(clientData.protocolVersion)) { + * chat.requestUpgrade(); + * } + * }, + * run: async ({ messages }) => { ... }, + * }); + * ``` + */ +function requestUpgrade(): void { + locals.set(chatUpgradeRequestedKey, true); +} + +/** + * Exit the run after the current turn completes, without waiting for the + * next message. Unlike {@link requestUpgrade}, no upgrade-required signal + * is sent to the client — the turn finishes normally, `onTurnComplete` + * fires, and the loop exits instead of going idle. + * + * Call from `run()`, `chat.defer()`, `onBeforeTurnComplete`, or + * `onTurnComplete` to end the run on your own terms (budget exhausted, + * task complete, goal achieved, etc.). + * + * The next user message on the same `chatId` starts a fresh run via the + * normal continuation mechanism. + * + * @example + * ```ts + * chat.agent({ + * id: "one-shot-agent", + * run: async ({ messages, signal }) => { + * const result = streamText({ model: openai("gpt-4o"), messages, abortSignal: signal }); + * // Single-response agent — exit after this turn. + * chat.endRun(); + * return result; + * }, + * }); + * ``` + */ +function endRun(): void { + locals.set(chatEndRunRequestedKey, true); +} + +// --------------------------------------------------------------------------- +// Per-turn deferred work +// --------------------------------------------------------------------------- + +/** + * Register a promise that runs in the background during the current turn. + * + * Use this to move non-blocking work (DB writes, analytics, etc.) out of + * the critical path. The promise runs in parallel with streaming and is + * awaited (with a 5 s timeout) before `onTurnComplete` fires. + * + * @example + * ```ts + * onTurnStart: async ({ chatId, uiMessages }) => { + * // Pass a promise directly + * chat.defer(db.chat.update({ where: { id: chatId }, data: { messages: uiMessages } })); + * + * // Or pass an async function — cleaner for multi-step work + * chat.defer(async () => { + * const flags = await getFeatureFlags(); + * if (flags.forceUpgrade) chat.requestUpgrade(); + * }); + * }, + * ``` + */ +function chatDefer(promiseOrFn: Promise | (() => Promise)): void { + const promises = locals.get(chatDeferKey); + if (promises) { + promises.add(typeof promiseOrFn === "function" ? promiseOrFn() : promiseOrFn); + } +} + +// --------------------------------------------------------------------------- +// Background context injection +// --------------------------------------------------------------------------- + +/** + * Queue model messages for injection at the next `prepareStep` boundary. + * + * Use this to inject context from background work into the agent's conversation. + * Messages are appended to the model messages before the next LLM inference call. + * + * Combine with `chat.defer()` to run background analysis and inject results: + * + * @example + * ```ts + * onTurnComplete: async ({ messages }) => { + * chat.defer((async () => { + * const review = await generateObject({ + * model: openai("gpt-4o-mini"), + * messages: [...messages, { role: "user", content: "Review the last response." }], + * schema: z.object({ suggestions: z.array(z.string()) }), + * }); + * if (review.object.suggestions.length > 0) { + * chat.inject([{ + * role: "system", + * content: `Improvements for next response:\n${review.object.suggestions.join("\n")}`, + * }]); + * } + * })()); + * }, + * ``` + */ +function injectBackgroundContext(messages: ModelMessage[]): void { + const queue = locals.get(chatBackgroundQueueKey) ?? []; + queue.push(...messages); + locals.set(chatBackgroundQueueKey, queue); +} + +// --------------------------------------------------------------------------- +// Aborted message cleanup +// --------------------------------------------------------------------------- + +/** + * Clean up a UIMessage that was captured during an aborted/stopped turn. + * + * When generation is stopped mid-stream, the captured message may contain: + * - Tool parts stuck in incomplete states (`partial-call`, `input-available`, + * `input-streaming`) that cause permanent UI spinners + * - Reasoning parts with `state: "streaming"` instead of `"done"` + * - Text parts with `state: "streaming"` instead of `"done"` + * + * This function returns a cleaned copy with: + * - Incomplete tool parts removed entirely + * - Reasoning and text parts marked as `"done"` + * + * `chat.agent` calls this automatically when stop is detected before passing + * the response to `onTurnComplete`. Use this manually when calling `pipeChat` + * directly and capturing response messages yourself. + * + * @example + * ```ts + * onTurnComplete: async ({ responseMessage, stopped }) => { + * // Already cleaned automatically by chat.agent — but if you captured + * // your own message via pipeChat, clean it manually: + * const cleaned = chat.cleanupAbortedParts(myMessage); + * await db.messages.save(cleaned); + * } + * ``` + */ +function cleanupAbortedParts(message: TUIM): TUIM { + if (!message.parts) return message; + + const isToolPart = (part: any) => + part.type === "tool-invocation" || + part.type?.startsWith("tool-") || + part.type === "dynamic-tool"; + + return { + ...message, + parts: message.parts + .filter((part: any) => { + if (!isToolPart(part)) return true; + // Remove tool parts that never completed execution. + // partial-call: input was still streaming when aborted. + // input-available: input was complete but tool never ran. + // input-streaming: input was mid-stream. + const state = part.toolInvocation?.state ?? part.state; + return ( + state !== "partial-call" && state !== "input-available" && state !== "input-streaming" + ); + }) + .map((part: any) => { + // Mark streaming reasoning as done + if (part.type === "reasoning" && part.state === "streaming") { + return { ...part, state: "done" }; + } + // Mark streaming text as done + if (part.type === "text" && part.state === "streaming") { + return { ...part, state: "done" }; + } + return part; + }), + } as TUIM; +} + +// --------------------------------------------------------------------------- +// Composable primitives for raw task chat +// --------------------------------------------------------------------------- + +/** + * Create a managed stop signal wired to the chat stop input stream. + * + * Call once at the start of your run. Use `signal` as the abort signal for + * `streamText`. Call `reset()` at the start of each turn to get a fresh + * per-turn signal. Call `cleanup()` when the run ends. + * + * @example + * ```ts + * const stop = chat.createStopSignal(); + * for (let turn = 0; turn < 100; turn++) { + * stop.reset(); + * const result = streamText({ model, messages, abortSignal: stop.signal }); + * await chat.pipe(result); + * // ... + * } + * stop.cleanup(); + * ``` + */ +function createStopSignal(): { + readonly signal: AbortSignal; + reset: () => void; + cleanup: () => void; +} { + let controller = new AbortController(); + const sub = stopInput.on((data) => { + controller.abort(data?.message || "stopped"); + }); + return { + get signal() { + return controller.signal; + }, + reset() { + controller = new AbortController(); + }, + cleanup() { + sub.off(); + }, + }; +} + +/** + * Signal the frontend that the current turn is complete. + * + * The `TriggerChatTransport` intercepts this to close the ReadableStream + * for the current turn. Call after piping the response stream. + * + * @example + * ```ts + * await chat.pipe(result); + * await chat.writeTurnComplete(); + * ``` + */ +async function chatWriteTurnComplete(options?: { publicAccessToken?: string }): Promise { + await writeTurnCompleteChunk(undefined, options?.publicAccessToken); +} + +/** + * Pipe a `StreamTextResult` (or similar) to the chat stream and capture + * the assistant's response message via `onFinish`. + * + * Combines `toUIMessageStream()` + `onFinish` callback + `chat.pipe()`. + * Returns the captured `UIMessage`, or `undefined` if capture failed. + * + * @example + * ```ts + * const result = streamText({ model, messages, abortSignal: signal }); + * const response = await chat.pipeAndCapture(result, { signal }); + * if (response) conversation.addResponse(response); + * ``` + */ +async function pipeChatAndCapture( + source: UIMessageStreamable, + options?: { signal?: AbortSignal; spanName?: string } +): Promise { + let captured: UIMessage | undefined; + let resolveOnFinish: () => void; + const onFinishPromise = new Promise((r) => { + resolveOnFinish = r; + }); + + const uiStream = source.toUIMessageStream({ + ...resolveUIMessageStreamOptions(), + onFinish: ({ responseMessage }: { responseMessage: UIMessage }) => { + captured = responseMessage; + resolveOnFinish!(); + }, + }); + + await pipeChat(uiStream, { + signal: options?.signal, + spanName: options?.spanName ?? "stream response", + }); + await onFinishPromise; + + return captured; +} + +/** + * Accumulates conversation messages across turns. + * + * Handles the transport protocol: turn 0 sends full history (replace), + * subsequent turns send only new messages (append), regenerate sends + * full history minus last assistant message (replace). + * + * @example + * ```ts + * const conversation = new chat.MessageAccumulator(); + * for (let turn = 0; turn < 100; turn++) { + * const messages = await conversation.addIncoming(payload.messages, payload.trigger, turn); + * const result = streamText({ model, messages }); + * const response = await chat.pipeAndCapture(result); + * if (response) await conversation.addResponse(response); + * } + * ``` + */ +class ChatMessageAccumulator { + modelMessages: ModelMessage[] = []; + uiMessages: UIMessage[] = []; + private _compaction?: ChatAgentCompactionOptions; + private _pendingMessages?: PendingMessagesOptions; + private _steeringQueue: SteeringQueueEntry[] = []; + + constructor(options?: { + compaction?: ChatAgentCompactionOptions; + pendingMessages?: PendingMessagesOptions; + }) { + this._compaction = options?.compaction; + this._pendingMessages = options?.pendingMessages; + } + + /** + * Add incoming messages from the transport payload. + * Returns the full accumulated model messages for `streamText`. + */ + async addIncoming(messages: UIMessage[], trigger: string, turn: number): Promise { + const cleaned = messages.map((m) => (m.role === "assistant" ? cleanupAbortedParts(m) : m)); + const model = await toModelMessages(cleaned); + + if (turn === 0 || trigger === "regenerate-message") { + this.modelMessages = model; + this.uiMessages = [...cleaned]; + } else { + this.modelMessages.push(...model); + this.uiMessages.push(...cleaned); + } + return this.modelMessages; + } + + /** + * Add the assistant's response to the accumulator. + * Call after `pipeAndCapture` with the captured response. + */ + /** + * Replace all accumulated messages (for compaction). + * Converts UIMessages to ModelMessages internally. + */ + async setMessages(uiMessages: UIMessage[]): Promise { + this.uiMessages = [...uiMessages]; + this.modelMessages = await toModelMessages(uiMessages); + } + + async addResponse(response: UIMessage): Promise { + if (!response.id) { + response = { ...response, id: generateMessageId() }; + } + this.uiMessages.push(response); + try { + const msgs = await toModelMessages([stripProviderMetadata(response)]); + this.modelMessages.push(...msgs); + } catch { + // Conversion failed — skip model message accumulation for this response + } + } + + /** + * Queue a message for injection via `prepareStep`. Call from a + * `messagesInput.on()` listener when a message arrives during streaming. + */ + steer(message: UIMessage, modelMessages?: ModelMessage[]): void { + if (modelMessages) { + this._steeringQueue.push({ uiMessage: message, modelMessages }); + } else { + // Defer conversion — will be done in prepareStep if needed + this._steeringQueue.push({ uiMessage: message, modelMessages: [] }); + } + } + + /** + * Queue a message for injection, converting to model messages automatically. + */ + async steerAsync(message: UIMessage): Promise { + const modelMsgs = await toModelMessages([message]); + this._steeringQueue.push({ uiMessage: message, modelMessages: modelMsgs }); + } + + /** + * Get and clear unconsumed steering messages. + */ + drainSteering(): UIMessage[] { + const result = this._steeringQueue.map((e) => e.uiMessage); + this._steeringQueue = []; + return result; + } + + /** + * Returns a `prepareStep` function that handles both compaction and + * pending message injection. Pass to `streamText({ prepareStep: conversation.prepareStep() })`. + */ + prepareStep(): + | ((args: { + messages: ModelMessage[]; + steps: CompactionStep[]; + }) => Promise<{ messages: ModelMessage[] } | undefined>) + | undefined { + if (!this._compaction && !this._pendingMessages) return undefined; + const comp = this._compaction; + const pm = this._pendingMessages; + const queue = this._steeringQueue; + + return async ({ messages, steps }) => { + let resultMessages: ModelMessage[] | undefined; + + // 1. Compaction + if (comp) { + const result = await chatCompact(messages, steps, { + shouldCompact: comp.shouldCompact, + summarize: (msgs) => comp.summarize({ messages: msgs, source: "inner" }), + }); + if (result.type !== "skipped") { + resultMessages = result.messages; + } + } + + // 2. Pending message injection + if (pm && queue.length > 0) { + const injected = await drainSteeringQueue(pm, resultMessages ?? messages, steps, queue); + if (injected.length > 0) { + resultMessages = [...(resultMessages ?? messages), ...injected]; + } + } + + return resultMessages ? { messages: resultMessages } : undefined; + }; + } + + /** + * Run outer-loop compaction if needed. Call after adding the response + * and capturing usage. Applies `compactModelMessages` and `compactUIMessages` + * callbacks if configured. + * + * @returns `true` if compaction was performed, `false` otherwise. + */ + async compactIfNeeded( + usage: LanguageModelUsage | undefined, + context?: { + chatId?: string; + turn?: number; + clientData?: unknown; + totalUsage?: LanguageModelUsage; + } + ): Promise { + if (!this._compaction || !usage) return false; + + const shouldTrigger = await this._compaction.shouldCompact({ + messages: this.modelMessages, + totalTokens: usage.totalTokens, + inputTokens: usage.inputTokens, + outputTokens: usage.outputTokens, + usage, + totalUsage: context?.totalUsage, + chatId: context?.chatId, + turn: context?.turn, + clientData: context?.clientData, + source: "outer", + }); + + if (!shouldTrigger) return false; + + const summary = await this._compaction.summarize({ + messages: this.modelMessages, + usage, + totalUsage: context?.totalUsage, + chatId: context?.chatId, + turn: context?.turn, + clientData: context?.clientData, + source: "outer", + }); + + const compactEvent: CompactMessagesEvent = { + summary, + uiMessages: this.uiMessages, + modelMessages: this.modelMessages, + chatId: context?.chatId ?? "", + turn: context?.turn ?? 0, + clientData: context?.clientData, + source: "outer", + }; + + this.modelMessages = this._compaction.compactModelMessages + ? await this._compaction.compactModelMessages(compactEvent) + : [ + { + role: "assistant" as const, + content: [{ type: "text" as const, text: `[Conversation summary]\n\n${summary}` }], + }, + ]; + + if (this._compaction.compactUIMessages) { + this.uiMessages = await this._compaction.compactUIMessages(compactEvent); + } + + return true; + } +} + +// --------------------------------------------------------------------------- +// chat.createSession — async iterator for chat turns +// --------------------------------------------------------------------------- + +export type ChatSessionOptions = { + /** Run-level cancel signal (from task context). */ + signal: AbortSignal; + /** Seconds to stay idle between turns before suspending. @default 30 */ + idleTimeoutInSeconds?: number; + /** Duration string for suspend timeout. @default "1h" */ + timeout?: string; + /** Max turns before ending. @default 100 */ + maxTurns?: number; + /** Automatic context compaction — same options as `chat.agent({ compaction })`. */ + compaction?: ChatAgentCompactionOptions; + /** Configure mid-execution message injection — same options as `chat.agent({ pendingMessages })`. */ + pendingMessages?: PendingMessagesOptions; +}; + +export type ChatTurn = { + /** Turn number (0-indexed). */ + number: number; + /** Chat session ID. */ + chatId: string; + /** What triggered this turn. */ + trigger: string; + /** Client data from the transport (`metadata` field on the wire payload). */ + clientData: unknown; + /** Full accumulated model messages — pass directly to `streamText`. */ + readonly messages: ModelMessage[]; + /** Full accumulated UI messages — use for persistence. */ + readonly uiMessages: UIMessage[]; + /** Combined stop+cancel AbortSignal (fresh each turn). */ + signal: AbortSignal; + /** Whether the user stopped generation this turn. */ + readonly stopped: boolean; + /** Whether this is a continuation run. */ + continuation: boolean; + /** Token usage from the previous turn. Undefined on turn 0. */ + previousTurnUsage?: LanguageModelUsage; + /** Cumulative token usage across all completed turns so far. */ + totalUsage: LanguageModelUsage; + + /** + * Replace accumulated messages (for compaction). Takes UIMessages and + * converts to ModelMessages internally. After calling this, `turn.messages` + * reflects the compacted history. + */ + setMessages(uiMessages: UIMessage[]): Promise; + + /** + * Easy path: pipe stream, capture response, accumulate it, + * clean up aborted parts if stopped, and write turn-complete chunk. + */ + complete(source: UIMessageStreamable): Promise; + + /** + * Manual path: just write turn-complete chunk. + * Use when you've already piped and accumulated manually. + */ + done(): Promise; + + /** + * Add the response to the accumulator manually. + * Use with `chat.pipeAndCapture` when you need control between pipe and done. + */ + addResponse(response: UIMessage): Promise; + + /** + * Returns a `prepareStep` function that handles both compaction and + * pending message injection. Pass to `streamText({ prepareStep: turn.prepareStep() })`. + * Only needed when not using `chat.toStreamTextOptions()` (which auto-injects it). + */ + prepareStep(): + | ((args: { + messages: ModelMessage[]; + steps: CompactionStep[]; + }) => Promise<{ messages: ModelMessage[] } | undefined>) + | undefined; +}; + +/** + * Create a chat session that yields turns as an async iterator. + * + * Handles: preload wait, stop signals, message accumulation, turn-complete + * signaling, and idle/suspend between turns. You control: initialization, + * model/tool selection, persistence, and any custom per-turn logic. + * + * @example + * ```ts + * import { task } from "@trigger.dev/sdk"; + * import { chat, type ChatTaskWirePayload } from "@trigger.dev/sdk/ai"; + * import { streamText } from "ai"; + * import { openai } from "@ai-sdk/openai"; + * + * export const myChat = task({ + * id: "my-chat", + * run: async (payload: ChatTaskWirePayload, { signal }) => { + * const session = chat.createSession(payload, { signal }); + * + * for await (const turn of session) { + * const result = streamText({ + * model: openai("gpt-4o"), + * messages: turn.messages, + * abortSignal: turn.signal, + * }); + * await turn.complete(result); + * } + * }, + * }); + * ``` + */ +function createChatSession( + payload: ChatTaskWirePayload, + options: ChatSessionOptions +): AsyncIterable { + const { + signal: runSignal, + idleTimeoutInSeconds: sessionIdleTimeoutOpt, + timeout = "1h", + maxTurns = 100, + compaction: sessionCompaction, + pendingMessages: sessionPendingMessages, + } = options; + + const idleTimeoutInSeconds = sessionIdleTimeoutOpt ?? 30; + + return { + [Symbol.asyncIterator]() { + let currentPayload = payload; + let turn = -1; + const stop = createStopSignal(); + const accumulator = new ChatMessageAccumulator(); + let previousTurnUsage: LanguageModelUsage | undefined; + let cumulativeUsage: LanguageModelUsage = emptyUsage(); + + return { + async next(): Promise> { + turn++; + + // First turn: handle preload — wait for the first real message + if (turn === 0 && currentPayload.trigger === "preload") { + const result = await messagesInput.waitWithIdleTimeout({ + idleTimeoutInSeconds: + sessionIdleTimeoutOpt ?? currentPayload.idleTimeoutInSeconds ?? 30, + timeout, + spanName: "waiting for first message", + }); + if (!result.ok || runSignal.aborted) { + stop.cleanup(); + return { done: true, value: undefined }; + } + currentPayload = result.output; + } + + // Subsequent turns: wait for the next message + if (turn > 0) { + // chat.requestUpgrade() / chat.endRun() — exit before waiting + if ( + locals.get(chatUpgradeRequestedKey) || + locals.get(chatEndRunRequestedKey) + ) { + stop.cleanup(); + return { done: true, value: undefined }; + } + + const next = await messagesInput.waitWithIdleTimeout({ + idleTimeoutInSeconds, + timeout, + spanName: "waiting for next message", + }); + if (!next.ok || runSignal.aborted) { + stop.cleanup(); + return { done: true, value: undefined }; + } + currentPayload = next.output; + } + + // Check limits + if (turn >= maxTurns || runSignal.aborted) { + stop.cleanup(); + return { done: true, value: undefined }; + } + + // Reset stop signal for this turn + stop.reset(); + + // Reset per-turn state + locals.set(chatResponsePartsKey, []); + // Set up steering queue and pending messages config in locals + // so toStreamTextOptions() auto-injects prepareStep for steering + const turnSteeringQueue: SteeringQueueEntry[] = []; + locals.set(chatSteeringQueueKey, turnSteeringQueue); + if (sessionPendingMessages) { + locals.set(chatPendingMessagesKey, sessionPendingMessages); + } + locals.set(chatTurnContextKey, { + chatId: currentPayload.chatId, + turn, + continuation: currentPayload.continuation ?? false, + clientData: currentPayload.metadata, + }); + + // Listen for messages during streaming (steering + next-turn buffer) + const sessionPendingWire: ChatTaskWirePayload[] = []; + const sessionMsgSub = messagesInput.on(async (msg) => { + sessionPendingWire.push(msg); + + if (sessionPendingMessages) { + // Slim wire: at most one delta message per record. Read + // `msg.message` directly — no array slicing needed. + const lastUIMessage = msg.message; + if (lastUIMessage) { + if (sessionPendingMessages.onReceived) { + try { + await sessionPendingMessages.onReceived({ + message: lastUIMessage, + chatId: currentPayload.chatId, + turn, + }); + } catch { + /* non-fatal */ + } + } + try { + const modelMsgs = await toModelMessages([lastUIMessage]); + turnSteeringQueue.push({ uiMessage: lastUIMessage, modelMessages: modelMsgs }); + } catch { + /* non-fatal */ + } + } + } + }); + + // Accumulate messages. Slim wire: pass the single delta message as + // a 0-or-1-length array. The accumulator's behavior is unchanged — + // it still appends user messages and reconverts on regenerate. + const incomingForAccumulator: UIMessage[] = currentPayload.message + ? [currentPayload.message] + : []; + const messages = await accumulator.addIncoming( + incomingForAccumulator, + currentPayload.trigger, + turn + ); + + // chat.requestUpgrade() called before this turn — signal transport and exit + if (locals.get(chatUpgradeRequestedKey)) { + await writeUpgradeRequiredChunk(); + sessionMsgSub.off(); + stop.cleanup(); + return { done: true, value: undefined }; + } + + const combinedSignal = AbortSignal.any([runSignal, stop.signal]); + + const turnObj: ChatTurn = { + number: turn, + chatId: currentPayload.chatId, + trigger: currentPayload.trigger, + clientData: currentPayload.metadata, + get messages() { + return accumulator.modelMessages; + }, + get uiMessages() { + return accumulator.uiMessages; + }, + signal: combinedSignal, + get stopped() { + return stop.signal.aborted && !runSignal.aborted; + }, + continuation: currentPayload.continuation ?? false, + previousTurnUsage, + totalUsage: cumulativeUsage, + + async setMessages(uiMessages: UIMessage[]) { + await accumulator.setMessages(uiMessages); + }, + + async complete(source: UIMessageStreamable) { + let response: UIMessage | undefined; + try { + response = await pipeChatAndCapture(source, { signal: combinedSignal }); + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + if (runSignal.aborted) { + // Full cancel — don't accumulate + sessionMsgSub.off(); + await chatWriteTurnComplete(); + return undefined; + } + // Stop — fall through to accumulate partial response + } else { + throw error; + } + } + + if (response) { + const cleaned = + stop.signal.aborted && !runSignal.aborted + ? cleanupAbortedParts(response) + : response; + // Append any non-transient data parts queued via chat.response or writer.write() + const queuedParts = locals.get(chatResponsePartsKey); + if (queuedParts && queuedParts.length > 0) { + (cleaned as any).parts = [...(cleaned.parts ?? []), ...queuedParts]; + locals.set(chatResponsePartsKey, []); + } + await accumulator.addResponse(cleaned); + } else { + // No response (manual pipe mode) but there are queued data parts + const queuedParts = locals.get(chatResponsePartsKey); + if (queuedParts && queuedParts.length > 0) { + await accumulator.addResponse({ + id: generateMessageId(), + role: "assistant" as const, + parts: queuedParts as UIMessage["parts"], + }); + locals.set(chatResponsePartsKey, []); + } + } + + // Capture token usage from the streamText result + let turnUsage: LanguageModelUsage | undefined; + if (typeof (source as any).totalUsage?.then === "function") { + try { + const usage: LanguageModelUsage = await (source as any).totalUsage; + turnUsage = usage; + previousTurnUsage = usage; + cumulativeUsage = addUsage(cumulativeUsage, usage); + } catch { + /* non-fatal */ + } + } + + // Outer-loop compaction (same logic as chat.agent) + if (sessionCompaction && turnUsage && !turnObj.stopped) { + const shouldTrigger = await sessionCompaction.shouldCompact({ + messages: accumulator.modelMessages, + totalTokens: turnUsage.totalTokens, + inputTokens: turnUsage.inputTokens, + outputTokens: turnUsage.outputTokens, + usage: turnUsage, + totalUsage: cumulativeUsage, + chatId: currentPayload.chatId, + turn, + clientData: currentPayload.metadata, + source: "outer", + }); + + if (shouldTrigger) { + const summary = await sessionCompaction.summarize({ + messages: accumulator.modelMessages, + usage: turnUsage, + totalUsage: cumulativeUsage, + chatId: currentPayload.chatId, + turn, + clientData: currentPayload.metadata, + source: "outer", + }); + + const compactEvent: CompactMessagesEvent = { + summary, + uiMessages: accumulator.uiMessages, + modelMessages: accumulator.modelMessages, + chatId: currentPayload.chatId, + turn, + clientData: currentPayload.metadata, + source: "outer", + }; + + accumulator.modelMessages = sessionCompaction.compactModelMessages + ? await sessionCompaction.compactModelMessages(compactEvent) + : [ + { + role: "assistant" as const, + content: [ + { type: "text" as const, text: `[Conversation summary]\n\n${summary}` }, + ], + }, + ]; + + if (sessionCompaction.compactUIMessages) { + accumulator.uiMessages = await sessionCompaction.compactUIMessages( + compactEvent + ); + } + } + } + + sessionMsgSub.off(); + await chatWriteTurnComplete(); + return response; + }, + + async addResponse(response: UIMessage) { + // Append any non-transient data parts queued via chat.response or writer.write() + const queuedParts = locals.get(chatResponsePartsKey); + if (queuedParts && queuedParts.length > 0) { + response = { ...response, parts: [...(response.parts ?? []), ...(queuedParts as UIMessage["parts"])] }; + locals.set(chatResponsePartsKey, []); + } + await accumulator.addResponse(response); + }, + + async done() { + sessionMsgSub.off(); + await chatWriteTurnComplete(); + }, + + prepareStep() { + const hasCompaction = !!sessionCompaction; + const hasPending = !!sessionPendingMessages; + if (!hasCompaction && !hasPending) return undefined; + + return async ({ + messages: stepMsgs, + steps, + }: { + messages: ModelMessage[]; + steps: CompactionStep[]; + }) => { + let resultMessages: ModelMessage[] | undefined; + + if (sessionCompaction) { + const compactResult = await chatCompact(stepMsgs, steps, { + shouldCompact: sessionCompaction.shouldCompact, + summarize: (msgs) => + sessionCompaction.summarize({ messages: msgs, source: "inner" }), + }); + if (compactResult.type !== "skipped") { + resultMessages = compactResult.messages; + } + } + + if (sessionPendingMessages) { + const injected = await drainSteeringQueue( + sessionPendingMessages, + resultMessages ?? stepMsgs, + steps, + turnSteeringQueue + ); + if (injected.length > 0) { + resultMessages = [...(resultMessages ?? stepMsgs), ...injected]; + } + } + + return resultMessages ? { messages: resultMessages } : undefined; + }; + }, + }; + + return { done: false, value: turnObj }; + }, + + async return() { + stop.cleanup(); + return { done: true, value: undefined }; + }, + }; + }, + }; +} + +// --------------------------------------------------------------------------- +// chat.local — per-run typed data with Proxy access +// --------------------------------------------------------------------------- + +/** @internal Symbol for storing the locals key on the proxy target. */ +const CHAT_LOCAL_KEY: unique symbol = Symbol("chatLocalKey"); +/** @internal Symbol for storing the dirty-tracking locals key. */ +const CHAT_LOCAL_DIRTY_KEY: unique symbol = Symbol("chatLocalDirtyKey"); + +// --------------------------------------------------------------------------- +// chat.local registry — tracks all declared locals for serialization +// --------------------------------------------------------------------------- + +type ChatLocalEntry = { key: ReturnType; id: string }; +const chatLocalRegistry = new Set(); + +/** @internal Run-scoped flag to ensure hydration happens at most once per run. */ +const chatLocalsHydratedKey = locals.create("chat.locals.hydrated"); + +/** + * Hydrate chat.local values from subtask metadata (set by `ai.toolExecute()` or legacy `ai.tool()`). + * Runs once per run — subsequent calls are no-ops. + * @internal + */ +function hydrateLocalsFromMetadata(): void { + if (locals.get(chatLocalsHydratedKey)) return; + locals.set(chatLocalsHydratedKey, true); + const opts = metadata.get(METADATA_KEY) as ToolCallExecutionOptions | undefined; + if (!opts?.chatLocals) return; + for (const [id, value] of Object.entries(opts.chatLocals)) { + locals.set(locals.create(id), value); + } +} + +/** + * A Proxy-backed, run-scoped data object that appears as `T` to users. + * Includes helper methods for initialization, dirty tracking, and serialization. + * Internal metadata is stored behind Symbols and invisible to + * `Object.keys()`, `JSON.stringify()`, and spread. + */ +export type ChatLocal> = T & { + /** Initialize the local with a value. Call in `onChatStart` or `run()`. */ + init(value: T): void; + /** Returns `true` if any property was set since the last check. Resets the dirty flag. */ + hasChanged(): boolean; + /** Returns a plain object copy of the current value. Useful for persistence. */ + get(): T; + readonly [CHAT_LOCAL_KEY]: ReturnType>; + readonly [CHAT_LOCAL_DIRTY_KEY]: ReturnType>; +}; + +/** + * Creates a per-run typed data object accessible from anywhere during task execution. + * + * Declare at module level, then initialize inside `onBoot` (recommended — fires + * on every fresh worker including continuation runs). Do NOT initialize in + * `onChatStart` alone: `onChatStart` only fires on the chat's very first + * message, so `chat.local` would be uninitialized on continuation runs and + * `run()` would throw. + * + * Multiple locals can coexist — each gets its own isolated run-scoped storage. + * + * The `id` is required and must be unique across all `chat.local()` calls in + * your project. It's used to serialize values into subtask metadata so that + * `ai.toolExecute()` (or legacy `ai.tool()`) subtasks can auto-hydrate parent locals (read-only). + * + * @example + * ```ts + * import { chat } from "@trigger.dev/sdk/ai"; + * + * const userPrefs = chat.local<{ theme: string; language: string }>({ id: "userPrefs" }); + * const gameState = chat.local<{ score: number; streak: number }>({ id: "gameState" }); + * + * export const myChat = chat.agent({ + * id: "my-chat", + * onBoot: async ({ clientData }) => { + * const prefs = await db.prefs.findUnique({ where: { userId: clientData.userId } }); + * userPrefs.init(prefs ?? { theme: "dark", language: "en" }); + * gameState.init({ score: 0, streak: 0 }); + * }, + * onTurnComplete: async ({ chatId }) => { + * if (gameState.hasChanged()) { + * await db.save({ where: { chatId }, data: gameState.get() }); + * } + * }, + * run: async ({ messages }) => { + * gameState.score++; + * return streamText({ + * system: `User prefers ${userPrefs.theme} theme. Score: ${gameState.score}`, + * messages, + * }); + * }, + * }); + * ``` + */ +function chatLocal>(options: { id: string }): ChatLocal { + const id = `chat.local.${options.id}`; + const localKey = locals.create(id); + const dirtyKey = locals.create(`${id}.dirty`); + + chatLocalRegistry.add({ key: localKey, id }); + + const target = {} as any; + target[CHAT_LOCAL_KEY] = localKey; + target[CHAT_LOCAL_DIRTY_KEY] = dirtyKey; + + return new Proxy(target, { + get(_target, prop, _receiver) { + // Internal Symbol properties + if (prop === CHAT_LOCAL_KEY) return _target[CHAT_LOCAL_KEY]; + if (prop === CHAT_LOCAL_DIRTY_KEY) return _target[CHAT_LOCAL_DIRTY_KEY]; + + // Instance methods + if (prop === "init") { + return (value: T) => { + locals.set(localKey, value); + locals.set(dirtyKey, false); + }; + } + if (prop === "hasChanged") { + return () => { + const dirty = locals.get(dirtyKey) ?? false; + locals.set(dirtyKey, false); + return dirty; + }; + } + if (prop === "get") { + return () => { + let current = locals.get(localKey); + if (current === undefined) { + hydrateLocalsFromMetadata(); + current = locals.get(localKey); + } + if (current === undefined) { + throw new Error( + "local.get() called before initialization. Call local.init() in onBoot (recommended — fires on every fresh worker including continuation runs) or run() first." + ); + } + return { ...current }; + }; + } + // toJSON for serialization (JSON.stringify(local)) + if (prop === "toJSON") { + return () => { + let current = locals.get(localKey); + if (current === undefined) { + hydrateLocalsFromMetadata(); + current = locals.get(localKey); + } + return current ? { ...current } : undefined; + }; + } + + let current = locals.get(localKey); + if (current === undefined) { + // Auto-hydrate from parent metadata in subtask context + hydrateLocalsFromMetadata(); + current = locals.get(localKey); + } + if (current === undefined) return undefined; + return (current as any)[prop]; + }, + + set(_target, prop, value) { + // Don't allow setting internal Symbols + if (typeof prop === "symbol") return false; + + const current = locals.get(localKey); + if (current === undefined) { + throw new Error( + "chat.local can only be modified after initialization. " + + "Call local.init() in onBoot (recommended — fires on every fresh worker including continuation runs) or run() first. " + + "If you previously initialized in onChatStart, move it to onBoot — onChatStart only fires on the chat's very first message and will not run on a continuation." + ); + } + locals.set(localKey, { ...current, [prop]: value }); + locals.set(dirtyKey, true); + return true; + }, + + has(_target, prop) { + if (typeof prop === "symbol") return prop in _target; + let current = locals.get(localKey); + if (current === undefined) { + hydrateLocalsFromMetadata(); + current = locals.get(localKey); + } + return current !== undefined && prop in current; + }, + + ownKeys() { + let current = locals.get(localKey); + if (current === undefined) { + hydrateLocalsFromMetadata(); + current = locals.get(localKey); + } + return current ? Reflect.ownKeys(current) : []; + }, + + getOwnPropertyDescriptor(_target, prop) { + if (typeof prop === "symbol") return undefined; + let current = locals.get(localKey); + if (current === undefined) { + hydrateLocalsFromMetadata(); + current = locals.get(localKey); + } + if (current === undefined || !(prop in current)) return undefined; + return { + configurable: true, + enumerable: true, + writable: true, + value: (current as any)[prop], + }; + }, + }) as ChatLocal; +} + +/** + * Extracts the client data (metadata) type from a chat task. + * Use this to type the `metadata` option on the transport. + * + * @example + * ```ts + * import type { InferChatClientData } from "@trigger.dev/sdk/ai"; + * import type { myChat } from "@/trigger/chat"; + * + * type MyClientData = InferChatClientData; + * // { model?: string; userId: string } + * ``` + */ +// `InferChatClientData` and `InferChatUIMessage` live in `./ai-shared.ts` +// so the chat React hooks can import them without dragging `ai.ts` into +// the browser graph. Re-exported here so `@trigger.dev/sdk/ai` consumers +// still see them. +import type { InferChatClientData, InferChatUIMessage } from "./ai-shared.js"; +export type { InferChatClientData, InferChatUIMessage } from "./ai-shared.js"; + +/** + * Options for {@link createChatStartSessionAction}. + */ +export type CreateChatStartSessionActionOptions = { + /** TTL for the session-scoped public access token. @default "1h" */ + tokenTTL?: string | number | Date; + /** + * Default trigger config used when starting a new session for a chat. + * Per-call `params.triggerConfig` shallow-merges on top. + */ + triggerConfig?: Partial; +}; + +/** + * Params for the function returned by {@link createChatStartSessionAction}. + */ +export type ChatStartSessionParams = { + /** Conversation id (mapped to the Session's `externalId`). */ + chatId: string; + /** + * Per-call trigger config. Shallow-merged over the action's default + * `triggerConfig`. `basePayload` is the customer's wire payload (for + * `chat.agent`: anything beyond `chatId`/`messages`/`trigger`/`metadata`, + * which the runtime injects automatically). + */ + triggerConfig?: Partial; + /** Pass-through metadata folded into the session row. */ + metadata?: Record; +}; + +/** + * Result from {@link createChatStartSessionAction}'s returned function. + */ +export type ChatStartSessionResult = { + /** + * Session-scoped public access token (`read:sessions:{chatId} + + * write:sessions:{chatId}`). Pass this to the browser; the transport + * uses it to call `.in/append`, `.out`, `end-and-continue`. + */ + publicAccessToken: string; + /** Friendly id of the run triggered alongside session create. */ + runId: string; + /** Session friendlyId — informational. */ + sessionId: string; +}; + +/** + * Creates a server-side helper that starts (or resumes) a Session for a + * given chatId — atomically creating the row, triggering the first run, + * and returning a session-scoped PAT for the browser to use. + * + * Wrap in a Next.js server action (or any server-side handler) so the + * customer's secret key never crosses to the browser. + * + * @example + * ```ts + * // actions.ts + * "use server"; + * import { chat } from "@trigger.dev/sdk/ai"; + * + * export const startChatSession = chat.createStartSessionAction("my-chat", { + * triggerConfig: { machine: "small-1x" }, + * }); + * ``` + * + * Then in the browser: + * ```tsx + * const transport = useTriggerChatTransport({ + * task: "my-chat", + * accessToken: async ({ chatId }) => { + * const { publicAccessToken } = await startChatSession({ chatId }); + * return publicAccessToken; + * }, + * }); + * ``` + */ +function createChatStartSessionAction( + taskId: string, + options?: CreateChatStartSessionActionOptions +): (params: ChatStartSessionParams) => Promise { + return async (params: ChatStartSessionParams): Promise => { + if (!params.chatId) { + throw new Error( + "chat.createStartSessionAction: params.chatId is required — used as the session externalId." + ); + } + + // The first run boots before the user's first message lands on + // `.in/append`, so it sees an empty `messages` array and `trigger: + // "preload"`. This matches the pre-Sessions preload semantics: + // `onPreload` fires, the runtime opens its `.in` subscription, the + // first user message arrives moments later via `.in/append`. + // + // `metadata` is the customer's transport-level `clientData`, + // threaded through so the agent's `clientDataSchema` validates on + // the very first turn (the typical schema requires `userId` etc.). + // Auto-tag every chat.agent run with `chat:{chatId}` so the dashboard / + // run-list filter by chat works without the customer having to wire it + // up. Mirrors the browser-mediated `TriggerChatTransport.doStart` path. + const userTags = params.triggerConfig?.tags ?? options?.triggerConfig?.tags ?? []; + const tags = [`chat:${params.chatId}`, ...userTags].slice(0, 5); + + const triggerConfig: SessionTriggerConfig = { + basePayload: { + messages: [], + trigger: "preload", + ...(options?.triggerConfig?.basePayload ?? {}), + ...(params.triggerConfig?.basePayload ?? {}), + chatId: params.chatId, + }, + ...(options?.triggerConfig?.machine || params.triggerConfig?.machine + ? { machine: params.triggerConfig?.machine ?? options?.triggerConfig?.machine } + : {}), + ...(options?.triggerConfig?.queue || params.triggerConfig?.queue + ? { queue: params.triggerConfig?.queue ?? options?.triggerConfig?.queue } + : {}), + tags, + ...(options?.triggerConfig?.maxAttempts !== undefined || + params.triggerConfig?.maxAttempts !== undefined + ? { + maxAttempts: + params.triggerConfig?.maxAttempts ?? options?.triggerConfig?.maxAttempts!, + } + : {}), + ...(options?.triggerConfig?.idleTimeoutInSeconds !== undefined || + params.triggerConfig?.idleTimeoutInSeconds !== undefined + ? { + idleTimeoutInSeconds: + params.triggerConfig?.idleTimeoutInSeconds ?? + options?.triggerConfig?.idleTimeoutInSeconds!, + } + : {}), + }; + + const created = await sessions.start({ + type: "chat.agent", + externalId: params.chatId, + taskIdentifier: taskId, + triggerConfig, + metadata: params.metadata, + }); + + // Session create returns a session PAT directly when called with a + // start token, but when the SDK call goes via the secret key we still + // need to mint our own (the server returns a PAT regardless, but + // re-minting here lets the customer override `tokenTTL`). + const publicAccessToken = + options?.tokenTTL !== undefined + ? await auth.createPublicToken({ + scopes: { + read: { sessions: params.chatId }, + write: { sessions: params.chatId }, + }, + expirationTime: options.tokenTTL, + }) + : created.publicAccessToken; + + return { + publicAccessToken, + runId: created.runId, + sessionId: created.id, + }; + }; +} + +export const chat = { + /** Create a chat agent. See {@link chatAgent}. */ + agent: chatAgent, + /** Create a custom agent with manual lifecycle control. See {@link chatCustomAgent}. */ + customAgent: chatCustomAgent, + /** Create a chat task with a fixed {@link UIMessage} subtype and optional default stream options. See {@link withUIMessage}. */ + withUIMessage, + /** Create a chat task with a fixed client data schema. See {@link withClientData}. */ + withClientData, + /** Create a server-side helper for starting (or resuming) a Session for a chatId. See {@link createChatStartSessionAction}. */ + createStartSessionAction: createChatStartSessionAction, + /** Pipe a stream to the chat transport. See {@link pipeChat}. */ + pipe: pipeChat, + /** Create a per-run typed local. See {@link chatLocal}. */ + local: chatLocal, + /** Create a public access token for a chat task. See {@link createChatAccessToken}. */ + createAccessToken: createChatAccessToken, + /** Override the turn timeout at runtime (duration string). See {@link setTurnTimeout}. */ + setTurnTimeout, + /** Override the turn timeout at runtime (seconds). See {@link setTurnTimeoutInSeconds}. */ + setTurnTimeoutInSeconds, + /** Override the idle timeout at runtime. See {@link setIdleTimeoutInSeconds}. */ + setIdleTimeoutInSeconds, + /** Override toUIMessageStream() options for the current turn. See {@link setUIMessageStreamOptions}. */ + setUIMessageStreamOptions, + /** Check if the current turn was stopped by the user. See {@link isStopped}. */ + isStopped, + /** Request that the run exits after the current turn so the next message starts on the latest version. See {@link requestUpgrade}. */ + requestUpgrade, + /** Exit the run after the current turn completes, without any upgrade signal. See {@link endRun}. */ + endRun, + /** Clean up aborted parts from a UIMessage. See {@link cleanupAbortedParts}. */ + cleanupAbortedParts, + /** Register background work that runs in parallel with streaming. See {@link chatDefer}. */ + defer: chatDefer, + /** Queue model messages for injection at the next `prepareStep` boundary. See {@link injectBackgroundContext}. */ + inject: injectBackgroundContext, + /** Typed chat output stream for writing custom chunks or piping from subtasks. */ + stream: chatStream, + /** Write data parts that persist to the response message. See {@link chatResponse}. */ + response: chatResponse, + /** Pre-built input stream for receiving messages from the transport. */ + messages: messagesInput, + /** Create a managed stop signal wired to the stop input stream. See {@link createStopSignal}. */ + createStopSignal, + /** Signal the frontend that the current turn is complete. See {@link chatWriteTurnComplete}. */ + writeTurnComplete: chatWriteTurnComplete, + /** Pipe a stream and capture the response message. See {@link pipeChatAndCapture}. */ + pipeAndCapture: pipeChatAndCapture, + /** Message accumulator class for raw task chat. See {@link ChatMessageAccumulator}. */ + MessageAccumulator: ChatMessageAccumulator, + /** Create a chat session (async iterator). See {@link createChatSession}. */ + createSession: createChatSession, + /** + * Store and retrieve a resolved prompt for the current run. + * + * - `chat.prompt.set(resolved)` — store a `ResolvedPrompt` or plain string + * - `chat.prompt()` — read the stored prompt (throws if not set) + */ + prompt: Object.assign(getChatPrompt, { set: setChatPrompt }), + /** + * Store and retrieve resolved agent skills for the current run. + * + * - `chat.skills.set([...])` — store an array of `ResolvedSkill`s + * - `chat.skills()` — read the stored skills (returns undefined if none) + * + * Skills set here are automatically injected into `streamText` by + * `chat.toStreamTextOptions()`: skill descriptions land in the system + * prompt and `loadSkill` / `readFile` / `bash` tools are added to the + * tool set. + */ + skills: Object.assign(getChatSkills, { set: setChatSkills }), + /** + * Returns an options object ready to spread into `streamText()`. + * Reads the stored prompt and returns `{ system, experimental_telemetry, ...config }`. + * Returns `{}` if no prompt has been set. + */ + toStreamTextOptions, + /** + * Replace the accumulated conversation messages for compaction. + * Call from `onTurnStart` or `onTurnComplete`. Takes `UIMessage[]` and + * converts to `ModelMessage[]` internally. + */ + setMessages: setChatMessages, + /** + * Imperative API for modifying the accumulated message history. + * Supports rollback, remove, replace, slice, and full replacement. + * Can be called from any hook or `run()`. + */ + history: chatHistory, + /** Check if it's safe to compact messages (no in-flight tool calls). */ + isCompactionSafe, + /** Returns a `prepareStep` function that handles context compaction automatically. */ + compactionStep: chatCompactionStep, + /** Low-level compaction for use inside a custom `prepareStep`. */ + compact: chatCompact, + /** Read the current compaction state (summary + base message count). */ + getCompactionState, + /** + * The friendlyId (`session_*`) of the backing Session for the current chat.agent run. + * Useful for persisting alongside `runId` so reloads can resume the same session. + * Throws if called outside a chat.agent `run()` or hook. + */ + get sessionId(): string { + return getChatSession().id; + }, +}; + +/** + * Writes a turn-complete control chunk to the chat output stream. + * The frontend transport intercepts this to close the ReadableStream for the current turn. + * @internal + */ +async function writeTurnCompleteChunk( + chatId?: string, + publicAccessToken?: string +): Promise { + const { waitUntilComplete } = chatStream.writer({ + spanName: "turn complete", + collapsed: true, + execute: ({ write }) => { + // Transport-intercepted control chunk — not a valid UIMessageChunk + // type but travels on the same session.out stream. + write({ + type: "trigger:turn-complete", + ...(publicAccessToken ? { publicAccessToken } : {}), + } as unknown as UIMessageChunk); + }, + }); + return await waitUntilComplete(); +} + +/** + * Hand off the session to a fresh run on the latest version and emit a + * telemetry chunk on `.out` so the transport can hide it from the + * consumer. + * + * Server-side flow (in `POST /sessions/:id/end-and-continue`): + * 1. Trigger a new run with the session's `triggerConfig` + * 2. Atomically swap `Session.currentRunId` to the new run's id + * (via optimistic claim keyed on the calling run's id) + * 3. Return the new runId + * + * The transport keeps its `.out` SSE open across the swap — v1's last + * chunks land, v2's new chunks land on the same stream (S2 keys on + * the session, not the run). The transport filters + * `trigger:upgrade-required` for cleanliness; consumers see no gap. + * + * If the swap fails (no current run, no env auth, etc.) we still emit + * the chunk and exit. The next `.in/append` will trigger a new run via + * the probe path; it just won't be quite as seamless. + * + * @internal + */ +async function writeUpgradeRequiredChunk(): Promise { + const ctx = taskContext.ctx; + const chatId = ctx?.run.id ? getChatIdFromContext() : undefined; + const callingRunId = ctx?.run.id; + + if (chatId && callingRunId) { + const apiClient = apiClientManager.clientOrThrow(); + try { + await apiClient.endAndContinueSession(chatId, { + callingRunId, + reason: "upgrade", + }); + } catch (error) { + // Non-fatal: the next `.in/append` re-triggers via the probe. + // Swallow rather than throw so we still emit the chunk + exit. + logger.warn("end-and-continue failed; falling back to probe-on-append", { + chatId, + callingRunId, + error, + }); + } + } + + const { waitUntilComplete } = chatStream.writer({ + spanName: "upgrade required", + collapsed: true, + execute: ({ write }) => { + write({ + type: "trigger:upgrade-required", + } as unknown as UIMessageChunk); + }, + }); + return await waitUntilComplete(); +} + +/** + * Resolves the current chat's `chatId` (used as session externalId) from + * the bound session handle. Returns `undefined` if no agent is bound — + * shouldn't happen at the call sites that invoke + * `writeUpgradeRequiredChunk`, but defensive against misuse. + * @internal + */ +function getChatIdFromContext(): string | undefined { + return locals.get(chatSessionHandleKey)?.id; +} + +/** + * Extracts the text content of the last user message from a UIMessage array. + * Returns undefined if no user message is found. + * @internal + */ +function extractLastUserMessageText(messages: UIMessage[]): string | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const msg = messages[i]!; + if (msg.role !== "user") continue; + + // UIMessage uses parts array + if (msg.parts) { + const textParts = msg.parts + .filter((p: any) => p.type === "text" && p.text) + .map((p: any) => p.text as string); + if (textParts.length > 0) { + return textParts.join("\n"); + } + } + + break; + } + + return undefined; +} + +/** + * Strips ephemeral OpenAI Responses API `itemId` from a UIMessage's parts. + * + * The OpenAI Responses provider attaches `itemId` to message parts via + * `providerMetadata.openai.itemId`. These IDs are ephemeral — sending them + * back in a subsequent `streamText` call causes 404s because the provider + * can't find the referenced item (especially for stopped/partial responses). + * + * @internal + */ +function stripProviderMetadata(message: UIMessage): UIMessage { + if (!message.parts) return message; + return { + ...message, + parts: message.parts.map((part: any) => { + const openai = part.providerMetadata?.openai; + if (!openai?.itemId) return part; + + const { itemId, ...restOpenai } = openai; + const { openai: _, ...restProviders } = part.providerMetadata; + return { + ...part, + providerMetadata: { + ...restProviders, + ...(Object.keys(restOpenai).length > 0 ? { openai: restOpenai } : {}), + }, + }; + }), + }; +} diff --git a/packages/trigger-sdk/src/v3/auth.ts b/packages/trigger-sdk/src/v3/auth.ts index 1f2df463b6f..16de798b0a3 100644 --- a/packages/trigger-sdk/src/v3/auth.ts +++ b/packages/trigger-sdk/src/v3/auth.ts @@ -67,6 +67,17 @@ type PublicTokenPermissionProperties = { * Grant access to send data to input streams on specific runs */ inputStreams?: string | string[]; + + /** + * Grant access to specific Sessions (the durable, typed I/O primitive that + * outlives a single run). Use the session's friendlyId (e.g. `session_abc`). + * + * `read:sessions:{id}` lets the bearer read both the `.out` and `.in` + * channels and list runs on the session. `write:sessions:{id}` lets the + * bearer append to the session's channels. `trigger:sessions:{id}` permits + * triggering new runs on the session. + */ + sessions?: string | string[]; }; export type PublicTokenPermissions = { diff --git a/packages/trigger-sdk/src/v3/chat-client.ts b/packages/trigger-sdk/src/v3/chat-client.ts new file mode 100644 index 00000000000..639012cdb88 --- /dev/null +++ b/packages/trigger-sdk/src/v3/chat-client.ts @@ -0,0 +1,797 @@ +/** + * Server-side API for chatting with Trigger.dev agents. + * + * @example + * ```ts + * import { AgentChat } from "@trigger.dev/sdk/chat"; + * + * const chat = new AgentChat({ + * agent: "my-agent", + * clientData: { userId: "user_123" }, + * }); + * + * const stream = await chat.sendMessage("Review PR #1"); + * const text = await stream.text(); + * await chat.close(); + * ``` + */ + +import type { SessionTriggerConfig, Task } from "@trigger.dev/core/v3"; +import type { ModelMessage, UIMessage, UIMessageChunk } from "ai"; +import { readUIMessageStream } from "ai"; +import { ApiClient, SSEStreamSubscription, apiClientManager } from "@trigger.dev/core/v3"; +import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js"; +import { sessions } from "./sessions.js"; + +// ─── Type inference ──────────────────────────────────────────────── + +/** Extract the client data (metadata) type from a chat agent task. */ +export type InferChatClientData = + T extends Task, any> + ? unknown extends TMetadata + ? Record + : TMetadata + : Record; + +/** Extract the UIMessage type from a chat agent task. */ +export type InferChatUIMessage = + T extends Task, any> + ? TUIMessage + : UIMessage; + +// ─── Types ───────────────────────────────────────────────────────── + +/** Persistable session state — store this to resume across requests. */ +export type ChatSession = { + /** Last SSE event ID seen on `session.out` — used to resume without replay. */ + lastEventId?: string; +}; + +export type AgentChatOptions = { + /** The agent task ID to trigger. */ + agent: string; + /** + * Conversation ID. Used for tagging runs and correlating messages. + * @default crypto.randomUUID() + */ + id?: string; + /** Client data included in every request. Typed from the agent's clientDataSchema. */ + clientData?: InferChatClientData; + /** + * Restore a previous session. Pass `lastEventId` from a previous + * request to resume the SSE stream without replaying old chunks. + */ + session?: ChatSession; + /** + * Called when a new run is triggered for this session (initial start). + * Useful for telemetry / dashboard linking. The runId is the + * friendlyId. + */ + onTriggered?: (event: { runId: string; chatId: string }) => void | Promise; + /** + * Called when a turn completes. Persist `lastEventId` for stream + * resumption across requests. + */ + onTurnComplete?: (event: { + chatId: string; + lastEventId?: string; + }) => void | Promise; + /** SSE timeout in seconds. @default 120 */ + streamTimeoutSeconds?: number; + /** + * Default trigger config used when starting a new session for this + * chat. Folded into `sessions.start({...triggerConfig})` body. + */ + triggerConfig?: SessionTriggerConfig; +}; + +// ─── ChatStream ──────────────────────────────────────────────────── + +/** Parsed tool call from the stream. */ +export type ChatToolCall = { + toolName: string; + toolCallId: string; + input: unknown; +}; + +/** Parsed tool result from the stream. */ +export type ChatToolResult = { + toolCallId: string; + output: unknown; +}; + +/** Accumulated result after a stream completes. */ +export type ChatStreamResult = { + text: string; + toolCalls: ChatToolCall[]; + toolResults: ChatToolResult[]; +}; + +/** + * A single turn's response stream from an agent. + * + * Pick one consumption mode: + * - `for await (const chunk of stream)` — typed UIMessageChunk iteration + * - `await stream.result()` — accumulated `{ text, toolCalls, toolResults }` + * - `await stream.text()` — just the text + * - `yield* stream.messages()` — sub-agent pattern (yields UIMessage snapshots) + */ +export class ChatStream { + private readonly _consumerStream: ReadableStream; + private readonly _messageCollector?: Promise; + private resultPromise: Promise | undefined; + /** @internal Last UIMessage snapshot from the assistant's response. */ + private lastAssistantMessage: UIMessage | undefined; + /** @internal Callback to capture the assistant's response message for accumulation. */ + private readonly onAssistantMessage?: (message: UIMessage) => void; + + constructor( + stream: ReadableStream, + onAssistantMessage?: (message: UIMessage) => void + ) { + this.onAssistantMessage = onAssistantMessage; + + if (onAssistantMessage) { + // Tee the stream: one branch for the consumer, one for message collection + const [consumer, collector] = stream.tee(); + this._consumerStream = consumer; + this._messageCollector = (async () => { + for await (const msg of readUIMessageStream({ stream: collector })) { + this.lastAssistantMessage = msg; + } + if (this.lastAssistantMessage) { + onAssistantMessage(this.lastAssistantMessage); + } + })(); + } else { + this._consumerStream = stream; + } + } + + /** The raw ReadableStream for direct use with AI SDK utilities. */ + get stream(): ReadableStream { + return this._consumerStream; + } + + async *[Symbol.asyncIterator](): AsyncIterableIterator { + const reader = this._consumerStream.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + yield value; + } + } finally { + reader.releaseLock(); + } + } + + /** + * Yields accumulated UIMessage snapshots for the sub-agent tool pattern. + * + * @example + * ```ts + * const stream = await chat.sendMessage("Research this topic"); + * yield* stream.messages(); + * ``` + */ + async *messages(): AsyncGenerator { + for await (const message of readUIMessageStream({ stream: this._consumerStream })) { + this.lastAssistantMessage = message; + yield message; + } + // When the constructor set up `_messageCollector` (because + // `onAssistantMessage` was provided), that collector IIFE owns + // firing the callback. Skipping it here prevents a double-invoke. + if (this.lastAssistantMessage && this.onAssistantMessage && !this._messageCollector) { + this.onAssistantMessage(this.lastAssistantMessage); + } + } + + /** Consume the stream and return the accumulated result. */ + result(): Promise { + if (!this.resultPromise) { + this.resultPromise = this.consumeStream(); + } + return this.resultPromise; + } + + /** Consume the stream and return just the text. */ + async text(): Promise { + return (await this.result()).text; + } + + private async consumeStream(): Promise { + let text = ""; + const toolCalls: ChatToolCall[] = []; + const toolResults: ChatToolResult[] = []; + + for await (const chunk of this) { + if (chunk.type === "text-delta") { + text += chunk.delta; + } else if (chunk.type === "tool-input-available") { + toolCalls.push({ + toolName: chunk.toolName, + toolCallId: chunk.toolCallId, + input: chunk.input, + }); + } else if (chunk.type === "tool-output-available") { + toolResults.push({ + toolCallId: chunk.toolCallId, + output: chunk.output, + }); + } + } + + return { text, toolCalls, toolResults }; + } +} + +// ─── Internal ────────────────────────────────────────────────────── + +type SessionState = { + lastEventId?: string; + skipToTurnComplete?: boolean; + /** True after the session has been started (sessions.start). */ + started: boolean; +}; + +// ─── AgentChat ───────────────────────────────────────────────────── + +/** + * A chat conversation with a Trigger.dev agent. + * + * @example + * ```ts + * // Simple usage + * const chat = new AgentChat({ agent: "my-agent" }); + * const text = await (await chat.sendMessage("Hello")).text(); + * await chat.close(); + * + * // Stateless request handler — persist and restore session + * const chat = new AgentChat({ + * agent: "my-agent", + * id: chatId, + * session: { lastEventId: savedLastEventId }, + * onTriggered: ({ runId }) => db.save(chatId, { runId }), + * onTurnComplete: ({ lastEventId }) => db.update(chatId, { lastEventId }), + * }); + * ``` + */ +export class AgentChat { + private readonly taskId: string; + private readonly chatId: string; + private readonly streamTimeoutSeconds: number; + private readonly clientData: Record | undefined; + private readonly triggerConfigDefault: SessionTriggerConfig | undefined; + private readonly onTriggered: AgentChatOptions["onTriggered"]; + private readonly onTurnComplete: AgentChatOptions["onTurnComplete"]; + + private state: SessionState; + + constructor(options: AgentChatOptions) { + this.taskId = options.agent; + this.chatId = options.id ?? crypto.randomUUID(); + this.streamTimeoutSeconds = options.streamTimeoutSeconds ?? 120; + this.clientData = options.clientData as Record | undefined; + this.triggerConfigDefault = options.triggerConfig; + this.onTriggered = options.onTriggered; + this.onTurnComplete = options.onTurnComplete; + + // Hydration: a non-empty `session` means the caller knows the + // session already exists (started in a previous request). Mark + // `started` so we don't re-`sessions.start()` on first message. + const hydrated = !!options.session; + this.state = { + lastEventId: options.session?.lastEventId, + started: hydrated, + }; + } + + /** The conversation ID. */ + get id(): string { + return this.chatId; + } + + /** Persistable session state — pass back via `options.session` to resume. */ + get session(): ChatSession { + return { lastEventId: this.state.lastEventId }; + } + + /** + * Eagerly start the session — creates the row and triggers the first + * run. The agent's `onPreload` hook fires immediately. Idempotent: a + * second call is a no-op. + */ + async preload(options?: { idleTimeoutInSeconds?: number }): Promise { + await this.ensureStarted({ idleTimeoutInSeconds: options?.idleTimeoutInSeconds }); + return this.session; + } + + /** + * Send a text message and get the response stream. + * + * @example + * ```ts + * const stream = await chat.sendMessage("Review PR #1"); + * const text = await stream.text(); + * ``` + */ + async sendMessage( + text: string, + options?: { abortSignal?: AbortSignal } + ): Promise { + const msgId = `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const message: UIMessage = { + id: msgId, + role: "user", + parts: [{ type: "text", text }], + }; + + const rawStream = await this.sendRaw([message], { abortSignal: options?.abortSignal }); + return new ChatStream(rawStream); + } + + /** Send raw UIMessage-like objects. Use `sendMessage()` for simple text. */ + async sendRaw( + messages: UIMessage[] | Array<{ + id: string; + role: string; + parts?: unknown[]; + [key: string]: unknown; + }>, + options?: { + trigger?: "submit-message" | "regenerate-message"; + abortSignal?: AbortSignal; + } + ): Promise> { + const triggerType = options?.trigger ?? "submit-message"; + + // Make sure the session exists (and a run is alive). The .in/append + // handler on the server probes currentRunId on every call and + // re-triggers if needed — so we don't need to track runId here. + await this.ensureStarted(); + + // Slim wire — at most ONE message per record. The agent rebuilds prior + // history from its durable S3 snapshot + session.out replay at run + // boot. `regenerate-message` omits `message` (the agent slices its own + // history). See plan vivid-humming-bonbon. + if (triggerType === "submit-message" && messages.length === 0) { + throw new Error( + "AgentChat.sendRaw: 'submit-message' trigger requires at least one message" + ); + } + const lastIfSubmit = + triggerType === "submit-message" + ? (messages.at(-1) as UIMessage | undefined) + : undefined; + const payload: ChatTaskWirePayload = { + ...(lastIfSubmit ? { message: lastIfSubmit } : {}), + chatId: this.chatId, + trigger: triggerType, + metadata: this.clientData, + } as ChatTaskWirePayload; + + const api = this.createApiClient(); + await api.appendToSessionStream( + this.chatId, + "in", + serializeInputChunk({ kind: "message", payload }) + ); + + return this.subscribeToSessionStream(options?.abortSignal); + } + + /** Send a steering message during an active stream. */ + async steer(text: string): Promise { + if (!this.state.started) return false; + + const payload: ChatTaskWirePayload = { + message: { + id: `steer-${Date.now()}`, + role: "user", + parts: [{ type: "text", text }], + } as unknown as UIMessage, + chatId: this.chatId, + trigger: "submit-message" as const, + metadata: this.clientData, + }; + + try { + const api = this.createApiClient(); + await api.appendToSessionStream( + this.chatId, + "in", + serializeInputChunk({ + kind: "message", + payload, + }) + ); + return true; + } catch { + return false; + } + } + + /** Stop the current generation (agent stays alive for next turn). */ + async stop(): Promise { + if (!this.state.started) return; + + this.state.skipToTurnComplete = true; + const api = this.createApiClient(); + await api + .appendToSessionStream( + this.chatId, + "in", + serializeInputChunk({ kind: "stop" }) + ) + .catch(() => {}); + } + + /** + * Hand over from a `chat.handover` route handler to a parked + * `handover-prepare` agent run. Wakes the run, which seeds its + * accumulators with `partialAssistantMessage` and continues from + * tool execution onward — the model call for step 1 is skipped. + * + * Used internally by `chat.handover`; not part of the customer + * surface. + */ + async sendHandover(args: { + partialAssistantMessage: ModelMessage[]; + /** + * UI messageId from the customer's step-1 stream — propagated to + * the agent so its post-handover chunks merge into the same + * assistant message on the browser. + */ + messageId?: string; + /** + * Whether the customer's step 1 is the final response (pure-text + * finish). When true, the agent runs hooks but skips the LLM + * call. When false, the agent runs `streamText` which executes + * pending tool-calls and continues from step 2. + */ + isFinal: boolean; + }): Promise { + const api = this.createApiClient(); + await api.appendToSessionStream( + this.chatId, + "in", + serializeInputChunk({ + kind: "handover", + partialAssistantMessage: args.partialAssistantMessage, + messageId: args.messageId, + isFinal: args.isFinal, + }) + ); + } + + /** + * Tell a parked `handover-prepare` agent run that the customer's + * first turn finished pure-text (no tool calls) — the run exits + * cleanly without making an LLM call. + * + * Used internally by `chat.handover`; not part of the customer + * surface. + */ + async sendHandoverSkip(): Promise { + const api = this.createApiClient(); + await api.appendToSessionStream( + this.chatId, + "in", + serializeInputChunk({ kind: "handover-skip" }) + ); + } + + /** + * Send a custom action to the agent. + * + * Actions are not turns. They wake the agent, fire `hydrateMessages` + * (if configured) and `onAction` only — no `onTurnStart` / + * `prepareMessages` / `onBeforeTurnComplete` / `onTurnComplete`, no + * `run()` invocation. + * + * The action payload is validated against the agent's `actionSchema` + * on the backend. Use `chat.history.*` inside `onAction` to mutate + * state. To produce a model response from the action, return a + * `StreamTextResult` (or `string` / `UIMessage`) from `onAction` — + * the returned stream is auto-piped over this stream. When `onAction` + * returns `void`, the action is side-effect-only and the returned + * stream completes immediately with `trigger:turn-complete`. + * + * @returns A `ChatStream`. For void actions the stream completes + * immediately. For actions that return a model response, the stream + * carries the assistant chunks. + * + * @example + * ```ts + * const stream = await agentChat.sendAction({ type: "undo" }); + * for await (const chunk of stream) { + * if (chunk.type === "text-delta") process.stdout.write(chunk.delta); + * } + * ``` + */ + async sendAction( + action: unknown, + options?: { abortSignal?: AbortSignal } + ): Promise { + await this.ensureStarted(); + + const payload: ChatTaskWirePayload = { + chatId: this.chatId, + trigger: "action" as const, + action, + metadata: this.clientData, + }; + + try { + const api = this.createApiClient(); + await api.appendToSessionStream( + this.chatId, + "in", + serializeInputChunk({ + kind: "message", + payload, + }) + ); + } catch { + throw new Error("Failed to send action. The session may have ended."); + } + + const rawStream = this.subscribeToSessionStream(options?.abortSignal); + return new ChatStream(rawStream); + } + + /** Close the conversation — agent exits its loop gracefully. */ + async close(): Promise { + if (!this.state.started) return false; + + try { + const api = this.createApiClient(); + await api.appendToSessionStream( + this.chatId, + "in", + serializeInputChunk({ + kind: "message", + payload: { + chatId: this.chatId, + trigger: "close", + } satisfies ChatTaskWirePayload, + }) + ); + this.state = { ...this.state, started: false }; + return true; + } catch { + return false; + } + } + + /** Reconnect to the response stream (e.g. after a disconnect). */ + async reconnect( + abortSignal?: AbortSignal + ): Promise | null> { + if (!this.state.started) return null; + return this.subscribeToSessionStream(abortSignal, { sendStopOnAbort: false }); + } + + // ─── Private ─────────────────────────────────────────────────── + + private createApiClient(): ApiClient { + const baseURL = apiClientManager.baseURL ?? "https://api.trigger.dev"; + const accessToken = apiClientManager.accessToken ?? ""; + return new ApiClient(baseURL, accessToken); + } + + /** + * Idempotent: `sessions.start` upserts on `(env, externalId)`. Two + * concurrent AgentChat instances on the same chatId converge to the + * same session. + */ + private async ensureStarted(options?: { idleTimeoutInSeconds?: number }): Promise { + if (this.state.started) return; + + const triggerConfig: SessionTriggerConfig = { + basePayload: { + // `trigger: "preload"` mirrors the browser-mediated + // `chat.createStartSessionAction` shape so the agent runtime fires + // `onPreload` (not `onChatStart` with `preloaded: true`). Without + // this, AgentChat's first run skips both preload and start hooks, + // which is where customer apps typically upsert their Chat row. + // Slim wire — preload carries no message body. + trigger: "preload", + ...(this.triggerConfigDefault?.basePayload ?? {}), + chatId: this.chatId, + ...(this.clientData ? { metadata: this.clientData } : {}), + }, + ...(this.triggerConfigDefault?.machine + ? { machine: this.triggerConfigDefault.machine } + : {}), + ...(this.triggerConfigDefault?.queue + ? { queue: this.triggerConfigDefault.queue } + : {}), + ...(this.triggerConfigDefault?.tags + ? { tags: this.triggerConfigDefault.tags } + : {}), + ...(this.triggerConfigDefault?.maxAttempts !== undefined + ? { maxAttempts: this.triggerConfigDefault.maxAttempts } + : {}), + ...(options?.idleTimeoutInSeconds !== undefined || + this.triggerConfigDefault?.idleTimeoutInSeconds !== undefined + ? { + idleTimeoutInSeconds: + options?.idleTimeoutInSeconds ?? + this.triggerConfigDefault?.idleTimeoutInSeconds!, + } + : {}), + }; + + const created = await sessions.start({ + type: "chat.agent", + externalId: this.chatId, + taskIdentifier: this.taskId, + triggerConfig, + }); + + this.state.started = true; + await this.onTriggered?.({ + runId: created.runId, + chatId: this.chatId, + }); + } + + private subscribeToSessionStream( + abortSignal: AbortSignal | undefined, + options?: { sendStopOnAbort?: boolean } + ): ReadableStream { + const state = this.state; + const baseURL = apiClientManager.baseURL ?? "https://api.trigger.dev"; + const accessToken = apiClientManager.accessToken ?? ""; + const onTurnComplete = this.onTurnComplete; + const chatId = this.chatId; + + const internalAbort = new AbortController(); + const combinedSignal = abortSignal + ? AbortSignal.any([abortSignal, internalAbort.signal]) + : internalAbort.signal; + + if (abortSignal) { + abortSignal.addEventListener( + "abort", + () => { + if (options?.sendStopOnAbort !== false) { + state.skipToTurnComplete = true; + const api = new ApiClient(baseURL, accessToken); + api + .appendToSessionStream( + chatId, + "in", + serializeInputChunk({ kind: "stop" }) + ) + .catch(() => {}); + } + internalAbort.abort(); + }, + { once: true } + ); + } + + const streamUrl = `${baseURL}/realtime/v1/sessions/${encodeURIComponent(chatId)}/out`; + + return new ReadableStream({ + start: async (controller) => { + try { + const subscription = new SSEStreamSubscription(streamUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + signal: combinedSignal, + timeoutInSeconds: this.streamTimeoutSeconds, + lastEventId: state.lastEventId, + }); + const sseStream = await subscription.subscribe(); + const reader = sseStream.getReader(); + + try { + while (true) { + const next = await reader.read(); + if (next.done) { + controller.close(); + return; + } + + if (combinedSignal.aborted) { + internalAbort.abort(); + await reader.cancel(); + controller.close(); + return; + } + + const value = next.value; + + if (value.id) state.lastEventId = value.id; + + // Session records arrive as raw JSON strings (the server + // wraps `{data, id}` on S2). Parse back into objects so + // the control-flow below can inspect chunk.type. + let chunkObj: Record | null = null; + if (value.chunk != null) { + if (typeof value.chunk === "string") { + try { + chunkObj = JSON.parse(value.chunk) as Record; + } catch { + chunkObj = null; + } + } else if (typeof value.chunk === "object") { + chunkObj = value.chunk as Record; + } + } + if (!chunkObj) continue; + + const chunk = chunkObj; + + if (state.skipToTurnComplete) { + if (chunk.type === "trigger:turn-complete") { + state.skipToTurnComplete = false; + } + continue; + } + + if (chunk.type === "trigger:upgrade-required") { + // Server has already triggered the new run via + // `end-and-continue`; v2's chunks arrive on the same + // S2 stream. Filter the marker for cleanliness and + // keep reading. + continue; + } + + if (chunk.type === "trigger:turn-complete") { + // Customer's callback may be async (e.g. persisting + // lastEventId to a DB). Wrap so a rejected Promise + // doesn't surface as an unhandled rejection — that + // would crash Node under `--unhandled-rejections=throw`. + Promise.resolve( + onTurnComplete?.({ + chatId, + lastEventId: state.lastEventId, + }) + ).catch(() => {}); + internalAbort.abort(); + try { + controller.close(); + } catch { + // Controller may already be closed + } + return; + } + + controller.enqueue(chunk as unknown as UIMessageChunk); + } + } catch (readError) { + reader.releaseLock(); + throw readError; + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + try { + controller.close(); + } catch { + // Controller may already be closed + } + return; + } + controller.error(error); + } + }, + }); + } +} + +/** + * Serialize a {@link ChatInputChunk} for `POST …/sessions/:session/:io/append`. + * Session channel records are raw JSON strings — the server wraps them + * in `{ data: , id }` for S2 storage and the subscribe side + * parses the string back for consumers. + */ +function serializeInputChunk(chunk: ChatInputChunk): string { + return JSON.stringify(chunk); +} diff --git a/packages/trigger-sdk/src/v3/chat-react.ts b/packages/trigger-sdk/src/v3/chat-react.ts new file mode 100644 index 00000000000..b14ac825bcb --- /dev/null +++ b/packages/trigger-sdk/src/v3/chat-react.ts @@ -0,0 +1,457 @@ +"use client"; + +/** + * @module @trigger.dev/sdk/chat/react + * + * React hooks for AI SDK chat transport integration. + * Use alongside `@trigger.dev/sdk/chat` for a type-safe, ergonomic DX. + * + * @example + * ```tsx + * import { useChat } from "@ai-sdk/react"; + * import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; + * import type { chat } from "@/trigger/chat"; + * + * function Chat() { + * const transport = useTriggerChatTransport({ + * task: "ai-chat", + * accessToken: ({ chatId }) => fetchToken(chatId), + * }); + * + * const { messages, sendMessage } = useChat({ transport }); + * } + * ``` + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { TriggerChatTransport, type TriggerChatTransportOptions } from "./chat.js"; +import type { AnyTask, TaskIdentifier } from "@trigger.dev/core/v3"; +import { + PENDING_MESSAGE_INJECTED_TYPE, + type InferChatClientData, + type InferChatUIMessage, +} from "./ai-shared.js"; +import type { UIMessage, ChatRequestOptions } from "ai"; + +/** + * Options for `useTriggerChatTransport`, with a type-safe `task` field. + * + * Pass a task type parameter to get compile-time validation of the task ID: + * ```ts + * useTriggerChatTransport({ task: "my-task", ... }) + * ``` + */ +export type UseTriggerChatTransportOptions = Omit< + TriggerChatTransportOptions>, + "task" +> & { + /** The task ID. Strongly typed when a task type parameter is provided. */ + task: TaskIdentifier; +}; + +export type { InferChatUIMessage }; + +/** + * React hook that creates and memoizes a `TriggerChatTransport` instance. + * + * The transport is created once on first render and reused for the lifetime + * of the component. This avoids the need for `useMemo` and ensures the + * transport's internal session state (run IDs, lastEventId, etc.) + * is preserved across re-renders. + * + * For dynamic access tokens, pass a function — it will be called on each + * request without needing to recreate the transport. + * + * The `onSessionChange` callback is kept in a ref so the transport always + * calls the latest version without needing to be recreated. + * + * @example + * ```tsx + * import { useChat } from "@ai-sdk/react"; + * import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react"; + * import type { chat } from "@/trigger/chat"; + * + * function Chat() { + * const transport = useTriggerChatTransport({ + * task: "ai-chat", + * accessToken: ({ chatId }) => fetchToken(chatId), + * }); + * + * const { messages, sendMessage } = useChat({ transport }); + * } + * ``` + */ +export function useTriggerChatTransport( + options: UseTriggerChatTransportOptions +): TriggerChatTransport { + const ref = useRef(null); + if (ref.current === null) { + ref.current = new TriggerChatTransport(options as TriggerChatTransportOptions); + } + + // Keep callbacks up to date without recreating the transport. + const { onSessionChange, clientData } = options; + useEffect(() => { + ref.current?.setOnSessionChange(onSessionChange); + }, [onSessionChange]); + + // Keep `clientData` up to date so the transport's per-turn merge and + // `startSession` callback both see the latest value without + // reconstructing the transport. + useEffect(() => { + ref.current?.setClientData(clientData as Record | undefined); + }, [clientData]); + + // Note: dispose() is NOT called in effect cleanup because React strict mode + // runs cleanup+re-setup, but the transport lives in a ref and isn't recreated. + // Calling dispose() would permanently close the BroadcastChannel. + // The coordinator's beforeunload handler handles tab close cleanup instead. + + return ref.current; +} + +/** + * Sync chat messages across browser tabs. + * + * Requires `multiTab: true` on the transport. Handles: + * - Tracking read-only state (`isReadOnly`) when another tab is active + * - Broadcasting messages from the active tab to other tabs + * - Receiving messages from other tabs and updating local state via `setMessages` + * + * @example + * ```tsx + * const transport = useTriggerChatTransport({ task: "my-chat", multiTab: true, accessToken }); + * const { messages, setMessages } = useChat({ id: chatId, transport }); + * const { isReadOnly } = useMultiTabChat(transport, chatId, messages, setMessages); + * + * + * ``` + */ +export function useMultiTabChat( + transport: TriggerChatTransport, + chatId: string, + messages: T[], + setMessages: (messages: T[]) => void +): { isReadOnly: boolean } { + const [isReadOnly, setIsReadOnly] = useState(() => transport.isReadOnly(chatId)); + + // Track read-only state + useEffect(() => { + const listener = (id: string, readOnly: boolean) => { + if (id === chatId) setIsReadOnly(readOnly); + }; + transport.addReadOnlyListener(listener); + setIsReadOnly(transport.isReadOnly(chatId)); + return () => transport.removeReadOnlyListener(listener); + }, [transport, chatId]); + + // Active tab: broadcast messages to other tabs on change. + // Only broadcast when THIS tab holds the claim (is the current sender). + // Deferred via requestIdleCallback so the structured clone in + // BroadcastChannel.postMessage never blocks rendering during streaming. + const idleRef = useRef | null>(null); + const latestMessagesRef = useRef(messages); + latestMessagesRef.current = messages; + + useEffect(() => { + if (!transport.hasClaim(chatId) || messages.length === 0) return; + if (idleRef.current !== null) return; // Already scheduled + + const schedule = + typeof requestIdleCallback === "function" + ? requestIdleCallback + : (fn: () => void) => setTimeout(fn, 50); + + idleRef.current = schedule(() => { + idleRef.current = null; + if (transport.hasClaim(chatId)) { + transport.broadcastMessages(chatId, latestMessagesRef.current as unknown[]); + } + }); + }, [transport, chatId, messages]); + + // Flush final state when claim is released (turn complete) + useEffect(() => { + if (!transport.hasClaim(chatId) && latestMessagesRef.current.length > 0) { + if (idleRef.current !== null) { + const cancel = + typeof cancelIdleCallback === "function" + ? cancelIdleCallback + : clearTimeout; + cancel(idleRef.current as any); + idleRef.current = null; + } + transport.broadcastMessages(chatId, latestMessagesRef.current as unknown[]); + } + }, [transport, chatId, isReadOnly]); + + // Read-only tab: receive messages from the active tab + useEffect(() => { + const listener = (id: string, msgs: unknown[]) => { + if (id === chatId) { + setMessages(msgs as T[]); + } + }; + transport.addMessagesListener(listener); + return () => transport.removeMessagesListener(listener); + }, [transport, chatId, setMessages]); + + return { isReadOnly }; +} + +// --------------------------------------------------------------------------- +// usePendingMessages — manage steering messages during streaming +// --------------------------------------------------------------------------- + +/** A pending message tracked by `usePendingMessages`. */ +export type PendingMessage = { + id: string; + text: string; + /** How this message is being handled. */ + mode: "steering" | "queued"; + /** Whether the backend confirmed this message was injected mid-response. */ + injected: boolean; +}; + +/** Options for `usePendingMessages`. */ +export type UsePendingMessagesOptions = { + /** The chat transport instance. */ + transport: TriggerChatTransport; + /** The chat session ID. */ + chatId: string; + /** The current useChat status. */ + status: string; + /** The current messages from useChat. */ + messages: TUIMessage[]; + /** The setMessages function from useChat. */ + setMessages: (fn: TUIMessage[] | ((prev: TUIMessage[]) => TUIMessage[])) => void; + /** The sendMessage function from useChat. */ + sendMessage: (message: { text: string }, options?: ChatRequestOptions) => void; + /** Metadata to include when sending (e.g. `{ model }` for model selection). */ + metadata?: Record; +}; + +/** A message embedded in an injection point data part. */ +export type InjectedMessage = { + id: string; + text: string; +}; + +/** Return value of `usePendingMessages`. */ +export type UsePendingMessagesReturn = { + /** Current pending messages with their mode and injection status. */ + pending: PendingMessage[]; + /** Send a steering message during streaming, or a normal message when ready. */ + steer: (text: string) => void; + /** Queue a message for the next turn (sent after current response finishes). */ + queue: (text: string) => void; + /** Promote a queued message to a steering message (sends via input stream immediately). */ + promoteToSteering: (id: string) => void; + /** Check if an assistant message part is an injection point. */ + isInjectionPoint: (part: unknown) => boolean; + /** Get the injected message IDs from an injection point part. */ + getInjectedMessageIds: (part: unknown) => string[]; + /** Get the injected messages (id + text) from an injection point part. Self-contained — works after turn complete. */ + getInjectedMessages: (part: unknown) => InjectedMessage[]; +}; + +/** + * React hook for managing pending messages (steering) during streaming. + * + * Handles: + * - Sending messages via input stream during streaming (bypassing useChat) + * - Tracking which messages were injected mid-response vs queued for next turn + * - Inserting injected messages into the conversation on turn complete + * - Auto-sending non-injected messages as the next turn + * + * @example + * ```tsx + * const pending = usePendingMessages({ + * transport, chatId, status, messages, setMessages, sendMessage, + * metadata: { model }, + * }); + * + * // In the form: + *
{ + * e.preventDefault(); + * pending.send(input); + * setInput(""); + * }}> + * + * // Render pending messages: + * {pending.pending.map(msg => ( + *
{msg.text} — {msg.injected ? "Injected" : "Pending"}
+ * ))} + * + * // Render injection points inline in assistant messages: + * {msg.parts.map((part, i) => + * pending.isInjectionPoint(part) + * ? + * : + * )} + * ``` + */ +export function usePendingMessages( + options: UsePendingMessagesOptions +): UsePendingMessagesReturn { + const { transport, chatId, status, messages, setMessages, sendMessage, metadata } = options; + + // Internal state: track messages with their mode + type InternalMessage = TUIMessage & { _mode: "steering" | "queued" }; + const [pendingMsgs, setPendingMsgs] = useState([]); + const injectedIdsRef = useRef>(new Set()); + const prevStatusRef = useRef(status); + + // Watch for injection confirmation chunks in streaming messages + useEffect(() => { + if (status !== "streaming") return; + let newlyInjected = false; + for (const msg of messages) { + if (msg.role !== "assistant") continue; + for (const part of msg.parts ?? []) { + if ((part as any).type === PENDING_MESSAGE_INJECTED_TYPE) { + const messageIds = (part as any).data?.messageIds; + if (Array.isArray(messageIds)) { + for (const id of messageIds) { + if (!injectedIdsRef.current.has(id)) { + injectedIdsRef.current.add(id); + newlyInjected = true; + } + } + } + } + } + } + // Remove injected steering messages from the pending overlay immediately + if (newlyInjected) { + setPendingMsgs((prev) => prev.filter((m) => !injectedIdsRef.current.has(m.id))); + } + }, [status, messages]); + + // Handle turn completion + useEffect(() => { + const turnCompleted = prevStatusRef.current === "streaming" && status === "ready"; + prevStatusRef.current = status; + if (!turnCompleted) return; + + // Auto-send non-injected messages as the next turn. + // This includes queued messages AND steering messages that weren't + // injected (arrived too late, no prepareStep boundary, etc.). + // Note: steering messages were also sent via sendPendingMessage to + // the backend's wire buffer, so the backend may already have them. + // Calling sendMessage here ensures useChat subscribes to the response. + const toSend = pendingMsgs.filter((m) => !injectedIdsRef.current.has(m.id)); + + // Clean up + setPendingMsgs([]); + injectedIdsRef.current.clear(); + promotedIdsRef.current.clear(); + + // Auto-send as next turn + if (toSend.length > 0) { + const text = toSend.map((m) => (m.parts?.[0] as any)?.text ?? "").join("\n"); + sendMessage({ text }, metadata ? { metadata } : undefined); + } + }, [status, pendingMsgs, sendMessage, metadata, messages]); + + // Send a steering message (injected mid-response via prepareStep) + const steer = useCallback( + (text: string) => { + if (status === "streaming") { + const msg = { + id: crypto.randomUUID(), + role: "user" as const, + parts: [{ type: "text" as const, text }], + _mode: "steering" as const, + } as InternalMessage; + transport.sendPendingMessage(chatId, msg, metadata); + setPendingMsgs((prev) => [...prev, msg]); + } else { + // Not streaming — just send normally + sendMessage({ text }, metadata ? { metadata } : undefined); + } + }, + [status, transport, chatId, sendMessage, metadata] + ); + + // Queue a message for the next turn (no injection attempt) + const queue = useCallback( + (text: string) => { + if (status === "streaming") { + const msg = { + id: crypto.randomUUID(), + role: "user" as const, + parts: [{ type: "text" as const, text }], + _mode: "queued" as const, + } as InternalMessage; + setPendingMsgs((prev) => [...prev, msg]); + } else { + sendMessage({ text }, metadata ? { metadata } : undefined); + } + }, + [status, sendMessage, metadata] + ); + + // Promote a queued message to steering (send via input stream immediately) + const promotedIdsRef = useRef>(new Set()); + const promoteToSteering = useCallback( + (id: string) => { + // Guard against double-click — ref check is synchronous + if (promotedIdsRef.current.has(id)) { + return; + } + promotedIdsRef.current.add(id); + + setPendingMsgs((prev) => { + const msg = prev.find((m) => m.id === id); + if (!msg || msg._mode !== "queued") return prev; + transport.sendPendingMessage(chatId, msg, metadata); + return prev.map((m) => (m.id === id ? { ...m, _mode: "steering" as const } : m)); + }); + }, + [transport, chatId, metadata] + ); + + const isInjectionPoint = useCallback( + (part: unknown): boolean => + typeof part === "object" && + part !== null && + (part as any).type === PENDING_MESSAGE_INJECTED_TYPE, + [] + ); + + const getInjectedMessageIds = useCallback( + (part: unknown): string[] => { + if (!isInjectionPoint(part)) return []; + const ids = (part as any).data?.messageIds; + return Array.isArray(ids) ? ids : []; + }, + [isInjectionPoint] + ); + + const getInjectedMessages = useCallback( + (part: unknown): InjectedMessage[] => { + if (!isInjectionPoint(part)) return []; + const msgs = (part as any).data?.messages; + return Array.isArray(msgs) ? msgs : []; + }, + [isInjectionPoint] + ); + + const pending: PendingMessage[] = pendingMsgs.map((m) => ({ + id: m.id, + text: (m.parts?.[0] as any)?.text ?? "", + mode: m._mode, + injected: injectedIdsRef.current.has(m.id), + })); + + return { + pending, + steer, + queue, + promoteToSteering, + isInjectionPoint, + getInjectedMessageIds, + getInjectedMessages, + }; +} diff --git a/packages/trigger-sdk/src/v3/chat-server.test.ts b/packages/trigger-sdk/src/v3/chat-server.test.ts new file mode 100644 index 00000000000..dc9ef11788f --- /dev/null +++ b/packages/trigger-sdk/src/v3/chat-server.test.ts @@ -0,0 +1,617 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { simulateReadableStream, streamText } from "ai"; +import type { UIMessageChunk } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; + +// Stub `SessionStreamInstance` so the handler's S2 tee is a no-op +// instead of trying to reach a real S2 endpoint. The real one calls +// `apiClient.initializeSessionStream` then pipes via S2 — both are +// out of scope for handler-shape tests. +vi.mock("@trigger.dev/core/v3", async (importActual) => { + const actual = (await importActual()) as Record; + class StubSessionStreamInstance { + constructor(opts: { source: ReadableStream }) { + // Drain the source so the upstream tee doesn't backpressure-stall + // the SSE half. We don't keep the chunks — durability/resume is + // out of scope here. + void (async () => { + const reader = opts.source.getReader(); + try { + while (true) { + const { done } = await reader.read(); + if (done) break; + } + } finally { + reader.releaseLock(); + } + })(); + } + async wait() { + return { written: 0 }; + } + } + return { ...actual, SessionStreamInstance: StubSessionStreamInstance }; +}); + +// Import AFTER the mock so chat-server picks up the stubbed class. +import { chat } from "./chat-server.js"; +import { apiClientManager } from "@trigger.dev/core/v3"; + +// ── Helpers ──────────────────────────────────────────────────────────── + +function textStream(text: string): ReadableStream { + return simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 5, text: 5, reasoning: undefined }, + }, + }, + ], + }); +} + +function toolCallStream(): ReadableStream { + return simulateReadableStream({ + chunks: [ + { + type: "tool-call", + toolCallId: "tc-1", + toolName: "weather", + input: JSON.stringify({ city: "tokyo" }), + }, + { + type: "finish", + finishReason: { unified: "tool-calls", raw: "tool-calls" }, + usage: { + inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 5, text: 0, reasoning: undefined }, + }, + }, + ], + }); +} + +function makeRequest(body: unknown): Request { + return new Request("https://my-app.example/api/chat", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const SESSION_PAT = "tr_session_pat_for_handover"; + +function createSessionResponse(externalId: string): Response { + return new Response( + JSON.stringify({ + id: "session_test", + externalId, + type: "chat.agent", + taskIdentifier: "test-agent", + triggerConfig: { + basePayload: { chatId: externalId, trigger: "handover-prepare" }, + idleTimeoutInSeconds: 60, + }, + currentRunId: "run_test", + runId: "run_test", + publicAccessToken: SESSION_PAT, + tags: [], + metadata: null, + closedAt: null, + closedReason: null, + expiresAt: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + isCached: false, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + } + ); +} + +function appendOkResponse(): Response { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +async function readSSEBodyToChunks(res: Response): Promise { + const text = await res.text(); + return text + .split("\n\n") + .filter((b) => b.startsWith("data: ")) + .map((b) => JSON.parse(b.slice(6)) as UIMessageChunk); +} + +type CapturedRequest = { url: string; init?: RequestInit }; + +async function withApiContext(fn: () => Promise): Promise { + return apiClientManager.runWithConfig( + { + baseURL: "https://api.test.trigger.dev", + secretKey: "tr_test_secret", + }, + fn + ); +} + +// ── Tests ────────────────────────────────────────────────────────────── + +describe("chat.headStart (route handler)", () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + it("creates the session with handover-prepare in basePayload and returns the session PAT in headers", async () => { + const requests: CapturedRequest[] = []; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + requests.push({ url: urlStr, init }); + if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) { + return createSessionResponse("chat-1"); + } + if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) { + return appendOkResponse(); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const handler = chat.headStart({ + agentId: "test-agent", + run: async ({ chat: chatHelper }) => { + return streamText({ + ...chatHelper.toStreamTextOptions(), + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("hi back") }), + }), + }); + }, + }); + + const res = await withApiContext(() => + handler( + makeRequest({ + chatId: "chat-1", + trigger: "submit-message", + headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }], + }) + ) + ); + + expect(res.status).toBe(200); + expect(res.headers.get("X-Trigger-Chat-Id")).toBe("chat-1"); + expect(res.headers.get("X-Trigger-Chat-Access-Token")).toBe(SESSION_PAT); + expect(res.headers.get("Content-Type")).toMatch(/text\/event-stream/); + + const sessionCreate = requests.find((r) => + r.url.endsWith("/api/v1/sessions") || r.url.endsWith("/api/v1/sessions/") + ); + expect(sessionCreate).toBeDefined(); + const body = JSON.parse(sessionCreate!.init!.body as string); + expect(body.type).toBe("chat.agent"); + expect(body.externalId).toBe("chat-1"); + expect(body.taskIdentifier).toBe("test-agent"); + // The trigger payload is rewritten to handover-prepare even though the + // browser sent submit-message — the agent boots into the handover wait branch. + expect(body.triggerConfig.basePayload.trigger).toBe("handover-prepare"); + expect(body.triggerConfig.basePayload.chatId).toBe("chat-1"); + expect(body.triggerConfig.basePayload.idleTimeoutInSeconds).toBe(60); + }); + + it("dispatches handover with isFinal=true on pure-text finishReason", async () => { + const requests: CapturedRequest[] = []; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + requests.push({ url: urlStr, init }); + if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) { + return createSessionResponse("chat-final"); + } + if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) { + return appendOkResponse(); + } + // Stitched response subscribes to `.out` after handover. + if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) { + return new Response(new ReadableStream({ start(c) { c.close(); } }), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const handler = chat.headStart({ + agentId: "test-agent", + run: async ({ chat: chatHelper }) => { + return streamText({ + ...chatHelper.toStreamTextOptions(), + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("just a text reply") }), + }), + }); + }, + }); + + const res = await withApiContext(() => + handler( + makeRequest({ + chatId: "chat-final", + trigger: "submit-message", + // Slim wire: head-start ships full history via `headStartMessages` + // (not `messages` / `message`). The route handler reads that field + // off the request body before invoking the customer's run(). + headStartMessages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }], + }) + ) + ); + + // Drain the SSE body so handoverWhenDone observes finishReason. + const chunks = await readSSEBodyToChunks(res); + expect(chunks.some((c) => c.type === "text-delta")).toBe(true); + + // Give the deferred handoverWhenDone a tick to dispatch. + await new Promise((r) => setTimeout(r, 30)); + + const handoverPost = requests.find( + (r) => + r.url.includes("/realtime/v1/sessions/chat-final/in/append") && + r.init?.body !== undefined + ); + expect(handoverPost).toBeDefined(); + const body = JSON.parse(handoverPost!.init!.body as string); + // Pure-text finishes go through `kind: "handover"` with `isFinal: true` + // so the agent runs hooks (persistence, etc.) without making an LLM call. + expect(body.kind).toBe("handover"); + expect(body.isFinal).toBe(true); + // The partial carries the customer's response messages — a single + // assistant message with the streamed text. + expect(Array.isArray(body.partialAssistantMessage)).toBe(true); + const assistant = body.partialAssistantMessage.find( + (m: { role: string }) => m.role === "assistant" + ); + expect(assistant).toBeDefined(); + }); + + it("dispatches handover with response.messages on tool-call finishReason", async () => { + const requests: CapturedRequest[] = []; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + requests.push({ url: urlStr, init }); + if (urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/")) { + return createSessionResponse("chat-tool"); + } + if (urlStr.includes("/realtime/v1/sessions/") && urlStr.endsWith("/in/append")) { + return appendOkResponse(); + } + // Stitched response now subscribes to `.out` after handover to + // pick up agent-side chunks. Return an empty SSE body that + // closes immediately — this test validates dispatch only, not + // the agent-side resume. + if (/\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr)) { + return new Response(new ReadableStream({ start(c) { c.close(); } }), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + // Schema-only tool — no execute. The mock model emits a tool-call; + // AI SDK doesn't run it (no execute) and finishes with "tool-calls". + const { tool } = await import("ai"); + const { z } = await import("zod"); + const weatherTool = tool({ + description: "weather", + inputSchema: z.object({ city: z.string() }), + }); + + const handler = chat.headStart({ + agentId: "test-agent", + run: async ({ chat: chatHelper }) => { + return streamText({ + ...chatHelper.toStreamTextOptions({ tools: { weather: weatherTool } }), + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: toolCallStream() }), + }), + }); + }, + }); + + const res = await withApiContext(() => + handler( + makeRequest({ + chatId: "chat-tool", + trigger: "submit-message", + headStartMessages: [ + { id: "m1", role: "user", parts: [{ type: "text", text: "weather in tokyo?" }] }, + ], + }) + ) + ); + + await readSSEBodyToChunks(res); + await new Promise((r) => setTimeout(r, 30)); + + const handoverPost = requests.find( + (r) => + r.url.includes("/realtime/v1/sessions/chat-tool/in/append") && + r.init?.body !== undefined + ); + expect(handoverPost).toBeDefined(); + const body = JSON.parse(handoverPost!.init!.body as string); + expect(body.kind).toBe("handover"); + expect(body.isFinal).toBe(false); // pending tool-calls — agent runs streamText + expect(Array.isArray(body.partialAssistantMessage)).toBe(true); + + // The partial is reshaped into AI SDK's tool-approval round so the + // agent's `streamText` can resume by executing the pending tool-call + // before step 2. Assistant gets a `tool-approval-request` part + // alongside the original `tool-call`; a trailing `tool` message + // carries the `tool-approval-response { approved: true }`. + const assistant = body.partialAssistantMessage.find( + (m: { role: string }) => m.role === "assistant" + ); + expect(assistant).toBeDefined(); + const toolCallPart = assistant.content.find( + (p: { type: string }) => p.type === "tool-call" + ); + expect(toolCallPart).toBeDefined(); + const approvalRequestPart = assistant.content.find( + (p: { type: string }) => p.type === "tool-approval-request" + ); + expect(approvalRequestPart).toBeDefined(); + expect(approvalRequestPart.toolCallId).toBe(toolCallPart.toolCallId); + + const trailingTool = body.partialAssistantMessage[body.partialAssistantMessage.length - 1]; + expect(trailingTool.role).toBe("tool"); + const approvalResponsePart = trailingTool.content.find( + (p: { type: string }) => p.type === "tool-approval-response" + ); + expect(approvalResponsePart).toBeDefined(); + expect(approvalResponsePart.approvalId).toBe(approvalRequestPart.approvalId); + expect(approvalResponsePart.approved).toBe(true); + }); + + it("rejects requests missing chatId", async () => { + global.fetch = vi.fn().mockResolvedValue(new Response("nope", { status: 500 })); + + const handler = chat.headStart({ + agentId: "test-agent", + run: async ({ chat: chatHelper }) => { + return streamText({ + ...chatHelper.toStreamTextOptions(), + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("x") }), + }), + }); + }, + }); + + await expect( + withApiContext(() => + handler( + makeRequest({ + // no chatId + trigger: "submit-message", + messages: [], + }) + ) + ) + ).rejects.toThrow(/chatId/); + }); +}); + +describe("chat.toNodeListener", () => { + /** + * Build a fake Node IncomingMessage that yields a JSON body. + * AsyncIterable so the listener can `for await` over it. + */ + function fakeNodeRequest(opts: { + method?: string; + url?: string; + host?: string; + headers?: Record; + body?: string; + }) { + const bodyBytes = opts.body ? new TextEncoder().encode(opts.body) : undefined; + const headers = { + host: opts.host ?? "example.com", + ...(opts.body ? { "content-type": "application/json" } : {}), + ...(opts.headers ?? {}), + }; + const errorListeners: Array<(e: Error) => void> = []; + return { + method: opts.method ?? "POST", + url: opts.url ?? "/api/chat", + headers, + on(event: string, listener: (e: Error) => void) { + if (event === "error") errorListeners.push(listener); + return this; + }, + async *[Symbol.asyncIterator]() { + if (bodyBytes) yield bodyBytes; + }, + }; + } + + function fakeNodeResponse() { + const writes: Uint8Array[] = []; + let ended = false; + let endChunk: Uint8Array | string | undefined; + const closeListeners: Array<() => void> = []; + const headers: Record = {}; + const obj = { + statusCode: 200, + headersSent: false, + setHeader(name: string, value: string | number | readonly string[]) { + headers[name.toLowerCase()] = value; + }, + write(chunk: Uint8Array | string) { + if (typeof chunk === "string") { + writes.push(new TextEncoder().encode(chunk)); + } else { + writes.push(chunk); + } + obj.headersSent = true; + return true; + }, + end(chunk?: Uint8Array | string) { + ended = true; + endChunk = chunk; + }, + on(event: string, listener: () => void) { + if (event === "close") closeListeners.push(listener); + return obj; + }, + // test helpers + _written() { + const all = [...writes]; + if (typeof endChunk === "string") all.push(new TextEncoder().encode(endChunk)); + else if (endChunk) all.push(endChunk); + let total = 0; + for (const c of all) total += c.length; + const merged = new Uint8Array(total); + let offset = 0; + for (const c of all) { + merged.set(c, offset); + offset += c.length; + } + return new TextDecoder().decode(merged); + }, + _ended: () => ended, + _headers: () => headers, + _close: () => { + for (const l of closeListeners) l(); + }, + }; + return obj; + } + + it("converts the Node request into a Web Request, calls the handler, and forwards the response", async () => { + const seen: { method?: string; url?: string; ct?: string | null; body?: string } = {}; + + const webHandler = async (req: Request): Promise => { + seen.method = req.method; + seen.url = req.url; + seen.ct = req.headers.get("content-type"); + seen.body = await req.text(); + return new Response("ok", { + status: 201, + headers: { "x-test": "1", "content-type": "text/plain" }, + }); + }; + + const listener = chat.toNodeListener(webHandler); + const req = fakeNodeRequest({ body: '{"hello":"world"}' }); + const res = fakeNodeResponse(); + + await listener(req as any, res as any); + + expect(seen.method).toBe("POST"); + expect(seen.url).toBe("http://example.com/api/chat"); + expect(seen.ct).toBe("application/json"); + expect(seen.body).toBe('{"hello":"world"}'); + + expect(res.statusCode).toBe(201); + expect(res._headers()["x-test"]).toBe("1"); + expect(res._written()).toBe("ok"); + expect(res._ended()).toBe(true); + }); + + it("streams the Web Response body to the Node response chunk by chunk (no buffering)", async () => { + const chunkOrder: string[] = []; + const webHandler = async (): Promise => { + const encoder = new TextEncoder(); + const stream = new ReadableStream({ + async start(controller) { + for (const piece of ["one\n", "two\n", "three\n"]) { + chunkOrder.push("emit-" + piece.trim()); + controller.enqueue(encoder.encode(piece)); + await new Promise((r) => setTimeout(r, 5)); + } + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }; + + const listener = chat.toNodeListener(webHandler); + const req = fakeNodeRequest({}); + const res = fakeNodeResponse(); + await listener(req as any, res as any); + + expect(res._written()).toBe("one\ntwo\nthree\n"); + expect(chunkOrder).toEqual(["emit-one", "emit-two", "emit-three"]); + expect(res._headers()["content-type"]).toBe("text/event-stream"); + }); + + it("propagates client disconnect to the Web handler via AbortSignal", async () => { + let signal: AbortSignal | undefined; + let aborted = false; + + const webHandler = async (req: Request): Promise => { + signal = req.signal; + signal.addEventListener("abort", () => { + aborted = true; + }); + // Return a never-ending stream so the listener stays open until close. + return new Response( + new ReadableStream({ + start() { + // never enqueues + }, + }) + ); + }; + + const listener = chat.toNodeListener(webHandler); + const req = fakeNodeRequest({}); + const res = fakeNodeResponse(); + + // Run listener in background (it'll hang on the never-ending stream). + const pending = listener(req as any, res as any); + + // Wait a tick for the handler to attach the abort listener. + await new Promise((r) => setTimeout(r, 5)); + + res._close(); + expect(aborted).toBe(true); + + // Cleanup: the listener will throw (abort) and we don't care about the result. + await pending.catch(() => {}); + }); + + it("returns 500 with error text if the handler throws before headers are sent", async () => { + const webHandler = async (): Promise => { + throw new Error("boom"); + }; + + const listener = chat.toNodeListener(webHandler); + const req = fakeNodeRequest({}); + const res = fakeNodeResponse(); + await listener(req as any, res as any); + + expect(res.statusCode).toBe(500); + expect(res._written()).toBe("boom"); + }); +}); diff --git a/packages/trigger-sdk/src/v3/chat-server.ts b/packages/trigger-sdk/src/v3/chat-server.ts new file mode 100644 index 00000000000..7ef090a6e63 --- /dev/null +++ b/packages/trigger-sdk/src/v3/chat-server.ts @@ -0,0 +1,915 @@ +/** + * Server-side helpers for the `chat.agent` head-start flow — a + * customer's warm process (Next.js route handler, Express, etc.) + * gets the conversation moving while the heavy chat.agent run boots + * in parallel. Mid-turn, ownership of the durable stream hands over + * to the agent. + * + * The `chat.headStart({ agentId, run })` entry point returns a + * Next.js-style POST handler. Inside the customer's `run` callback + * they call `streamText` themselves, spreading + * `chat.toStreamTextOptions({ tools })` to inherit handover wiring. + * The handler runs `streamText` step 1 in the customer's process + * while the chat.agent run boots in parallel; on `tool-calls` the + * agent run picks up tool execution and continues, on pure-text the + * agent run exits clean without an LLM call. + * + * Two-layer naming: customer-facing surface is "head start" + * (describes the *benefit* — fast first-turn TTFC). The internal + * protocol still uses "handover" (describes the *mechanism* — the + * conversation hands off mid-turn from the warm process to the + * agent). Customers see `chat.headStart`, `HeadStartSession`, etc. + * The wire format and run-loop locals stay on `handover` / + * `handover-prepare` / `handover-skip`. + * + * Cooperative ordering only — handler stops writing to `session.out` + * before sending the `handover` chunk on `session.in`. No S2 fencing. + * + * ⚠️ HARD CONSTRAINT — bundle isolation + * + * This module is the customer-facing boundary for the route handler. + * The whole TTFC win comes from the customer's process being + * lightweight while the heavy agent run boots in parallel. **The + * route-handler bundle must not include heavy tool execute deps**: + * E2B, puppeteer/playwright, native bindings, the trigger SDK + * runtime, turndown, image processing libs, anything that pulls + * weight or pulls `node:` builtins. + * + * "Schema-only" tools must live in a module that imports only `ai` + * (for `tool()`) and `zod`. The agent task module imports those + * schemas and adds execute fns elsewhere — that's where the heavy + * deps live, and it's never reached by the route handler bundle. + * + * Runtime "strip executes" helpers (anything that takes a tool + * catalog with executes and removes them) DO NOT solve this. The + * import chain is resolved at bundle/build time, so importing the + * full catalog drags every dep in regardless of what the SDK does + * with the value at runtime. + * + * IMPORTANT (internal): this module must NOT import from `./ai.ts`. + * `ai.ts` statically imports `agentSkillsRuntime` (which uses `node:` + * builtins unfit for some serverless runtimes) and the heavy task + * runtime. Allowed imports: `./ai-shared.js`, `./chat-client.js`, + * `@trigger.dev/core/v3` (api client), `ai` (types + lightweight + * helpers like `stepCountIs` / `convertToModelMessages`). + */ + +import { ApiClient, SessionStreamInstance, apiClientManager } from "@trigger.dev/core/v3"; +import { + convertToModelMessages, + generateId as generateAssistantMessageId, + stepCountIs, + type ModelMessage, + type StreamTextResult, + type Tool, + type UIMessage, + type UIMessageChunk, +} from "ai"; +import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js"; + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export type HeadStartRunArgs> = { + /** User messages parsed from the incoming request. */ + messages: UIMessage[]; + /** Aborts when the request closes or the SDK times out the handover. */ + signal: AbortSignal; + /** Helper exposing `toStreamTextOptions(...)` and a session escape hatch. */ + chat: HeadStartChatHelper; +}; + +export type HeadStartChatHelper> = { + /** + * Spread into the customer's `streamText` call to inherit handover + * wiring. Returns options for: + * + * - `messages` — converted from the wire payload's UIMessages + * - `tools` — the customer's tool set (typically schema-only — see + * the bundle-isolation note in this module's header) + * - `abortSignal` — combined request-lifecycle + idle timeout + * - `stopWhen` — `stepCountIs(1)`. Step 1 only. The agent run picks + * up tool execution and step 2+ after the handover signal. + * + * Customer adds `model`, `system`, `providerOptions`, etc. on top. + * The customer keeps full control of the `streamText` call shape; + * this helper just hands back the options the SDK needs to own. + * + * The customer COULD override any of these by re-setting them after + * the spread, but doing so for `stopWhen` / `messages` / + * `abortSignal` will break the handover protocol. The intent is + * that customers spread first, then add only their own keys. + */ + toStreamTextOptions = Record>(opts?: { + tools?: TTools; + }): TOpts; + /** Lower-level escape hatch with manual `out` / `in` / dispatch primitives. */ + session: HeadStartSession; +}; + +export type HeadStartSession = { + readonly chatId: string; + /** + * Tees a UIMessage stream into `session.out` for durability/resume, + * fire-and-forget. Returns a passthrough that the caller can use as + * the HTTP response body. + */ + tee( + stream: ReadableStream + ): ReadableStream; + /** + * Awaits `result.finishReason` and dispatches `handover` (with the + * partial assistant ModelMessages) or `handover-skip`. + */ + handoverWhenDone(result: StreamTextResult): Promise; + /** + * Sugar over `tee` + `handoverWhenDone` + standard SSE response. + * Returns a `Response` with `Content-Type: text/event-stream` whose + * body is the teed stream. + */ + handoverResponse(result: StreamTextResult): Response; + /** + * Manually dispatch the `handover` signal on `session.in`. + * + * - `isFinal: true` — the partial assistant message IS the response. + * The agent runs `onChatStart` / `onTurnStart` / `onTurnComplete` + * against it but skips the LLM call. Use for pure-text replies. + * - `isFinal: false` — the partial assistant message ends with + * pending tool calls. The agent executes them and then runs a + * step-2 LLM call to produce the final response. + * + * `messageId` lets the caller carry a stable assistant message id + * across the handover boundary so the browser merges step 1 and + * step 2 into the same `UIMessage`. + */ + handover(args: { + partialAssistantMessage: ModelMessage[]; + isFinal: boolean; + messageId?: string; + }): Promise; + /** Manually dispatch the `handover-skip` signal on `session.in`. */ + handoverSkip(): Promise; +}; + +export type HeadStartHandlerOptions> = { + /** The `chat.agent({ id })` of the agent we're handing off to. */ + agentId: string; + /** + * Customer's first-turn implementation. Receives `messages`, + * `signal`, and a `chat` helper. Should call `streamText` with + * `...chat.toStreamTextOptions({ tools })` and return the + * `StreamTextResult`. + */ + run: (args: HeadStartRunArgs) => Promise>; + /** + * Seconds the agent run waits for the handover signal before + * exiting. Defaults to 60. + */ + idleTimeoutInSeconds?: number; +}; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export const chat = { + /** + * Returns a Next.js-style POST handler for the chat.agent + * head-start flow. Customer mounts it as + * `export const { POST } = chat.headStart({...})` (or + * `export const POST = chat.headStart({...})`). + * + * Pair with the browser transport's `headStart: "/api/chat"` + * option so the first message of a brand-new chat lands here + * before the agent run boots. + */ + headStart>( + opts: HeadStartHandlerOptions + ): (req: Request) => Promise { + return async (req: Request) => { + const session = await openHandoverSession({ + req, + agentId: opts.agentId, + idleTimeoutInSeconds: opts.idleTimeoutInSeconds, + }); + + const helper: HeadStartChatHelper = { + toStreamTextOptions(spreadOpts) { + return session.buildStreamTextOptions(spreadOpts) as any; + }, + session: session.handle, + }; + + const result = await opts.run({ + messages: session.uiMessages, + signal: session.combinedSignal, + chat: helper, + }); + + return session.handle.handoverResponse(result); + }; + }, + + /** + * Lower-level primitive for power users who want to call + * `streamText` themselves outside the `run` callback shape — custom + * transforms, non-AI-SDK code paths, or manual control over the + * response. Same wiring `chat.headStart` builds on internally. + */ + openSession(opts: { + req: Request; + agentId: string; + idleTimeoutInSeconds?: number; + }): Promise { + return openHandoverSession(opts).then((s) => s.handle); + }, + + /** + * Wrap a Web Fetch handler — `(req: Request) => Promise` — + * as a Node `http` listener — `(req: IncomingMessage, res: ServerResponse) => Promise`. + * + * Use this to mount `chat.headStart` (or any other Web Fetch + * handler) inside Node-only frameworks like Express, Fastify, Koa, + * or raw `node:http`. Web-native frameworks (Next.js App Router, + * Hono, SvelteKit, Remix, Workers, Bun, Deno, etc.) don't need + * this — they pass `Request` objects directly. + * + * Streams the response body chunk-by-chunk to the Node response, + * so the `chat.headStart` SSE chunks reach the browser as they + * arrive (no buffering). Aborts the underlying handler if the + * client closes the connection. + * + * Type-only import of `node:http` types — no runtime dep on `node:http`, + * so this stays safe to bundle into edge / Workers builds (the + * function just won't be called there). + * + * @example + * ```ts + * import express from "express"; + * import { chat } from "@trigger.dev/sdk/chat-server"; + * + * const handler = chat.headStart({ + * agentId: "my-chat", + * run: async ({ chat: helper }) => streamText({ ... }), + * }); + * + * const app = express(); + * app.post("/api/chat", chat.toNodeListener(handler)); + * ``` + */ + toNodeListener, +}; + +// --------------------------------------------------------------------------- +// Internals +// --------------------------------------------------------------------------- + +type InternalSession = { + uiMessages: UIMessage[]; + combinedSignal: AbortSignal; + handle: HeadStartSession; + buildStreamTextOptions(spreadOpts?: { tools?: Record }): Record; +}; + +async function openHandoverSession(opts: { + req: Request; + agentId: string; + idleTimeoutInSeconds?: number; +}): Promise { + const wirePayload = (await opts.req.json()) as ChatTaskWirePayload; + const chatId = wirePayload.chatId; + if (!chatId) { + throw new Error("[chat.handover] request body missing `chatId`"); + } + // Slim wire — head-start ships full history via `headStartMessages` (not + // `message`/`messages`) because the route handler runs on the customer's + // own HTTP endpoint and isn't subject to the 512 KiB `/in/append` cap. + // The full UIMessage[] flows through `wirePayload` into the auto-trigger + // `basePayload` below, where the agent run boot consumes it on first turn. + const uiMessages = (wirePayload.headStartMessages ?? []) as UIMessage[]; + // `convertToModelMessages` is async — resolve once up front so the + // synchronous `toStreamTextOptions` builder can hand back a fully + // formed object. AI SDK's `streamText` validates `messages` as a + // `ModelMessage[]` synchronously and rejects a Promise. + const modelMessages = await convertToModelMessages(uiMessages); + + const apiClient = resolveApiClient(); + const idleTimeoutInSeconds = opts.idleTimeoutInSeconds ?? 60; + + // Create the session and trigger the chat.agent's `handover-prepare` + // run atomically. `createSession` is idempotent on `(env, externalId + // = chatId)` and the auto-triggered run uses `triggerConfig. + // basePayload` as the wire payload — so a single round-trip both + // ensures the session exists and starts the agent booting with the + // right trigger. + // + // Awaited intentionally: subsequent writes to `session.out` (the + // tee from the customer's `streamText` to S2) need the session to + // exist, and the handover signal at end-of-step-1 needs the agent + // run to be there to consume it. The added latency (~one round trip + // to the control plane) is bounded; the agent's compute boot still + // overlaps with LLM TTFB. + const created = await apiClient.createSession({ + type: "chat.agent", + externalId: chatId, + taskIdentifier: opts.agentId, + triggerConfig: { + basePayload: { + ...wirePayload, + chatId, + trigger: "handover-prepare", + idleTimeoutInSeconds, + }, + idleTimeoutInSeconds, + }, + }); + const sessionPublicAccessToken = created.publicAccessToken; + + // Combined abort signal: request lifecycle OR an internal timeout + // mirroring the agent's idle wait so a hung handler doesn't sit + // forever. + const abortController = new AbortController(); + const requestAbort = (opts.req as Request & { signal?: AbortSignal }).signal; + if (requestAbort) { + if (requestAbort.aborted) abortController.abort(); + else requestAbort.addEventListener("abort", () => abortController.abort(), { once: true }); + } + const idleTimer = setTimeout( + () => abortController.abort(new Error("chat.handover: idle timeout")), + idleTimeoutInSeconds * 1000 + ); + + const buildStreamTextOptions = ( + spreadOpts?: { tools?: Record } + ): Record => { + // The customer spreads this object into their `streamText` call + // and then adds `model`, `system`, etc. on top. We set the four + // keys handover correctness depends on: + // + // - `messages`: the wire payload's UIMessages, converted + // (Promise resolved upfront so the spread is synchronous) + // - `tools`: customer's schema-only tool set + // - `stopWhen`: `stepCountIs(1)` — step 1 only. Agent run picks + // up tool execution and step 2+ after the handover signal. + // - `abortSignal`: combined request-lifecycle + idle timeout + // + // The customer's `StreamTextResult` exposes `finishReason` and + // `response.messages` directly, so we don't need to install an + // `onStepFinish` capture hook — we read those off the result in + // `handoverWhenDone`. + return { + messages: modelMessages, + tools: spreadOpts?.tools, + stopWhen: stepCountIs(1), + abortSignal: abortController.signal, + }; + }; + + // Tee a UIMessage stream into session.out via S2 direct-write, + // batched. `SessionStreamInstance` calls `initializeSessionStream` + // once to fetch S2 credentials, then pipes via `StreamsWriterV2`'s + // `BatchTransform` — one S2 append per ~200ms of chunks instead of + // one HTTP round-trip per UIMessageChunk. + let sessionWriter: SessionStreamInstance | null = null; + const tee = (stream: ReadableStream): ReadableStream => { + const [a, b] = stream.tee(); + sessionWriter = new SessionStreamInstance({ + apiClient, + baseUrl: apiClient.baseUrl, + sessionId: chatId, // Sessions are addressable by externalId (chatId). + io: "out", + source: b, + signal: abortController.signal, + }); + return a; + }; + /** Wait for the teed S2 writer to drain. Called before signaling handover. */ + const flushSessionWriter = async (): Promise => { + if (!sessionWriter) return; + try { + await sessionWriter.wait(); + } catch { + // Drop write errors — the customer's response stream is the + // source of truth for what the user sees. Durability/resume + // best-effort. + } + }; + + const handover = async (args: { + partialAssistantMessage: ModelMessage[]; + messageId?: string; + isFinal: boolean; + }) => { + const chunk: ChatInputChunk = { + kind: "handover", + partialAssistantMessage: args.partialAssistantMessage, + messageId: args.messageId, + isFinal: args.isFinal, + }; + await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk)); + }; + + /** + * Sent only on dispatch error (handler aborted before producing a + * `finishReason`). Normal pure-text and tool-call finishes go + * through `handover()` with the appropriate `isFinal` flag. + */ + const handoverSkip = async () => { + const chunk: ChatInputChunk = { kind: "handover-skip" }; + await apiClient.appendToSessionStream(chatId, "in", JSON.stringify(chunk)); + }; + + // A stable assistant messageId for this turn. The customer's + // `toUIMessageStream` is configured to emit its `start` chunk with + // this id, the handover signal carries it to the agent, and the + // agent's post-handover `toUIMessageStream` reuses it — so all + // chunks (customer's step 1 + agent's step 2) merge into one + // assistant message on the browser side. + const turnMessageId = generateAssistantMessageId(); + + // Set by `handoverWhenDone` after it observes `result.finishReason` + // and dispatches the handover decision. The stitched response stream + // awaits this to know whether to close (skip) or pull more chunks + // from session.out (handover). + type HandoverDecision = { kind: "handover" | "handover-skip" }; + let resolveDecision!: (decision: HandoverDecision) => void; + const decisionPromise = new Promise((resolve) => { + resolveDecision = resolve; + }); + + const handoverWhenDone = async (result: StreamTextResult) => { + // Owns idle-timer cleanup via the finally below, so both the + // sugar (`handoverResponse`) and the escape-hatch + // (`chat.openSession()` → `handle.handoverWhenDone(...)`) clean up + // the timer the same way. + try { + // `result.finishReason` is a Promise on the AI SDK + // result. Wait for the stream to settle, then dispatch. + const finishReason = await result.finishReason; + + // Drain the S2 tee so any in-flight handler writes (last + // `tool-input-available` parts, the synthetic `finish-step` for + // pure-text) are visible before the agent reads from session.out + // / session.in. Cooperative ordering — agent doesn't read past + // these unless we've finished writing them. + await flushSessionWriter(); + + const responseMessages = (await result.response).messages as ModelMessage[]; + + if (finishReason === "tool-calls") { + // Reshape pending tool-calls into AI SDK's tool-approval round + // so the agent's `streamText` resumes by executing them + // before the step-2 LLM call. + const reshaped = reshapeForHandoverResume(responseMessages); + await handover({ + partialAssistantMessage: reshaped, + messageId: turnMessageId, + isFinal: false, + }); + } else { + // Pure-text (or any non-tool-calls) finish — customer's step 1 + // IS the final response. The agent runs the turn-loop hooks + // (`onChatStart`, `onTurnStart`, `onTurnComplete`, etc.) using + // this partial as the response, but skips the LLM call. That + // way persistence (`onTurnComplete` writing to DB), self- + // review, and any post-turn work all fire normally. + await handover({ + partialAssistantMessage: responseMessages, + messageId: turnMessageId, + isFinal: true, + }); + } + resolveDecision({ kind: "handover" }); + } catch (err) { + // Dispatch failed before we could send the handover signal. + // Tell the agent to exit clean (no hooks fire) and close the + // response stream so it doesn't hang waiting for agent chunks. + resolveDecision({ kind: "handover-skip" }); + try { + await handoverSkip(); + } catch { + // best-effort + } + throw err; + } finally { + clearTimeout(idleTimer); + } + }; + + /** + * Build a single ReadableStream that: + * 1. Forwards the customer's `streamText` chunks (step 1) directly + * to the response — same low-latency path as before. + * 2. After step 1 ends and the dispatch decision lands: + * - `handover-skip`: closes the response immediately. The agent + * run exits without writing more chunks. + * - `handover`: subscribes to `session.out` from the sequence + * ID where the customer's tee left off, forwarding the agent + * run's chunks (tool-output-available, step 2 LLM text, + * `finish-step`, etc.) until `trigger:turn-complete`. + * + * The browser sees one continuous SSE response per first turn, just + * like a normal `streamText` would produce. + */ + const stitchHandoverStream = ( + customerBranch: ReadableStream + ): ReadableStream => { + return new ReadableStream({ + async start(controller) { + try { + // Phase 1: forward customer's chunks. + const reader = customerBranch.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + controller.enqueue(value); + } + } finally { + reader.releaseLock(); + } + + // Phase 2a: wait for handoverWhenDone to decide. + const decision = await decisionPromise; + if (decision.kind === "handover-skip") { + controller.close(); + return; + } + + // Phase 2b: agent is taking over. Resume from session.out + // starting AFTER the customer tee's last write, so we don't + // re-emit chunks the browser already saw. + const writeResult = sessionWriter + ? await sessionWriter.wait().catch(() => undefined) + : undefined; + const customerLastEventId = writeResult?.lastEventId; + + // Capture the latest S2 event id seen on session.out via + // `onPart`. After the stream closes we emit it to the + // browser as a `trigger:session-state` control chunk so the + // transport can hydrate `state.lastEventId` for turn 2's + // subscribe — without it, turn 2 reads session.out from the + // start and replays turn 1 to the user. + let latestEventId: string | undefined; + const agentStream = await apiClient.subscribeToSessionStream( + chatId, + "out", + { + ...(customerLastEventId != null + ? { lastEventId: customerLastEventId } + : {}), + signal: abortController.signal, + onPart: (part) => { + if (part.id) latestEventId = part.id; + }, + } + ); + + for await (const chunk of agentStream) { + controller.enqueue(chunk); + // The agent's run-loop emits `trigger:turn-complete` when + // the turn finishes. That's our cue to close — anything + // after is the next turn (which goes via the direct + // `session.in`/`session.out` path, not this endpoint). + if ( + chunk && + typeof chunk === "object" && + (chunk as { type?: unknown }).type === "trigger:turn-complete" + ) { + break; + } + } + + // Final control chunk: hand the browser transport the + // `lastEventId` it should use for the next turn's + // session.out subscribe. Filtered out before reaching the + // AI SDK on the browser side. + if (latestEventId != null) { + controller.enqueue({ + type: "trigger:session-state", + lastEventId: latestEventId, + } as unknown as UIMessageChunk); + } + controller.close(); + } catch (err) { + controller.error(err); + } + }, + cancel() { + // Browser closed the connection. Trigger the abort so any + // pending session.out subscription stops too. + abortController.abort(); + }, + }); + }; + + const handoverResponse = (result: StreamTextResult): Response => { + // `generateMessageId` makes the customer's `start` chunk carry + // `turnMessageId`, so the browser-side AI SDK keys the assistant + // message by it. The agent's post-handover stream emits chunks + // with the same id (passed via the handover signal) — both sides + // merge into one message on the browser. + const teed = tee( + result.toUIMessageStream({ + generateMessageId: () => turnMessageId, + }) + ); + // `handoverWhenDone` re-throws on dispatch failure for visibility, + // but the recovery (resolveDecision + handoverSkip) has already run + // by then and `stitchHandoverStream` closes the response cleanly via + // `decisionPromise`. The user-facing path is fine; we only suppress + // the unhandled-rejection so processes started with + // `--unhandled-rejections=throw` don't crash on what is effectively + // a logged failure with no further action to take. + // (Idle-timer cleanup lives inside `handoverWhenDone` itself.) + void handoverWhenDone(result).catch(() => {}); + + const stitched = stitchHandoverStream(teed); + + // Encode UIMessageChunks as SSE for the AI SDK transport on the + // browser. AI SDK's `toUIMessageStreamResponse()` does this same + // thing internally; replicate the format here so we don't have + // to bridge through the SDK's response helper. + const encoder = new TextEncoder(); + const sseStream = stitched.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + }, + }) + ); + + return new Response(sseStream, { + headers: { + "Content-Type": "text/event-stream", + "X-Vercel-AI-UI-Message-Stream": "v1", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + // Browser transport reads these to hydrate session state + // for subsequent (non-handover) turns. Once the browser has + // the PAT it talks directly to `session.in` / `session.out` + // without going back through the handler. + "X-Trigger-Chat-Id": chatId, + "X-Trigger-Chat-Access-Token": sessionPublicAccessToken, + }, + }); + }; + + const handle: HeadStartSession = { + chatId, + tee, + handoverWhenDone, + handoverResponse, + handover, + handoverSkip, + }; + + return { + uiMessages, + combinedSignal: abortController.signal, + handle, + buildStreamTextOptions, + }; +} + +function resolveApiClient(): ApiClient { + // Reuse the SDK's standard apiClientManager so customers configure + // base URL + secret key the same way as for `tasks.trigger(...)`. + const client = apiClientManager.clientOrThrow(); + return client; +} + +// --------------------------------------------------------------------------- +// Node `http` adapter +// --------------------------------------------------------------------------- + +// Minimal Node http types we use. Avoids a `node:http` type import so the +// file stays lint-clean on non-Node TS projects (the docs example handlers +// might typecheck under workers / deno configs that lack `node:` types). +interface NodeIncomingHeaders { + [k: string]: string | string[] | undefined; +} +interface NodeIncomingMessage extends AsyncIterable { + readonly url?: string; + readonly method?: string; + readonly headers: NodeIncomingHeaders; + on(event: "error", listener: (err: Error) => void): unknown; +} +interface NodeServerResponse { + statusCode: number; + headersSent: boolean; + setHeader(name: string, value: string | number | readonly string[]): unknown; + write(chunk: Uint8Array | string): boolean; + end(chunk?: Uint8Array | string): unknown; + on(event: "close" | "error", listener: () => void): unknown; +} + +/** @internal — exposed via `chat.toNodeListener`. */ +function toNodeListener( + webHandler: (req: Request) => Promise +): (req: NodeIncomingMessage, res: NodeServerResponse) => Promise { + return async function nodeListener(req, res) { + const abort = new AbortController(); + res.on("close", () => abort.abort()); + + try { + const url = `http://${req.headers.host ?? "localhost"}${req.url ?? "/"}`; + const method = req.method ?? "GET"; + const hasBody = method !== "GET" && method !== "HEAD"; + + // Read full body upfront. Chat wire payloads are small (sub-KB + // typically) so accumulating avoids the duplex-stream ceremony + // some Node versions need for streaming request bodies into + // a Web Request. + let body: ArrayBuffer | undefined; + if (hasBody) { + const chunks: Uint8Array[] = []; + for await (const chunk of req as AsyncIterable) { + chunks.push(chunk); + } + if (chunks.length > 0) { + let total = 0; + for (const c of chunks) total += c.length; + const merged = new Uint8Array(total); + let offset = 0; + for (const c of chunks) { + merged.set(c, offset); + offset += c.length; + } + body = merged.buffer.slice(merged.byteOffset, merged.byteOffset + merged.byteLength); + } + } + + // Flatten Node header values: arrays → comma-joined (per RFC 7230 §3.2.2). + const webHeaders = new Headers(); + for (const [name, value] of Object.entries(req.headers)) { + if (value == null) continue; + if (Array.isArray(value)) { + for (const v of value) webHeaders.append(name, v); + } else { + webHeaders.set(name, value); + } + } + + const webReq = new Request(url, { + method, + headers: webHeaders, + body, + signal: abort.signal, + }); + + const webRes = await webHandler(webReq); + + res.statusCode = webRes.status; + // `Headers.forEach` exposes the value comma-joined for multi-valued + // headers, which `setHeader` accepts. Set-Cookie is handled separately + // via `getSetCookie()` to preserve multiple values. + webRes.headers.forEach((value, key) => { + if (key.toLowerCase() === "set-cookie") return; + res.setHeader(key, value); + }); + const setCookies = + typeof (webRes.headers as Headers & { getSetCookie?: () => string[] }).getSetCookie === "function" + ? (webRes.headers as Headers & { getSetCookie: () => string[] }).getSetCookie() + : []; + if (setCookies.length > 0) { + res.setHeader("set-cookie", setCookies); + } + + if (!webRes.body) { + res.end(); + return; + } + + // Pipe the Web Response body to the Node response. On client + // disconnect (`abort.signal`), cancel the reader so a pending + // `read()` rejects and we exit the loop instead of blocking on + // a stream that will never produce more chunks. + const reader = webRes.body.getReader(); + const onAbort = () => { + reader.cancel(abort.signal.reason).catch(() => {}); + }; + if (abort.signal.aborted) onAbort(); + else abort.signal.addEventListener("abort", onAbort, { once: true }); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(value); + } + } catch { + // Reader was cancelled (client disconnect). Silently end. + } finally { + abort.signal.removeEventListener("abort", onAbort); + } + res.end(); + } catch (err) { + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("content-type", "text/plain; charset=utf-8"); + res.end(err instanceof Error ? err.message : "Internal error"); + } else { + res.end(); + } + } + }; +} + +/** + * Reshape a step-1 partial so the agent's `streamText` resumes by + * executing pending tool-calls before the next LLM call. + * + * When the customer's handler runs `streamText` with schema-only tools + * (no `execute` fns) and `stopWhen: stepCountIs(1)`, the LLM emits + * tool-calls but AI SDK can't execute them — the partial we ship is + * `[{ assistant: text + tool-call }]`. Splicing that as-is onto the + * agent's accumulator and calling `streamText` throws + * `MissingToolResultsError` synchronously inside + * `convertToLanguageModelPrompt`. + * + * AI SDK's documented escape hatch for "external party decides what + * to do with a tool-call, then SDK executes" is the tool-approval + * round. By appending a `tool-approval-request` part to the assistant + * message and a trailing `tool` message with a matching + * `tool-approval-response { approved: true }`, AI SDK: + * 1. Suppresses `MissingToolResultsError` for approved tool-calls + * (`convert-to-language-model-prompt.ts:135-144`). + * 2. Hits its initial-tool-execution branch + * (`stream-text.ts:1342-1486`) on the next `streamText` call, + * runs the agent-side `execute` fns, and synthesizes + * `tool-result` parts before the step-2 LLM call. + * + * If the customer's tools already had `execute` fns (rare for the + * handover use case but valid), the partial already contains a + * `tool-result` per tool-call — we leave those alone and only inject + * approvals for genuinely-pending calls. + * + * `collectToolApprovals` only scans the LAST message + * (`collect-tool-approvals.ts:30-37`), so the synthesized tool message + * must end up at the tail of the partial. The agent's run-loop + * splices the partial onto the end of the accumulator, which keeps + * this invariant. + */ +function reshapeForHandoverResume(responseMessages: ModelMessage[]): ModelMessage[] { + // First pass: gather the set of tool-call IDs that already have a + // matching tool-result. Those are "complete" — leave them alone. + const completedToolCallIds = new Set(); + for (const message of responseMessages) { + if (message.role !== "tool" || typeof message.content === "string") continue; + for (const part of message.content as Array<{ type: string; toolCallId?: string }>) { + if (part.type === "tool-result" && part.toolCallId) { + completedToolCallIds.add(part.toolCallId); + } + } + } + + // Second pass: clone the messages, appending a tool-approval-request + // alongside each pending tool-call. Collect the matching responses. + const approvalResponses: Array<{ + type: "tool-approval-response"; + approvalId: string; + approved: true; + }> = []; + let approvalCounter = 0; + + const reshaped: ModelMessage[] = responseMessages.map((message) => { + if (message.role !== "assistant" || typeof message.content === "string") { + return message; + } + const newContent: typeof message.content = [...message.content]; + for (const part of message.content as Array<{ + type: string; + toolCallId?: string; + }>) { + if ( + part.type === "tool-call" && + part.toolCallId && + !completedToolCallIds.has(part.toolCallId) + ) { + const approvalId = `handover-approval-${++approvalCounter}`; + newContent.push({ + type: "tool-approval-request", + approvalId, + toolCallId: part.toolCallId, + } as never); + approvalResponses.push({ + type: "tool-approval-response", + approvalId, + approved: true, + }); + } + } + return { ...message, content: newContent } as ModelMessage; + }); + + if (approvalResponses.length > 0) { + reshaped.push({ + role: "tool", + content: approvalResponses as never, + } as ModelMessage); + } + + return reshaped; +} diff --git a/packages/trigger-sdk/src/v3/chat-tab-coordinator.test.ts b/packages/trigger-sdk/src/v3/chat-tab-coordinator.test.ts new file mode 100644 index 00000000000..2731d769897 --- /dev/null +++ b/packages/trigger-sdk/src/v3/chat-tab-coordinator.test.ts @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { ChatTabCoordinator } from "./chat-tab-coordinator.js"; + +// Mock BroadcastChannel for testing +class MockBroadcastChannel { + static instances: MockBroadcastChannel[] = []; + onmessage: ((event: MessageEvent) => void) | null = null; + closed = false; + + constructor(public name: string) { + MockBroadcastChannel.instances.push(this); + } + + postMessage(data: unknown): void { + if (this.closed) return; + // Deliver to all OTHER instances on the same channel + for (const instance of MockBroadcastChannel.instances) { + if (instance !== this && instance.name === this.name && !instance.closed) { + instance.onmessage?.({ data } as MessageEvent); + } + } + } + + close(): void { + this.closed = true; + MockBroadcastChannel.instances = MockBroadcastChannel.instances.filter((i) => i !== this); + } +} + +describe("ChatTabCoordinator", () => { + beforeEach(() => { + MockBroadcastChannel.instances = []; + vi.stubGlobal("BroadcastChannel", MockBroadcastChannel); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it("tab A claims, tab B sees isReadOnly", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + + expect(b.isReadOnly("chat-1")).toBe(false); + + a.claim("chat-1"); + + expect(b.isReadOnly("chat-1")).toBe(true); + expect(a.isReadOnly("chat-1")).toBe(false); // Owner is not read-only + + a.dispose(); + b.dispose(); + }); + + it("tab A releases, tab B sees isReadOnly = false", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + + a.claim("chat-1"); + expect(b.isReadOnly("chat-1")).toBe(true); + + a.release("chat-1"); + expect(b.isReadOnly("chat-1")).toBe(false); + + a.dispose(); + b.dispose(); + }); + + it("fires listener on claim and release", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + const listener = vi.fn(); + b.addListener(listener); + + a.claim("chat-1"); + expect(listener).toHaveBeenCalledWith("chat-1", true); + + a.release("chat-1"); + expect(listener).toHaveBeenCalledWith("chat-1", false); + + a.dispose(); + b.dispose(); + }); + + it("removeListener stops notifications", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + const listener = vi.fn(); + b.addListener(listener); + b.removeListener(listener); + + a.claim("chat-1"); + expect(listener).not.toHaveBeenCalled(); + + a.dispose(); + b.dispose(); + }); + + it("claim returns false when another tab holds the chatId", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + + expect(a.claim("chat-1")).toBe(true); + expect(b.claim("chat-1")).toBe(false); + + a.dispose(); + b.dispose(); + }); + + it("supports multiple independent chatIds", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + + a.claim("chat-1"); + b.claim("chat-2"); + + expect(a.isReadOnly("chat-1")).toBe(false); + expect(a.isReadOnly("chat-2")).toBe(true); + expect(b.isReadOnly("chat-1")).toBe(true); + expect(b.isReadOnly("chat-2")).toBe(false); + + a.dispose(); + b.dispose(); + }); + + it("heartbeat timeout clears stale claim from crashed tab", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + const listener = vi.fn(); + b.addListener(listener); + + a.claim("chat-1"); + expect(b.isReadOnly("chat-1")).toBe(true); + + // Simulate tab A crashing (close its channel, stop heartbeats) + a.dispose(); + + // Advance past heartbeat timeout (10s) + vi.advanceTimersByTime(11_000); + + expect(b.isReadOnly("chat-1")).toBe(false); + expect(listener).toHaveBeenCalledWith("chat-1", false); + + b.dispose(); + }); + + it("dispose releases all claims", () => { + const a = new ChatTabCoordinator(); + const b = new ChatTabCoordinator(); + + a.claim("chat-1"); + a.claim("chat-2"); + expect(b.isReadOnly("chat-1")).toBe(true); + expect(b.isReadOnly("chat-2")).toBe(true); + + a.dispose(); + expect(b.isReadOnly("chat-1")).toBe(false); + expect(b.isReadOnly("chat-2")).toBe(false); + + b.dispose(); + }); + + it("gracefully degrades when BroadcastChannel is unavailable", () => { + vi.stubGlobal("BroadcastChannel", undefined); + + const coord = new ChatTabCoordinator(); + + // All operations are no-ops + expect(coord.claim("chat-1")).toBe(true); + expect(coord.isReadOnly("chat-1")).toBe(false); + coord.release("chat-1"); // No error + coord.dispose(); // No error + }); +}); diff --git a/packages/trigger-sdk/src/v3/chat-tab-coordinator.ts b/packages/trigger-sdk/src/v3/chat-tab-coordinator.ts new file mode 100644 index 00000000000..42d766cd33f --- /dev/null +++ b/packages/trigger-sdk/src/v3/chat-tab-coordinator.ts @@ -0,0 +1,268 @@ +/** + * Coordinates multi-tab access to chat sessions via BroadcastChannel. + * + * When multiple browser tabs open the same chat, only one can be the active + * sender. Others enter read-only mode. The coordinator uses a simple + * claim/release/heartbeat protocol to track ownership per chatId. + * + * Gracefully degrades to a no-op when BroadcastChannel is unavailable + * (SSR, Node.js, old browsers). + * + * @internal + */ + +const CHANNEL_NAME = "trigger-chat-tab-coord"; +const HEARTBEAT_INTERVAL_MS = 5_000; +const HEARTBEAT_TIMEOUT_MS = 10_000; + +type TabMessage = + | { type: "claim"; chatId: string; tabId: string } + | { type: "release"; chatId: string; tabId: string } + | { type: "heartbeat"; chatId: string; tabId: string } + | { type: "messages"; chatId: string; tabId: string; messages: unknown[] } + | { type: "session"; chatId: string; tabId: string; session: { lastEventId?: string } }; + +type ReadOnlyListener = (chatId: string, isReadOnly: boolean) => void; +type MessagesListener = (chatId: string, messages: unknown[]) => void; +type SessionListener = (chatId: string, session: { lastEventId?: string }) => void; + +export class ChatTabCoordinator { + private tabId: string; + private channel: BroadcastChannel | null = null; + /** Claims held by OTHER tabs: chatId -> { tabId, lastSeen } */ + private claims = new Map(); + /** chatIds that THIS tab has claimed */ + private myClaims = new Set(); + private listeners = new Set(); + private messagesListeners = new Set(); + private sessionListeners = new Set(); + private heartbeatTimer: ReturnType | null = null; + private beforeUnloadHandler: (() => void) | null = null; + + constructor() { + this.tabId = + typeof crypto !== "undefined" && crypto.randomUUID + ? crypto.randomUUID() + : `tab-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + + if (typeof BroadcastChannel === "undefined") { + return; // No-op mode + } + + this.channel = new BroadcastChannel(CHANNEL_NAME); + this.channel.onmessage = (event: MessageEvent) => { + this.handleMessage(event.data); + }; + + // Heartbeat: send for our claims + check for stale claims from other tabs + this.heartbeatTimer = setInterval(() => { + this.sendHeartbeats(); + this.expireStaleClaimsFromOtherTabs(); + }, HEARTBEAT_INTERVAL_MS); + + // Best-effort release on tab close + this.beforeUnloadHandler = () => this.releaseAll(); + if (typeof window !== "undefined") { + window.addEventListener("beforeunload", this.beforeUnloadHandler); + } + } + + /** + * Attempt to claim a chatId for sending. + * Returns false if another tab already holds it. + */ + claim(chatId: string): boolean { + if (!this.channel) return true; // No-op mode + + const existing = this.claims.get(chatId); + if (existing && existing.tabId !== this.tabId) { + return false; // Another tab holds this chat + } + + this.myClaims.add(chatId); + this.broadcast({ type: "claim", chatId, tabId: this.tabId }); + return true; + } + + /** Release a chatId so other tabs can claim it. */ + release(chatId: string): void { + if (!this.channel) return; + if (!this.myClaims.has(chatId)) return; + + this.myClaims.delete(chatId); + this.broadcast({ type: "release", chatId, tabId: this.tabId }); + } + + /** Check if THIS tab currently holds a claim for the chatId. */ + hasClaim(chatId: string): boolean { + return this.myClaims.has(chatId); + } + + /** Check if another tab holds this chatId. */ + isReadOnly(chatId: string): boolean { + if (!this.channel) return false; + + const claim = this.claims.get(chatId); + return claim != null && claim.tabId !== this.tabId; + } + + addListener(fn: ReadOnlyListener): void { + this.listeners.add(fn); + } + + removeListener(fn: ReadOnlyListener): void { + this.listeners.delete(fn); + } + + /** Broadcast the current messages to other tabs (for real-time sync). */ + broadcastMessages(chatId: string, messages: unknown[]): void { + if (!this.channel) return; + this.broadcast({ type: "messages", chatId, tabId: this.tabId, messages }); + } + + addMessagesListener(fn: MessagesListener): void { + this.messagesListeners.add(fn); + } + + removeMessagesListener(fn: MessagesListener): void { + this.messagesListeners.delete(fn); + } + + /** Broadcast session state (lastEventId) to other tabs. */ + broadcastSession(chatId: string, session: { lastEventId?: string }): void { + if (!this.channel) return; + this.broadcast({ type: "session", chatId, tabId: this.tabId, session }); + } + + addSessionListener(fn: SessionListener): void { + this.sessionListeners.add(fn); + } + + removeSessionListener(fn: SessionListener): void { + this.sessionListeners.delete(fn); + } + + /** Clean up channel, timers, and event listeners. */ + dispose(): void { + this.releaseAll(); + + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + + if (this.beforeUnloadHandler && typeof window !== "undefined") { + window.removeEventListener("beforeunload", this.beforeUnloadHandler); + this.beforeUnloadHandler = null; + } + + if (this.channel) { + this.channel.close(); + this.channel = null; + } + + this.listeners.clear(); + this.messagesListeners.clear(); + this.sessionListeners.clear(); + } + + // --- Private --- + + private handleMessage(msg: TabMessage): void { + if (msg.tabId === this.tabId) return; // Ignore own messages + + switch (msg.type) { + case "claim": { + const wasReadOnly = this.isReadOnly(msg.chatId); + this.claims.set(msg.chatId, { tabId: msg.tabId, lastSeen: Date.now() }); + if (!wasReadOnly) { + this.notify(msg.chatId, true); + } + break; + } + case "release": { + const claim = this.claims.get(msg.chatId); + if (claim && claim.tabId === msg.tabId) { + this.claims.delete(msg.chatId); + this.notify(msg.chatId, false); + } + break; + } + case "heartbeat": { + const claim = this.claims.get(msg.chatId); + if (claim && claim.tabId === msg.tabId) { + claim.lastSeen = Date.now(); + } + break; + } + case "messages": { + this.notifyMessages(msg.chatId, msg.messages); + break; + } + case "session": { + this.notifySession(msg.chatId, msg.session); + break; + } + } + } + + private sendHeartbeats(): void { + for (const chatId of this.myClaims) { + this.broadcast({ type: "heartbeat", chatId, tabId: this.tabId }); + } + } + + private expireStaleClaimsFromOtherTabs(): void { + const now = Date.now(); + for (const [chatId, claim] of this.claims) { + if (claim.tabId !== this.tabId && now - claim.lastSeen > HEARTBEAT_TIMEOUT_MS) { + this.claims.delete(chatId); + this.notify(chatId, false); + } + } + } + + private releaseAll(): void { + for (const chatId of [...this.myClaims]) { + this.release(chatId); + } + } + + private broadcast(msg: TabMessage): void { + try { + this.channel?.postMessage(msg); + } catch { + // Channel may be closed + } + } + + private notify(chatId: string, isReadOnly: boolean): void { + for (const fn of this.listeners) { + try { + fn(chatId, isReadOnly); + } catch { + // Non-fatal + } + } + } + + private notifyMessages(chatId: string, messages: unknown[]): void { + for (const fn of this.messagesListeners) { + try { + fn(chatId, messages); + } catch { + // Non-fatal + } + } + } + + private notifySession(chatId: string, session: { lastEventId?: string }): void { + for (const fn of this.sessionListeners) { + try { + fn(chatId, session); + } catch { + // Non-fatal + } + } + } +} diff --git a/packages/trigger-sdk/src/v3/chat.test.ts b/packages/trigger-sdk/src/v3/chat.test.ts new file mode 100644 index 00000000000..eaa69bed934 --- /dev/null +++ b/packages/trigger-sdk/src/v3/chat.test.ts @@ -0,0 +1,1193 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { UIMessage, UIMessageChunk } from "ai"; +import { TriggerChatTransport, createChatTransport } from "./chat.js"; + +// ─────────────────────────────────────────────────────────────────────────── +// Test helpers +// ─────────────────────────────────────────────────────────────────────────── + +/** + * Encode chunks as SSE text. The runtime SSE parser + * ({@link SSEStreamSubscription}) auto-parses the `data:` field via + * `safeParseJSON` and yields it as `value.chunk`, so each `data:` line + * just needs to contain the JSON-encoded chunk directly. + * + * In production the session backend sends the raw S2 record body as the + * `data:` field — that body is itself a JSON string (the transport + * round-trips through `JSON.stringify`/`JSON.parse`). The transport's + * SSE reader handles both shapes (`typeof value.chunk === "string"` → + * parse-once, `=== "object"` → use as-is). We pick the object form + * here for test simplicity. + */ +function sseEncode(chunks: (UIMessageChunk | Record)[]): string { + return chunks + .map((chunk, i) => `id: ${i}\ndata: ${JSON.stringify(chunk)}\n\n`) + .join(""); +} + +function createSSEStream(sseText: string): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(sseText)); + controller.close(); + }, + }); +} + +let messageIdCounter = 0; +function createUserMessage(text: string): UIMessage { + return { + id: `msg-user-${++messageIdCounter}`, + role: "user", + parts: [{ type: "text", text }], + }; +} + +const sampleChunks: UIMessageChunk[] = [ + { type: "text-start", id: "part-1" }, + { type: "text-delta", id: "part-1", delta: "Hello" }, + { type: "text-delta", id: "part-1", delta: " world" }, + { type: "text-delta", id: "part-1", delta: "!" }, + { type: "text-end", id: "part-1" }, +]; + +const sampleChunksWithTurnComplete: (UIMessageChunk | Record)[] = [ + ...sampleChunks, + { type: "trigger:turn-complete" }, +]; + +// URL predicates +function isSessionCreateUrl(urlStr: string): boolean { + return urlStr.endsWith("/api/v1/sessions") || urlStr.endsWith("/api/v1/sessions/"); +} +function isSessionOutSubscribeUrl(urlStr: string): boolean { + return /\/realtime\/v1\/sessions\/[^/]+\/out$/.test(urlStr); +} +function isSessionStreamAppendUrl(urlStr: string): boolean { + return /\/realtime\/v1\/sessions\/[^/]+\/(in|out)\/append$/.test(urlStr); +} +function chatIdFromUrl(urlStr: string): string | undefined { + const m = urlStr.match(/\/realtime\/v1\/sessions\/([^/]+)\//); + return m?.[1]; +} + +const DEFAULT_RUN_ID = "run_default"; +const DEFAULT_SESSION_ID = "session_default"; +const DEFAULT_SESSION_PAT = "pat_session_default"; + +function createSessionResponseBody(options?: { + sessionId?: string; + externalId?: string; + publicAccessToken?: string; + runId?: string; +}): string { + const externalId = options?.externalId ?? null; + return JSON.stringify({ + id: options?.sessionId ?? DEFAULT_SESSION_ID, + externalId, + type: "chat.agent", + taskIdentifier: "my-chat-task", + triggerConfig: { basePayload: { chatId: externalId ?? "" } }, + currentRunId: options?.runId ?? DEFAULT_RUN_ID, + runId: options?.runId ?? DEFAULT_RUN_ID, + publicAccessToken: options?.publicAccessToken ?? DEFAULT_SESSION_PAT, + tags: [], + metadata: null, + closedAt: null, + closedReason: null, + expiresAt: null, + createdAt: new Date(0).toISOString(), + updatedAt: new Date(0).toISOString(), + isCached: false, + }); +} + +function defaultSessionCreateResponse(options?: { + sessionId?: string; + externalId?: string; + publicAccessToken?: string; + runId?: string; +}): Response { + return new Response(createSessionResponseBody(options), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function defaultAppendResponse(): Response { + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +function defaultSseResponse( + chunks: (UIMessageChunk | Record)[] = sampleChunksWithTurnComplete +): Response { + return new Response(createSSEStream(sseEncode(chunks)), { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function authError(status = 401): Response { + return new Response( + JSON.stringify({ error: "Unauthorized", name: "TriggerApiError", status }), + { + status, + headers: { "content-type": "application/json" }, + } + ); +} + +/** + * Drains a UIMessageChunk stream into an array. Used to assert what + * the transport surfaced after filtering control chunks. + */ +async function drainChunks( + stream: ReadableStream +): Promise { + const reader = stream.getReader(); + const out: UIMessageChunk[] = []; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + out.push(value); + } + } finally { + reader.releaseLock(); + } + return out; +} + +// ─────────────────────────────────────────────────────────────────────────── +// Tests +// ─────────────────────────────────────────────────────────────────────────── + +describe("TriggerChatTransport", () => { + let originalFetch: typeof global.fetch; + + beforeEach(() => { + originalFetch = global.fetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + describe("constructor", () => { + it("creates with required options", () => { + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + }); + expect(transport).toBeInstanceOf(TriggerChatTransport); + }); + + it("createChatTransport returns a TriggerChatTransport", () => { + const transport = createChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + }); + expect(transport).toBeInstanceOf(TriggerChatTransport); + }); + + it("hydrates sessions from options.sessions", () => { + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + sessions: { + "chat-1": { + publicAccessToken: "hydrated-pat", + lastEventId: "42", + isStreaming: false, + }, + }, + }); + + const session = transport.getSession("chat-1"); + expect(session).toEqual({ + publicAccessToken: "hydrated-pat", + lastEventId: "42", + isStreaming: false, + }); + }); + + it("returns undefined for unknown chatIds", () => { + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + }); + expect(transport.getSession("unknown")).toBeUndefined(); + }); + }); + + describe("setSession / setOnSessionChange", () => { + it("setSession installs persisted state and notifies", () => { + const onSessionChange = vi.fn(); + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + onSessionChange, + }); + + transport.setSession("chat-x", { + publicAccessToken: "tok", + lastEventId: "10", + }); + + expect(transport.getSession("chat-x")).toMatchObject({ + publicAccessToken: "tok", + lastEventId: "10", + }); + expect(onSessionChange).toHaveBeenCalledWith( + "chat-x", + expect.objectContaining({ publicAccessToken: "tok", lastEventId: "10" }) + ); + }); + + it("setOnSessionChange swaps the callback at runtime", () => { + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + }); + + const cb1 = vi.fn(); + const cb2 = vi.fn(); + transport.setOnSessionChange(cb1); + transport.setSession("c", { publicAccessToken: "t1" }); + expect(cb1).toHaveBeenCalledTimes(1); + + transport.setOnSessionChange(cb2); + transport.setSession("c", { publicAccessToken: "t2" }); + expect(cb1).toHaveBeenCalledTimes(1); + expect(cb2).toHaveBeenCalledTimes(1); + }); + }); + + describe("start", () => { + it("calls the customer's startSession callback and caches the returned PAT", async () => { + const startSession = vi.fn().mockResolvedValue({ publicAccessToken: "session-pat-1" }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "should-not-be-called", + startSession, + }); + + const result = await transport.start("chat-1"); + + expect(startSession).toHaveBeenCalledWith({ + taskId: "my-chat-task", + chatId: "chat-1", + clientData: {}, + }); + expect(result.publicAccessToken).toBe("session-pat-1"); + expect(transport.getSession("chat-1")?.publicAccessToken).toBe("session-pat-1"); + }); + + it("is idempotent — second call returns the cached state without re-invoking startSession", async () => { + const startSession = vi + .fn() + .mockResolvedValue({ publicAccessToken: "session-pat-2" }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + startSession, + }); + + await transport.start("chat-2"); + await transport.start("chat-2"); + expect(startSession).toHaveBeenCalledTimes(1); + }); + + it("dedupes concurrent calls via an in-flight promise", async () => { + let resolveStart!: (r: { publicAccessToken: string }) => void; + const startPromise = new Promise<{ publicAccessToken: string }>((resolve) => { + resolveStart = resolve; + }); + const startSession = vi.fn().mockReturnValue(startPromise); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + startSession, + }); + + const a = transport.start("chat-3"); + const b = transport.start("chat-3"); + + resolveStart({ publicAccessToken: "session-pat-3" }); + await Promise.all([a, b]); + + expect(startSession).toHaveBeenCalledTimes(1); + }); + + it("preload() is an alias for start()", async () => { + const startSession = vi + .fn() + .mockResolvedValue({ publicAccessToken: "session-pat-pre" }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + startSession, + }); + + await transport.preload("chat-pre"); + expect(startSession).toHaveBeenCalledTimes(1); + expect(transport.getSession("chat-pre")?.publicAccessToken).toBe("session-pat-pre"); + }); + + it("throws a clear error when start() is called without startSession configured", async () => { + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + }); + await expect(transport.start("chat-no-start")).rejects.toThrow(/startSession/); + }); + + it("threads the transport's `clientData` through to startSession", async () => { + const startSession = vi + .fn() + .mockResolvedValue({ publicAccessToken: "session-pat-cd" }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + startSession, + clientData: { userId: "u-1", model: "claude-sonnet-4-6" }, + }); + + await transport.start("chat-cd"); + + expect(startSession).toHaveBeenCalledWith({ + taskId: "my-chat-task", + chatId: "chat-cd", + clientData: { userId: "u-1", model: "claude-sonnet-4-6" }, + }); + }); + + it("setClientData updates the value passed to subsequent startSession calls", async () => { + const startSession = vi + .fn() + .mockResolvedValue({ publicAccessToken: "session-pat-set" }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + startSession, + clientData: { userId: "old" }, + }); + + transport.setClientData({ userId: "new" }); + await transport.start("chat-set"); + + expect(startSession).toHaveBeenCalledWith({ + taskId: "my-chat-task", + chatId: "chat-set", + clientData: { userId: "new" }, + }); + }); + }); + + describe("ensureSessionState (lazy start on first sendMessage)", () => { + it("calls startSession lazily on first sendMessage when no PAT is hydrated", async () => { + const startSession = vi + .fn() + .mockResolvedValue({ publicAccessToken: "lazy-session-pat" }); + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "should-not-be-called", + startSession, + baseURL: "https://api.test.trigger.dev", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-lazy", + messageId: undefined, + messages: [createUserMessage("hi")], + abortSignal: undefined, + }); + await drainChunks(stream); + + expect(startSession).toHaveBeenCalledTimes(1); + expect(startSession).toHaveBeenCalledWith({ + taskId: "my-chat-task", + chatId: "chat-lazy", + clientData: {}, + }); + expect(transport.getSession("chat-lazy")?.publicAccessToken).toBe("lazy-session-pat"); + }); + + it("falls back to accessToken when no startSession is configured (out-of-band session create)", async () => { + const accessToken = vi.fn().mockResolvedValue("server-mediated-pat"); + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken, + baseURL: "https://api.test.trigger.dev", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-server", + messageId: undefined, + messages: [createUserMessage("hi")], + abortSignal: undefined, + }); + await drainChunks(stream); + + expect(accessToken).toHaveBeenCalledTimes(1); + expect(accessToken).toHaveBeenCalledWith({ chatId: "chat-server" }); + }); + + it("does not call accessToken when a PAT is hydrated", async () => { + const accessToken = vi.fn().mockResolvedValue("should-not-be-called"); + + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken, + sessions: { + "chat-h": { publicAccessToken: "hydrated-pat" }, + }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-h", + messageId: undefined, + messages: [createUserMessage("hi")], + abortSignal: undefined, + }); + await drainChunks(stream); + + expect(accessToken).not.toHaveBeenCalled(); + }); + }); + + describe("sendMessages", () => { + it("posts the user message to .in/append and streams chunks from .out", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + requests.push({ url: urlStr, init }); + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + baseURL: "https://api.test.trigger.dev", + sessions: { "chat-1": { publicAccessToken: "p" } }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-1", + messageId: "m1", + messages: [createUserMessage("Hello")], + abortSignal: undefined, + }); + const chunks = await drainChunks(stream); + + // Five UI chunks pass through; trigger:turn-complete is filtered. + expect(chunks).toHaveLength(sampleChunks.length); + expect(chunks[0]).toEqual(sampleChunks[0]); + + const append = requests.find((r) => + isSessionStreamAppendUrl(r.url) && r.url.endsWith("/in/append") + ); + expect(append).toBeDefined(); + expect(chatIdFromUrl(append!.url)).toBe("chat-1"); + + // Body is the serialized ChatInputChunk. + const body = JSON.parse(append!.init!.body as string); + expect(body.kind).toBe("message"); + expect(body.payload.chatId).toBe("chat-1"); + expect(body.payload.trigger).toBe("submit-message"); + }); + + it("addresses .out SSE by chatId (not by sessionId)", async () => { + const requests: string[] = []; + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + requests.push(urlStr); + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + baseURL: "https://api.test.trigger.dev", + sessions: { "chat-by-chatid": { publicAccessToken: "p" } }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-by-chatid", + messageId: undefined, + messages: [createUserMessage("Hi")], + abortSignal: undefined, + }); + await drainChunks(stream); + + const subscribe = requests.find(isSessionOutSubscribeUrl); + expect(subscribe).toBeDefined(); + expect(subscribe!).toContain("/realtime/v1/sessions/chat-by-chatid/out"); + }); + + it("for submit-message, only the latest message is delivered to .in", async () => { + // Slim wire: each `.in/append` carries at most ONE new message in + // `payload.message` (singular). Even if the caller hands sendMessages + // an array of three, only the last element flows to the wire — the + // agent rebuilds prior history at run boot from snapshot + replay. + let appendBody: any; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) { + appendBody = JSON.parse(init!.body as string); + return defaultAppendResponse(); + } + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + sessions: { "chat-slice": { publicAccessToken: "p" } }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-slice", + messageId: undefined, + messages: [ + createUserMessage("first"), + createUserMessage("second"), + createUserMessage("third"), + ], + abortSignal: undefined, + }); + await drainChunks(stream); + + expect(appendBody.payload.message).toBeDefined(); + expect(appendBody.payload.message.parts[0].text).toBe("third"); + expect(appendBody.payload.messages).toBeUndefined(); + }); + + it("for regenerate-message, no message is delivered to .in (server slices its own tail)", async () => { + // Slim wire: the regenerate trigger ships NO message — the agent + // trims the trailing assistant from its accumulator and re-runs from + // the prior user turn. The wire payload only carries the trigger + // discriminator + chatId + metadata. + let appendBody: any; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) { + appendBody = JSON.parse(init!.body as string); + return defaultAppendResponse(); + } + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + sessions: { "chat-regen": { publicAccessToken: "p" } }, + }); + + const stream = await transport.sendMessages({ + trigger: "regenerate-message", + chatId: "chat-regen", + messageId: undefined, + messages: [createUserMessage("a"), createUserMessage("b")], + abortSignal: undefined, + }); + await drainChunks(stream); + + expect(appendBody.payload.trigger).toBe("regenerate-message"); + expect(appendBody.payload.message).toBeUndefined(); + expect(appendBody.payload.messages).toBeUndefined(); + }); + + it("merges transport-level clientData into per-call metadata (per-call wins)", async () => { + let appendBody: any; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) { + appendBody = JSON.parse(init!.body as string); + return defaultAppendResponse(); + } + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + clientData: { userId: "u1", scope: "default" } as Record, + sessions: { "chat-md": { publicAccessToken: "p" } }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-md", + messageId: undefined, + messages: [createUserMessage("hi")], + abortSignal: undefined, + metadata: { scope: "request" } as never, + }); + await drainChunks(stream); + + expect(appendBody.payload.metadata).toEqual({ userId: "u1", scope: "request" }); + }); + + it("filters trigger:upgrade-required and continues reading", async () => { + const chunks: (UIMessageChunk | Record)[] = [ + ...sampleChunks.slice(0, 2), + { type: "trigger:upgrade-required" }, + ...sampleChunks.slice(2), + { type: "trigger:turn-complete" }, + ]; + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(chunks); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + sessions: { "chat-up": { publicAccessToken: "p" } }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-up", + messageId: undefined, + messages: [createUserMessage("hi")], + abortSignal: undefined, + }); + const surfaced = await drainChunks(stream); + + // Both control chunks are filtered. + expect(surfaced).toHaveLength(sampleChunks.length); + expect(surfaced.find((c: any) => c.type === "trigger:upgrade-required")).toBeUndefined(); + expect(surfaced.find((c: any) => c.type === "trigger:turn-complete")).toBeUndefined(); + }); + + it("clears isStreaming on turn-complete and notifies", async () => { + const onSessionChange = vi.fn(); + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + onSessionChange, + sessions: { "chat-tc": { publicAccessToken: "p" } }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-tc", + messageId: undefined, + messages: [createUserMessage("hi")], + abortSignal: undefined, + }); + await drainChunks(stream); + + const lastIsStreamingFalse = onSessionChange.mock.calls + .map((call) => call[1]) + .reverse() + .find((s) => s !== null && s.isStreaming === false); + expect(lastIsStreamingFalse).toBeDefined(); + }); + }); + + describe("auth retry on 401", () => { + it("refreshes the PAT via accessToken and retries the .in/append once", async () => { + const accessToken = vi.fn().mockResolvedValue("fresh-pat"); + let appendCount = 0; + let appendAuth: string | null = null; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) { + appendCount++; + if (appendCount === 1) return authError(401); + appendAuth = new Headers(init?.headers).get("Authorization"); + return defaultAppendResponse(); + } + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken, + sessions: { "chat-401": { publicAccessToken: "stale-pat" } }, + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-401", + messageId: undefined, + messages: [createUserMessage("hi")], + abortSignal: undefined, + }); + await drainChunks(stream); + + expect(accessToken).toHaveBeenCalledWith({ chatId: "chat-401" }); + expect(appendCount).toBe(2); + expect(appendAuth).toBe("Bearer fresh-pat"); + expect(transport.getSession("chat-401")?.publicAccessToken).toBe("fresh-pat"); + }); + }); + + describe("stopGeneration", () => { + it("posts {kind: stop} to .in/append and returns true", async () => { + let stopBody: any; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) { + stopBody = JSON.parse(init!.body as string); + return defaultAppendResponse(); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + sessions: { "chat-stop": { publicAccessToken: "p" } }, + }); + + const ok = await transport.stopGeneration("chat-stop"); + expect(ok).toBe(true); + expect(stopBody).toEqual({ kind: "stop" }); + }); + + it("returns false when there is no session for the chatId", async () => { + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + }); + const ok = await transport.stopGeneration("never-started"); + expect(ok).toBe(false); + }); + }); + + describe("sendAction", () => { + it("posts an action chunk to .in/append and subscribes to .out", async () => { + let actionBody: any; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionStreamAppendUrl(urlStr)) { + actionBody = JSON.parse(init!.body as string); + return defaultAppendResponse(); + } + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + sessions: { "chat-act": { publicAccessToken: "p" } }, + }); + + const stream = await transport.sendAction("chat-act", { type: "undo" }); + await drainChunks(stream); + + expect(actionBody.kind).toBe("message"); + expect(actionBody.payload.trigger).toBe("action"); + expect(actionBody.payload.action).toEqual({ type: "undo" }); + }); + }); + + describe("reconnectToStream", () => { + it("returns null when no session exists", async () => { + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + }); + const result = await transport.reconnectToStream({ chatId: "missing" }); + expect(result).toBeNull(); + }); + + it("returns null when the session is hydrated with isStreaming=false", async () => { + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + sessions: { + "chat-rc": { publicAccessToken: "p", isStreaming: false }, + }, + }); + const result = await transport.reconnectToStream({ chatId: "chat-rc" }); + expect(result).toBeNull(); + }); + + it("opens an SSE subscription with the X-Peek-Settled header set", async () => { + let subscribeHeaders: Headers | undefined; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionOutSubscribeUrl(urlStr)) { + subscribeHeaders = new Headers(init?.headers); + return defaultSseResponse(); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + sessions: { + "chat-rc-on": { publicAccessToken: "p", isStreaming: true }, + }, + }); + + const stream = await transport.reconnectToStream({ chatId: "chat-rc-on" }); + expect(stream).not.toBeNull(); + await drainChunks(stream!); + + expect(subscribeHeaders?.get("X-Peek-Settled")).toBe("1"); + }); + }); + + describe("multi-tab coordination", () => { + it("isReadOnly defaults to false when multiTab is disabled", () => { + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + }); + expect(transport.isReadOnly("any-chat")).toBe(false); + expect(transport.hasClaim("any-chat")).toBe(false); + }); + }); + + describe("endpoint (chat.handover routing)", () => { + /** + * Encode UIMessageChunks the same way the chat-server.ts handler + * does: `data: \n\n` per chunk. The transport's + * `parseUIMessageSseTransform` parses this back into chunk objects. + */ + function handoverSseBody(chunks: UIMessageChunk[]): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`)); + } + controller.close(); + }, + }); + } + + function handoverResponse(args: { + chatId: string; + accessToken: string; + chunks: UIMessageChunk[]; + }): Response { + return new Response(handoverSseBody(args.chunks), { + status: 200, + headers: { + "content-type": "text/event-stream", + "X-Trigger-Chat-Id": args.chatId, + "X-Trigger-Chat-Access-Token": args.accessToken, + }, + }); + } + + it("first-turn POSTs the wire payload to endpoint when no session exists", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + requests.push({ url: urlStr, init }); + if (urlStr === "https://my-app.example/api/chat") { + return handoverResponse({ + chatId: "chat-handover-1", + accessToken: "handover-pat-1", + chunks: sampleChunks, + }); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + headStart: "https://my-app.example/api/chat", + }); + + const stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-handover-1", + messageId: "m1", + messages: [createUserMessage("hello")], + abortSignal: undefined, + }); + const chunks = await drainChunks(stream); + + // Chunks were forwarded from the handler's SSE body unchanged. + expect(chunks).toEqual(sampleChunks); + + // Only the endpoint was called — no /api/v1/sessions, no .in/append, + // no .out subscribe. The handler owns first-turn end-to-end. + const endpointPosts = requests.filter( + (r) => r.url === "https://my-app.example/api/chat" + ); + expect(endpointPosts).toHaveLength(1); + expect(requests.some((r) => isSessionCreateUrl(r.url))).toBe(false); + expect(requests.some((r) => isSessionStreamAppendUrl(r.url))).toBe(false); + expect(requests.some((r) => isSessionOutSubscribeUrl(r.url))).toBe(false); + + // Body shape: head-start wire payload. Full UIMessage history is + // shipped via `headStartMessages` (this is the one path that still + // ships full history — the route handler runs against the customer's + // own HTTP endpoint, not /in/append, so the 512 KiB cap doesn't + // apply). The `message` field is omitted on this path. + const body = JSON.parse(endpointPosts[0]!.init!.body as string); + expect(body.chatId).toBe("chat-handover-1"); + expect(body.trigger).toBe("submit-message"); + expect(body.messageId).toBe("m1"); + expect(body.headStartMessages).toHaveLength(1); + expect(body.message).toBeUndefined(); + expect(body.messages).toBeUndefined(); + }); + + it("hydrates session state from response headers so subsequent turns bypass the endpoint", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + requests.push({ url: urlStr, init }); + if (urlStr === "https://my-app.example/api/chat") { + return handoverResponse({ + chatId: "chat-handover-2", + accessToken: "handover-pat-2", + chunks: sampleChunks, + }); + } + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const onSessionChange = vi.fn(); + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "fallback-pat", + headStart: "https://my-app.example/api/chat", + onSessionChange, + }); + + // Turn 1 — POSTs to endpoint, hydrates session. + await drainChunks( + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-handover-2", + messageId: "m1", + messages: [createUserMessage("first")], + abortSignal: undefined, + }) + ); + + const hydrated = transport.getSession("chat-handover-2"); + expect(hydrated).toBeDefined(); + expect(hydrated!.publicAccessToken).toBe("handover-pat-2"); + expect(onSessionChange).toHaveBeenCalledWith( + "chat-handover-2", + expect.objectContaining({ publicAccessToken: "handover-pat-2" }) + ); + + // Turn 2 — bypass endpoint, write directly to .in. + requests.length = 0; + const turn2Stream = await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-handover-2", + messageId: "m2", + messages: [createUserMessage("second")], + abortSignal: undefined, + }); + + expect(requests.some((r) => r.url === "https://my-app.example/api/chat")).toBe(false); + + const append = requests.find( + (r) => isSessionStreamAppendUrl(r.url) && r.url.endsWith("/in/append") + ); + expect(append).toBeDefined(); + expect(chatIdFromUrl(append!.url)).toBe("chat-handover-2"); + + // Drain after asserting append — `.out` is subscribed lazily when the + // returned stream is read. + await drainChunks(turn2Stream); + + const subscribe = requests.find((r) => isSessionOutSubscribeUrl(r.url)); + expect(subscribe).toBeDefined(); + }); + + it("bypasses endpoint when a session is already hydrated (page reload after first turn)", async () => { + const requests: Array<{ url: string; init?: RequestInit }> = []; + global.fetch = vi.fn().mockImplementation(async (url: string | URL, init?: RequestInit) => { + const urlStr = typeof url === "string" ? url : url.toString(); + requests.push({ url: urlStr, init }); + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + headStart: "https://my-app.example/api/chat", + sessions: { + "chat-resumed": { publicAccessToken: "persisted-pat" }, + }, + }); + + await drainChunks( + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-resumed", + messageId: undefined, + messages: [createUserMessage("hi again")], + abortSignal: undefined, + }) + ); + + expect(requests.some((r) => r.url === "https://my-app.example/api/chat")).toBe(false); + expect(requests.some((r) => isSessionStreamAppendUrl(r.url))).toBe(true); + }); + + it("propagates a non-2xx response from the endpoint as an error", async () => { + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (urlStr === "https://my-app.example/api/chat") { + return new Response(null, { status: 500, statusText: "Internal Server Error" }); + } + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + headStart: "https://my-app.example/api/chat", + }); + + await expect( + transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-handover-err", + messageId: undefined, + messages: [createUserMessage("oops")], + abortSignal: undefined, + }) + ).rejects.toThrow(/500/); + }); + + it("leaves the legacy direct-trigger path unchanged when endpoint is unset", async () => { + const requests: string[] = []; + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + requests.push(urlStr); + if (isSessionStreamAppendUrl(urlStr)) return defaultAppendResponse(); + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + // endpoint NOT set + sessions: { "chat-legacy": { publicAccessToken: "p" } }, + }); + + await drainChunks( + await transport.sendMessages({ + trigger: "submit-message", + chatId: "chat-legacy", + messageId: undefined, + messages: [createUserMessage("legacy")], + abortSignal: undefined, + }) + ); + + // No POST to /api/chat anywhere. + expect(requests.some((u) => u.endsWith("/api/chat"))).toBe(false); + expect(requests.some(isSessionStreamAppendUrl)).toBe(true); + expect(requests.some(isSessionOutSubscribeUrl)).toBe(true); + }); + }); + + describe("watch mode", () => { + it("keeps the SSE open across trigger:turn-complete (multi-turn watch)", async () => { + const turn1: (UIMessageChunk | Record)[] = [ + { type: "text-delta", id: "p1", delta: "Hi" }, + { type: "trigger:turn-complete" }, + { type: "text-delta", id: "p2", delta: "Again" }, + { type: "trigger:turn-complete" }, + ]; + global.fetch = vi.fn().mockImplementation(async (url: string | URL) => { + const urlStr = typeof url === "string" ? url : url.toString(); + if (isSessionOutSubscribeUrl(urlStr)) return defaultSseResponse(turn1); + throw new Error(`Unexpected URL: ${urlStr}`); + }); + + const transport = new TriggerChatTransport({ + task: "my-chat-task", + accessToken: () => "pat", + watch: true, + sessions: { + "chat-watch": { publicAccessToken: "p", isStreaming: true }, + }, + }); + + const stream = await transport.reconnectToStream({ chatId: "chat-watch" }); + const surfaced = await drainChunks(stream!); + + // Both trigger:turn-complete control chunks filtered; both + // text-deltas surfaced because watch mode kept the loop alive + // through the first turn-complete. + const textChunks = surfaced.filter((c: any) => c.type === "text-delta"); + expect(textChunks).toHaveLength(2); + }); + }); +}); diff --git a/packages/trigger-sdk/src/v3/chat.ts b/packages/trigger-sdk/src/v3/chat.ts new file mode 100644 index 00000000000..980d34c1f04 --- /dev/null +++ b/packages/trigger-sdk/src/v3/chat.ts @@ -0,0 +1,1264 @@ +/** + * @module @trigger.dev/sdk/chat + * + * Browser-safe module for AI SDK chat transport integration. + * Use this on the frontend with the AI SDK's `useChat` hook. + * + * For backend helpers (`chatAgent`, `pipeChat`), use `@trigger.dev/sdk/ai` instead. + * + * @example + * ```tsx + * import { useChat } from "@ai-sdk/react"; + * import { TriggerChatTransport } from "@trigger.dev/sdk/chat"; + * + * function Chat() { + * const { messages, sendMessage, status } = useChat({ + * transport: new TriggerChatTransport({ + * task: "my-chat-task", + * accessToken: async ({ chatId }) => fetchSessionToken(chatId), + * startSession: async ({ chatId, taskId }) => createChatSession({ chatId, taskId }), + * }), + * }); + * } + * ``` + */ + +import type { ChatTransport, UIMessage, UIMessageChunk, ChatRequestOptions } from "ai"; +import { ApiClient, SSEStreamSubscription } from "@trigger.dev/core/v3"; +import { ChatTabCoordinator } from "./chat-tab-coordinator.js"; +import type { ChatInputChunk, ChatTaskWirePayload } from "./ai-shared.js"; + +const DEFAULT_BASE_URL = "https://api.trigger.dev"; +const DEFAULT_STREAM_TIMEOUT_SECONDS = 120; + +/** + * Detect 401/403 from realtime/input-stream calls without relying on `instanceof` + * (Vitest can load duplicate `@trigger.dev/core` copies, which breaks subclass checks). + */ +function isAuthError(error: unknown): boolean { + if (error === null || typeof error !== "object") return false; + const e = error as { name?: string; status?: number }; + return e.name === "TriggerApiError" && (e.status === 401 || e.status === 403); +} + +/** + * Parses an SSE byte/text stream of `data: \n\n` + * frames back into `UIMessageChunk` objects. Used by the handover + * first-turn path to convert the customer's route handler response + * (which is AI-SDK-shaped SSE text) into the chunk form the AI SDK's + * `useChat` consumes from a transport. + * + * Spec-light parser — assumes well-formed `data:` events from our own + * `chat.handover` SSE writer. Lines starting with `:` (comments) and + * other event types are ignored. + */ +function parseUIMessageSseTransform(): TransformStream { + let buffer = ""; + return new TransformStream({ + transform(chunk, controller) { + buffer += chunk; + // Frames are separated by blank lines. + let idx = buffer.indexOf("\n\n"); + while (idx !== -1) { + const frame = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + for (const line of frame.split("\n")) { + if (line.startsWith("data: ")) { + const data = line.slice(6).trim(); + if (!data) continue; + try { + controller.enqueue(JSON.parse(data) as UIMessageChunk); + } catch { + /* drop malformed chunk; the response source is our own writer */ + } + } + } + idx = buffer.indexOf("\n\n"); + } + }, + flush(controller) { + // Trailing data without a closing blank line — treat as a final frame. + if (buffer.trim().length === 0) return; + for (const line of buffer.split("\n")) { + if (line.startsWith("data: ")) { + const data = line.slice(6).trim(); + if (!data) continue; + try { + controller.enqueue(JSON.parse(data) as UIMessageChunk); + } catch { + /* drop */ + } + } + } + buffer = ""; + }, + }); +} + +/** + * Arguments for the `accessToken` callback. The transport invokes this + * whenever it needs a fresh session-scoped PAT — initial use, and + * after a 401 from any session-PAT-authed request. + * + * The callback's job is to return a token, not to start a run. + * Customers whose implementation also creates the session (typical for + * `chat.createStartSessionAction` server actions) own the trigger + * payload server-side — they know their own user/context and don't + * need anything from the browser to populate `basePayload.metadata`. + */ +export type AccessTokenParams = { + /** Conversation id — same value passed to `sendMessage` / `useChat`. */ + chatId: string; +}; + +/** + * Arguments for the `startSession` callback. The transport invokes this + * when it needs a session for a chatId — on `transport.preload(chatId)`, + * on `transport.start(chatId)`, and lazily on the first `sendMessage` + * for any chatId without a cached session. + * + * The callback typically wraps a server action that calls + * `chat.createStartSessionAction(taskId)({ chatId, clientData })`. That + * action is idempotent on `(env, externalId)`, so concurrent / repeat + * calls converge on the same session. + * + * The `clientData` field carries the transport's current `clientData` + * option — same value the transport merges into per-turn `metadata` on + * each `.in` chunk. Passing it through `startSession` makes the first + * run's `payload.metadata` (visible in `onPreload` / `onChatStart`) + * match what subsequent turns see. + * + * @typeParam TClientData – Type of the agent's `clientDataSchema` (when + * the transport is parameterised with `useTriggerChatTransport`). + */ +export type StartSessionParams = { + /** The Trigger.dev task ID associated with this transport. */ + taskId: string; + /** Conversation id — same value passed to `sendMessage` / `useChat`. */ + chatId: string; + /** + * The transport's current `clientData`. Pass through to the server + * action's `basePayload.metadata` so the first run's `payload.metadata` + * matches per-turn `metadata`. + */ + clientData: TClientData; +}; + +/** + * Result returned from the `startSession` callback. Carries the + * session-scoped PAT the transport caches and uses for every + * `.in/append`, `.out` SSE, and `end-and-continue` call afterward. + */ +export type StartSessionResult = { + /** Session-scoped PAT — `read:sessions:{chatId} + write:sessions:{chatId}`. */ + publicAccessToken: string; +}; + +/** + * Public surface of {@link TriggerChatTransport}'s session state. Everything + * the customer should persist for resumption across page reloads. The + * transport addresses by `chatId` everywhere, so this is light: just a PAT, + * the last SSE event id, and a couple of UX-state flags. + */ +export type ChatSessionPersistedState = { + publicAccessToken: string; + lastEventId?: string; + isStreaming?: boolean; +}; + +/** + * Common options for the {@link TriggerChatTransport}. + * + * @typeParam TClientData – Type of the per-call client data merged into + * the wire payload via `metadata`. When the task uses `clientDataSchema`, + * pin this to the schema's input type for end-to-end type safety. + */ +export type TriggerChatTransportOptions = { + /** + * The Trigger.dev task ID this transport drives. Sessions created by + * `transport.start(chatId)` are bound to this task — every run the + * Session schedules invokes it. Threaded into `startSession` so the + * customer's server action knows which task to bind. + */ + task: string; + + /** + * Returns a fresh session-scoped PAT for an existing chat session. + * The transport invokes this on a 401/403 from any session-PAT-authed + * request — pure refresh, never creates a session. + * + * Customer implementation typically does + * `auth.createPublicToken({ scopes: { read: { sessions: chatId }, + * write: { sessions: chatId } } })` server-side and returns the token. + * + * Required so the transport can recover from PAT expiry — never + * leaves the consumer in an unrecoverable state. + */ + accessToken: (params: AccessTokenParams) => string | Promise; + + /** + * Creates (or no-ops on existing) a session for the given chatId, and + * returns the session-scoped PAT the transport will use afterward. + * + * Wraps a server action that calls + * `chat.createStartSessionAction(taskId)({ chatId, clientData })`. + * Customer's server controls authorization, the rest of the + * triggerConfig, and any atomic DB writes paired with session creation. + * + * The transport invokes this: + * - when `transport.start(chatId)` / `transport.preload(chatId)` is called + * - lazily on the first `sendMessage` for a chatId with no cached PAT + * + * Concurrent and repeat calls dedupe via an in-flight promise + the + * customer-side idempotency on `(env, externalId)`. + * + * Optional only when the customer fully manages session lifecycle + * externally (hydrating `sessions: { ... }` and never calling + * `start` / `preload`). Most customers should provide it. + */ + startSession?: ( + params: StartSessionParams< + TClientData extends Record ? TClientData : Record + > + ) => Promise; + + /** Base URL for the Trigger.dev API. @default "https://api.trigger.dev" */ + baseURL?: string; + + /** Additional headers included in every API request. */ + headers?: Record; + + /** + * Seconds to wait for the realtime stream to produce data before timing + * out. @default 120 + */ + streamTimeoutSeconds?: number; + + /** + * Default client data merged into every wire `metadata`. Per-call + * `metadata` overrides transport-level defaults. + */ + clientData?: TClientData extends Record ? TClientData : Record; + + /** + * Restore active session state from external storage (e.g. localStorage) + * after a page refresh. Hydrated entries skip the start round-trip and + * use their `publicAccessToken` directly. On 401, the transport + * invokes `accessToken` to refresh. + */ + sessions?: Record; + + /** + * Called whenever a chat session's state changes. Use this to persist + * state for reconnection after a page refresh — `null` is passed when + * the session is removed. + */ + onSessionChange?: (chatId: string, session: ChatSessionPersistedState | null) => void; + + /** + * Enable multi-tab coordination. When `true`, only one tab at a time + * can send messages to a given chatId; other tabs go read-only. + * + * No-op when `BroadcastChannel` is unavailable. @default false + */ + multiTab?: boolean; + + /** + * Read-only "watch" mode for observing an existing chat run from the + * outside (e.g. a dashboard viewer). When `true`, the SSE subscription + * stays open across `trigger:turn-complete` so consumers see turn 2, + * 3, … through one long-lived stream. Pair with `sessions` hydration + * and `reconnectToStream` for the typical viewer flow. @default false + */ + watch?: boolean; + + /** + * Opt-in URL that gives a brand-new chat a head start: instead of + * waiting for the trigger.dev agent run to dequeue + boot before + * the first LLM call, the transport POSTs the first user message + * to a route handler in your warm process (Next.js, etc.) that + * exports `chat.handover({ agentId, run })` from + * `@trigger.dev/sdk/chat-server`. That handler runs `streamText` + * step 1 right away while the agent boots in parallel, then hands + * off mid-turn for tool execution (or exits clean for pure-text + * turns). + * + * First turn only. Subsequent turns on the same chat bypass this + * URL and write directly to `session.in` — the same direct-trigger + * path used when `headStart` is unset. Customers using `headStart` + * still need `accessToken` and (optionally) `startSession` for + * those subsequent turns. + * + * NOT a stock `useChat` "endpoint" — this is not the canonical + * request URL for every turn, just the warm first-turn shortcut. + * + * In benchmarks, head-starting drops first-turn TTFC roughly in + * half versus the direct-trigger flow (cold-start agent boot + + * onTurnStart hook overlap with the LLM TTFB instead of stacking + * before it). + * + * @default undefined (direct-trigger flow on every turn) + */ + headStart?: string; +}; + +/** + * Internal state for tracking active chat sessions. Sessions are + * task-bound and the server is the run manager — the transport only + * needs to know the session-scoped PAT to address `.in/append`, `.out`, + * `end-and-continue`, etc. + * @internal + */ +type ChatSessionState = { + /** Session-scoped PAT — `read:sessions:{chatId} + write:sessions:{chatId}`. */ + publicAccessToken: string; + /** Last SSE event ID — used to resume the stream without replaying old events. */ + lastEventId?: string; + /** Set when the stream was aborted mid-turn (stop). On reconnect, skip chunks until trigger:turn-complete. */ + skipToTurnComplete?: boolean; + /** Whether the agent is currently streaming a response. Set on first chunk, cleared on turn-complete. */ + isStreaming?: boolean; +}; + +/** + * A custom AI SDK `ChatTransport` that runs chat completions as durable + * Trigger.dev tasks via the Sessions primitive. + * + * Lifecycle: + * 1. Customer pre-creates the session server-side OR calls + * `transport.start(chatId)` to mint a one-shot start token and + * `POST /api/v1/sessions` from the browser. + * 2. The server triggers the first run as part of session create and + * returns a session-scoped PAT. + * 3. `sendMessages` appends to `.in` and subscribes to `.out`. When a + * run dies (idle, cancel, end-and-continue), the server's + * append-time probe triggers a fresh run for the same session — + * transport keeps streaming. + * 4. `stop()` posts a `{kind:"stop"}` chunk; the agent's turn aborts + * but the run keeps reading `.in` for the next message. + * 5. PAT expiry: transport invokes `accessToken` to refresh and + * retries the failing request once. + */ +export class TriggerChatTransport implements ChatTransport { + private readonly taskId: string; + private readonly resolveAccessToken: (params: AccessTokenParams) => string | Promise; + private readonly resolveStartSession: + | ((params: StartSessionParams>) => Promise) + | undefined; + private readonly baseURL: string; + private readonly extraHeaders: Record; + private readonly streamTimeoutSeconds: number; + private defaultMetadata: Record | undefined; + private readonly watchMode: boolean; + private readonly headStart: string | undefined; + private coordinator: ChatTabCoordinator | null = null; + private _onSessionChange: + | ((chatId: string, session: ChatSessionPersistedState | null) => void) + | undefined; + + private sessions: Map = new Map(); + private activeStreams: Map = new Map(); + private pendingStarts: Map> = new Map(); + + constructor(options: TriggerChatTransportOptions) { + this.taskId = options.task; + this.resolveAccessToken = options.accessToken; + this.resolveStartSession = options.startSession as + | ((params: StartSessionParams>) => Promise) + | undefined; + this.baseURL = options.baseURL ?? DEFAULT_BASE_URL; + this.extraHeaders = options.headers ?? {}; + this.streamTimeoutSeconds = options.streamTimeoutSeconds ?? DEFAULT_STREAM_TIMEOUT_SECONDS; + this.defaultMetadata = options.clientData; + this._onSessionChange = options.onSessionChange; + this.watchMode = options.watch ?? false; + this.headStart = options.headStart; + + if (options.multiTab && !this.watchMode) { + this.coordinator = new ChatTabCoordinator(); + this.coordinator.addSessionListener((chatId, sessionUpdate) => { + const session = this.sessions.get(chatId); + if (session && sessionUpdate.lastEventId) { + session.lastEventId = sessionUpdate.lastEventId; + } + }); + } + + if (options.sessions) { + for (const [chatId, session] of Object.entries(options.sessions)) { + this.sessions.set(chatId, { + publicAccessToken: session.publicAccessToken, + lastEventId: session.lastEventId, + isStreaming: session.isStreaming, + }); + } + } + } + + // ------------------------------------------------------------------------- + // Public lifecycle + // ------------------------------------------------------------------------- + + /** + * Eagerly create a Session and trigger its first run. Useful as a + * "the user might be about to send a message — boot the agent now" + * preload, or to take ownership of the session before any sendMessage. + * + * Idempotent: calling `start(chatId)` twice converges to the same + * session via the `(env, externalId)` upsert. Concurrent calls + * deduplicate via an in-flight promise. + * + * Requires `getStartToken` to be configured. Customers who pre-create + * sessions server-side don't need to call this. + */ + async start(chatId: string): Promise { + const existing = this.sessions.get(chatId); + if (existing?.publicAccessToken) { + return this.toPersisted(existing); + } + + const inflight = this.pendingStarts.get(chatId); + if (inflight) return inflight.then(this.toPersisted); + + const promise = this.doStart(chatId).finally(() => { + this.pendingStarts.delete(chatId); + }); + this.pendingStarts.set(chatId, promise); + return promise.then(this.toPersisted); + } + + /** + * Eagerly create the session before the user types. Same semantics as + * {@link start} — kept as a separate name for the AI SDK Chat hook, + * which calls `preload` rather than `start`. + */ + async preload(chatId: string): Promise { + await this.start(chatId); + } + + /** + * Send a user message via the session's `.in` channel. The server + * probes `currentRunId`; if terminal/null it triggers a fresh run on + * the same session before the append lands. The returned + * `ReadableStream` carries the agent's response chunks via `.out` SSE. + */ + sendMessages = async ( + options: { + trigger: "submit-message" | "regenerate-message"; + chatId: string; + messageId: string | undefined; + messages: UIMessage[]; + abortSignal: AbortSignal | undefined; + } & ChatRequestOptions + ): Promise> => { + const { trigger, chatId, messageId, messages, abortSignal, body, metadata } = options; + + if (this.coordinator) { + if (this.coordinator.isReadOnly(chatId)) { + throw new Error("This chat is active in another tab"); + } + this.coordinator.claim(chatId); + } + + const mergedMetadata = + this.defaultMetadata || metadata + ? { ...(this.defaultMetadata ?? {}), ...((metadata as Record) ?? {}) } + : undefined; + + // First-turn handover routing — when `headStart` is set AND no + // session state exists yet for this chatId, POST the wire payload + // to the customer's `chat.handover` route handler. The handler + // creates the session, triggers the agent run with + // `handover-prepare`, runs `streamText` step 1 in its warm + // process, and tees the output back as the SSE response. We + // hydrate session state from the response headers so subsequent + // turns bypass the handler and use direct `session.in` writes. + if (this.headStart && !this.sessions.has(chatId)) { + return this.sendMessagesViaHandover({ + trigger, + chatId, + messageId, + messages, + abortSignal, + body, + metadata: mergedMetadata, + }); + } + + // Slim wire — at most ONE message per record. The agent rebuilds prior + // history from its durable S3 snapshot + session.out replay at run boot + // (or `hydrateMessages`, if registered). See plan vivid-humming-bonbon. + // + // - "submit-message": ship the latest message (new user message OR a + // tool-approval-responded assistant message). Throw if absent. + // - "regenerate-message": omit `message`; the agent slices its own + // history (drops the trailing assistant) and re-runs. + if (trigger === "submit-message" && messages.length === 0) { + throw new Error( + "TriggerChatTransport.sendMessages: 'submit-message' trigger requires at least one message" + ); + } + const wirePayload: ChatTaskWirePayload = { + ...((body as Record) ?? {}), + ...(trigger === "submit-message" ? { message: messages.at(-1) } : {}), + chatId, + trigger, + messageId, + metadata: mergedMetadata, + }; + + const state = await this.ensureSessionState(chatId); + + const sendChatMessage = async (token: string) => { + const apiClient = new ApiClient(this.baseURL, token); + await apiClient.appendToSessionStream( + chatId, + "in", + this.serializeInputChunk({ kind: "message", payload: wirePayload }) + ); + }; + + await this.callWithAuthRetry(chatId, state, sendChatMessage); + + // Cancel any in-flight stream for this chat — the new turn supersedes it. + const activeStream = this.activeStreams.get(chatId); + if (activeStream) { + activeStream.abort(); + this.activeStreams.delete(chatId); + } + + state.isStreaming = true; + this.notifySessionChange(chatId, state); + + return this.subscribeToSessionStream(state, abortSignal, chatId); + }; + + /** + * First-turn-only path used when `headStart` is configured. POSTs the + * wire payload to the customer's `chat.handover` route handler and + * pipes its SSE response back as a UIMessageChunk stream. Hydrates + * session state from response headers so subsequent turns bypass + * the endpoint and use the direct `session.in` path. + */ + private async sendMessagesViaHandover(args: { + trigger: "submit-message" | "regenerate-message"; + chatId: string; + messageId: string | undefined; + messages: UIMessage[]; + abortSignal: AbortSignal | undefined; + body: ChatRequestOptions["body"]; + metadata: Record | undefined; + }): Promise> { + if (!this.headStart) { + throw new Error("sendMessagesViaHandover called without headStart configured"); + } + + // Head-start ships full UIMessage history via `headStartMessages`. The + // route handler runs on the customer's own HTTP endpoint (NOT + // `/realtime/v1/sessions/{id}/in/append`), so the 512 KiB body cap + // doesn't apply. The agent's run boot consumes `headStartMessages` ONLY + // when no snapshot exists yet (very first turn) — see plan section B.3. + const wirePayload: ChatTaskWirePayload = { + ...((args.body as Record) ?? {}), + headStartMessages: args.messages, + chatId: args.chatId, + trigger: args.trigger, + messageId: args.messageId, + metadata: args.metadata, + }; + + const response = await fetch(this.headStart, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...this.extraHeaders, + }, + body: JSON.stringify(wirePayload), + signal: args.abortSignal, + }); + + if (!response.ok) { + throw new Error( + `chat.handover endpoint returned ${response.status} ${response.statusText}` + ); + } + if (!response.body) { + throw new Error("chat.handover endpoint returned no response body"); + } + + // Hydrate session state from response headers so subsequent turns + // skip the endpoint and write directly to session.in. + const accessToken = response.headers.get("X-Trigger-Chat-Access-Token"); + const chatId = args.chatId; + if (accessToken) { + const state: ChatSessionState = { + publicAccessToken: accessToken, + isStreaming: true, + }; + this.sessions.set(chatId, state); + this.notifySessionChange(chatId, state); + } + + // Filter the parsed UIMessage stream: + // - Drop control chunks (`trigger:turn-complete`, + // `trigger:session-state`) before they reach AI SDK — they + // aren't valid UIMessageChunks and the AI SDK chunk parser + // would reject them. + // - On `trigger:turn-complete`, clear `isStreaming` so the + // useChat resume / reconnectToStream path doesn't open a + // second `session.out` subscription on top of our stitched + // response. + // - On `trigger:session-state`, hydrate `state.lastEventId` + // with the agent's final S2 event id. Without this, turn 2's + // `session.out` subscribe reads from the start and replays + // turn 1's chunks back into the UI. + // - On stream end (handover-skip case — no + // `trigger:turn-complete` arrives, customer's stream just + // ends), also clear `isStreaming` for the same reason. + const sessions = this.sessions; + const notifyChange = (id: string, state: ChatSessionState) => + this.notifySessionChange(id, state); + const TRIGGER_TURN_COMPLETE = "trigger:turn-complete"; + const TRIGGER_SESSION_STATE = "trigger:session-state"; + const clearStreaming = () => { + const state = sessions.get(chatId); + if (state && state.isStreaming) { + state.isStreaming = false; + notifyChange(chatId, state); + } + }; + const setLastEventId = (lastEventId: string) => { + const state = sessions.get(chatId); + if (state) { + state.lastEventId = lastEventId; + notifyChange(chatId, state); + } + }; + + return response.body + .pipeThrough(new TextDecoderStream()) + .pipeThrough(parseUIMessageSseTransform()) + .pipeThrough( + new TransformStream({ + transform(chunk, controller) { + if (chunk && typeof chunk === "object") { + const type = (chunk as { type?: unknown }).type; + if (type === TRIGGER_TURN_COMPLETE) { + clearStreaming(); + return; // drop — not a real UIMessageChunk + } + if (type === TRIGGER_SESSION_STATE) { + const lastEventId = (chunk as { lastEventId?: unknown }).lastEventId; + if (typeof lastEventId === "string") { + setLastEventId(lastEventId); + } + return; // drop + } + } + controller.enqueue(chunk); + }, + flush() { + clearStreaming(); + }, + }) + ); + } + + /** + * Send a steering message during an active stream without disrupting + * it. The agent's `pendingMessages` config decides whether to inject + * between tool-call steps or buffer for the next turn. + */ + sendPendingMessage = async ( + chatId: string, + message: UIMessage, + metadata?: Record + ): Promise => { + const state = this.sessions.get(chatId); + if (!state) return false; + + const mergedMetadata = + this.defaultMetadata || metadata + ? { ...(this.defaultMetadata ?? {}), ...(metadata ?? {}) } + : undefined; + + const wirePayload: ChatTaskWirePayload = { + message, + chatId, + trigger: "submit-message" as const, + metadata: mergedMetadata, + }; + + const send = async (token: string) => { + const apiClient = new ApiClient(this.baseURL, token); + await apiClient.appendToSessionStream( + chatId, + "in", + this.serializeInputChunk({ kind: "message", payload: wirePayload }) + ); + }; + + try { + await this.callWithAuthRetry(chatId, state, send); + return true; + } catch { + return false; + } + }; + + /** + * Re-establish an SSE subscription to a known session. Used after a + * page refresh: the customer hydrates `sessions` in the constructor, + * the AI SDK calls `reconnectToStream` to resume the stream. + */ + reconnectToStream = async ( + options: { + chatId: string; + abortSignal?: AbortSignal | undefined; + } & ChatRequestOptions + ): Promise | null> => { + const state = this.sessions.get(options.chatId); + if (!state) return null; + + if (state.isStreaming === false) return null; + if (this.activeStreams.has(options.chatId)) return null; + + const abortController = new AbortController(); + this.activeStreams.set(options.chatId, abortController); + + const abortSignal = options.abortSignal + ? AbortSignal.any([options.abortSignal, abortController.signal]) + : abortController.signal; + + return this.subscribeToSessionStream(state, abortSignal, options.chatId, { + sendStopOnAbort: !!options.abortSignal, + // Reconnect-on-reload opts into the server's settled-peek shortcut + // so the SSE doesn't hang for 60s when no turn is in flight. Active + // send-a-message paths must keep wait=60 to avoid racing the + // freshly-triggered turn's first chunk. + peekSettled: true, + }); + }; + + /** + * Stop the current generation. Sends `{kind:"stop"}` on `.in`; the + * agent aborts its `streamText` call but stays alive for the next + * message. + */ + stopGeneration = async (chatId: string): Promise => { + const state = this.sessions.get(chatId); + if (!state) return false; + + const send = async (token: string) => { + const api = new ApiClient(this.baseURL, token); + await api.appendToSessionStream( + chatId, + "in", + this.serializeInputChunk({ kind: "stop" }) + ); + }; + + try { + await this.callWithAuthRetry(chatId, state, send); + } catch { + return false; + } + + state.skipToTurnComplete = true; + + const activeStream = this.activeStreams.get(chatId); + if (activeStream) { + activeStream.abort(); + this.activeStreams.delete(chatId); + } + return true; + }; + + /** + * Send a custom action chunk (for `chat.agent`'s `actionSchema` / + * `onAction` hook). Actions are not turns — only `hydrateMessages` + * and `onAction` fire on the agent side. The returned stream + * carries any model response `onAction` produced (when it returns a + * `StreamTextResult`); for `void`-returning side-effect-only actions + * the stream completes immediately with `trigger:turn-complete`. + */ + sendAction = async ( + chatId: string, + action: unknown + ): Promise> => { + if (this.coordinator) { + if (this.coordinator.isReadOnly(chatId)) { + throw new Error("This chat is active in another tab"); + } + this.coordinator.claim(chatId); + } + + const state = await this.ensureSessionState(chatId); + + const wirePayload: ChatTaskWirePayload = { + chatId, + trigger: "action" as const, + action, + metadata: this.defaultMetadata ?? undefined, + }; + + const body = this.serializeInputChunk({ kind: "message", payload: wirePayload }); + const send = async (token: string) => { + const apiClient = new ApiClient(this.baseURL, token); + await apiClient.appendToSessionStream(chatId, "in", body); + }; + + await this.callWithAuthRetry(chatId, state, send); + + return this.subscribeToSessionStream(state, undefined, chatId); + }; + + // ------------------------------------------------------------------------- + // External-state surface + // ------------------------------------------------------------------------- + + getSession = (chatId: string): ChatSessionPersistedState | undefined => { + const state = this.sessions.get(chatId); + if (!state) return undefined; + return this.toPersisted(state); + }; + + setSession(chatId: string, session: ChatSessionPersistedState): void { + this.sessions.set(chatId, { + publicAccessToken: session.publicAccessToken, + lastEventId: session.lastEventId, + isStreaming: session.isStreaming, + }); + this.notifySessionChange(chatId, this.toPersisted(this.sessions.get(chatId)!)); + } + + setOnSessionChange( + callback: ((chatId: string, session: ChatSessionPersistedState | null) => void) | undefined + ): void { + this._onSessionChange = callback; + } + + /** + * Update the transport's `clientData`. Used by `useTriggerChatTransport` + * to keep the latest value reachable from inside `startSession` and + * the per-turn `metadata` merge without recreating the transport. + * + * Reads always go through the live field — closures around the + * transport see the latest value the next time they fire. + */ + setClientData(clientData: Record | undefined): void { + this.defaultMetadata = clientData; + } + + // ------------------------------------------------------------------------- + // Multi-tab coordination passthrough + // ------------------------------------------------------------------------- + + isReadOnly(chatId: string): boolean { + return this.coordinator?.isReadOnly(chatId) ?? false; + } + hasClaim(chatId: string): boolean { + return this.coordinator?.hasClaim(chatId) ?? false; + } + addReadOnlyListener(fn: (chatId: string, isReadOnly: boolean) => void): void { + this.coordinator?.addListener(fn); + } + removeReadOnlyListener(fn: (chatId: string, isReadOnly: boolean) => void): void { + this.coordinator?.removeListener(fn); + } + broadcastMessages(chatId: string, messages: unknown[]): void { + this.coordinator?.broadcastMessages(chatId, messages); + } + addMessagesListener(fn: (chatId: string, messages: unknown[]) => void): void { + this.coordinator?.addMessagesListener(fn); + } + removeMessagesListener(fn: (chatId: string, messages: unknown[]) => void): void { + this.coordinator?.removeMessagesListener(fn); + } + dispose(): void { + this.coordinator?.dispose(); + this.coordinator = null; + } + + // ------------------------------------------------------------------------- + // Internal helpers + // ------------------------------------------------------------------------- + + private serializeInputChunk(chunk: ChatInputChunk): string { + return JSON.stringify(chunk); + } + + private toPersisted = (state: ChatSessionState): ChatSessionPersistedState => ({ + publicAccessToken: state.publicAccessToken, + lastEventId: state.lastEventId, + isStreaming: state.isStreaming, + }); + + private notifySessionChange(chatId: string, session: ChatSessionState | null): void { + if (!this._onSessionChange) return; + this._onSessionChange(chatId, session ? this.toPersisted(session) : null); + } + + /** + * Resolves the session state for a chatId, starting the session if + * needed (and `getStartToken` is configured). Customers who provide + * `accessToken` but no `getStartToken` are expected to have created + * the session server-side; in that case the first `accessToken` call + * returns a fresh session PAT. + */ + private async ensureSessionState(chatId: string): Promise { + const existing = this.sessions.get(chatId); + if (existing?.publicAccessToken) return existing; + + if (this.resolveStartSession) { + // Lazily start: customer's server action creates the session and + // returns a PAT. Idempotent on `(env, externalId)` so concurrent + // tabs / repeat calls converge to the same session. + const inflight = this.pendingStarts.get(chatId); + if (inflight) return inflight; + const promise = this.doStart(chatId).finally(() => { + this.pendingStarts.delete(chatId); + }); + this.pendingStarts.set(chatId, promise); + return promise; + } + + // No `startSession` configured. Customer fully manages session + // lifecycle externally — they're expected to have hydrated + // `sessions: { ... }` already, or the very first `accessToken` call + // returns a PAT for an out-of-band-created session. + const token = await this.resolveAccessToken({ chatId }); + const state: ChatSessionState = { publicAccessToken: token }; + this.sessions.set(chatId, state); + this.notifySessionChange(chatId, state); + return state; + } + + private async doStart(chatId: string): Promise { + if (!this.resolveStartSession) { + throw new Error( + "TriggerChatTransport: `startSession` is required to call `start()` / `preload()`. Either provide it or pre-hydrate the session via `sessions: { ... }`." + ); + } + + const { publicAccessToken } = await this.resolveStartSession({ + taskId: this.taskId, + chatId, + clientData: (this.defaultMetadata ?? {}) as Record, + }); + + const state: ChatSessionState = { + publicAccessToken, + isStreaming: false, + }; + this.sessions.set(chatId, state); + this.notifySessionChange(chatId, state); + return state; + } + + /** + * Run `op` with the session's stored PAT. On 401/403, refresh the PAT + * via `accessToken` and retry once. Surfaces non-auth errors as-is. + */ + private async callWithAuthRetry( + chatId: string, + state: ChatSessionState, + op: (token: string) => Promise + ): Promise { + try { + await op(state.publicAccessToken); + return; + } catch (err) { + if (!isAuthError(err)) throw err; + } + + const fresh = await this.resolveAccessToken({ chatId }); + state.publicAccessToken = fresh; + this.notifySessionChange(chatId, state); + await op(fresh); + } + + /** + * Open an SSE subscription to the session's `.out` stream and pipe + * UIMessageChunks through to the AI SDK. Filters control chunks + * (`trigger:turn-complete`, `trigger:upgrade-required`) — the latter + * is purely telemetry now since the server handles the run swap + * inline (see `end-and-continue`). + */ + private subscribeToSessionStream( + state: ChatSessionState, + abortSignal: AbortSignal | undefined, + chatId: string, + options?: { + sendStopOnAbort?: boolean; + peekSettled?: boolean; + } + ): ReadableStream { + const internalAbort = new AbortController(); + this.activeStreams.set(chatId, internalAbort); + const combinedSignal = abortSignal + ? AbortSignal.any([abortSignal, internalAbort.signal]) + : internalAbort.signal; + + if (abortSignal) { + abortSignal.addEventListener( + "abort", + () => { + if (options?.sendStopOnAbort !== false) { + state.skipToTurnComplete = true; + const api = new ApiClient(this.baseURL, state.publicAccessToken); + api + .appendToSessionStream( + chatId, + "in", + this.serializeInputChunk({ kind: "stop" }) + ) + .catch(() => {}); + } + internalAbort.abort(); + }, + { once: true } + ); + } + + const streamUrl = `${this.baseURL}/realtime/v1/sessions/${encodeURIComponent(chatId)}/out`; + + return new ReadableStream({ + start: async (controller) => { + // Track the live subscription so browser wake events can act + // on it. Three classes of wake: + // - `online`: network came back. Existing connection might + // be silently dead; force a fresh one. + // - `visibilitychange` → visible after long hidden: tab + // was backgrounded long enough that the OS likely killed + // the TCP socket. Force reconnect. + // - `visibilitychange` → visible after short hidden: cheap + // wake of any in-flight backoff. + // - `pageshow` with `event.persisted`: bfcache restore + // (mobile Safari back/forward, app-switcher resume). The + // socket is definitely dead. Force reconnect. + let currentSubscription: SSEStreamSubscription | null = null; + let hiddenSince: number | null = null; + const FORCE_RECONNECT_AFTER_HIDDEN_MS = 30_000; + + const onVisibilityChange = () => { + if (typeof document === "undefined") return; + if (document.visibilityState === "hidden") { + hiddenSince = Date.now(); + return; + } + const wasHiddenForMs = hiddenSince ? Date.now() - hiddenSince : 0; + hiddenSince = null; + if (wasHiddenForMs >= FORCE_RECONNECT_AFTER_HIDDEN_MS) { + currentSubscription?.forceReconnect(); + } else { + currentSubscription?.retryNow(); + } + }; + + const onPageShow = (event: Event) => { + // PageTransitionEvent in browsers; type guard via `persisted`. + if ((event as PageTransitionEvent).persisted) { + currentSubscription?.forceReconnect(); + } + }; + + const onOnline = () => currentSubscription?.forceReconnect(); + + const teardownWakeListeners = + typeof document !== "undefined" && typeof window !== "undefined" + ? (() => { + document.addEventListener("visibilitychange", onVisibilityChange); + window.addEventListener("online", onOnline); + window.addEventListener("pageshow", onPageShow); + return () => { + document.removeEventListener("visibilitychange", onVisibilityChange); + window.removeEventListener("online", onOnline); + window.removeEventListener("pageshow", onPageShow); + }; + })() + : () => {}; + + const connectSseOnce = async (token: string) => { + const subscription = new SSEStreamSubscription(streamUrl, { + headers: { + Authorization: `Bearer ${token}`, + ...this.extraHeaders, + ...(options?.peekSettled ? { "X-Peek-Settled": "1" } : {}), + }, + signal: combinedSignal, + timeoutInSeconds: this.streamTimeoutSeconds, + lastEventId: state.lastEventId, + // Catch silent-dead-socket: if no chunk (or server + // keepalive) arrives in 60s, force reconnect. Sized + // generously over typical agent thinking pauses. + stallTimeoutMs: 60_000, + }); + currentSubscription = subscription; + const sseStream = await subscription.subscribe(); + const reader = sseStream.getReader(); + try { + const first = await reader.read(); + if (first.done) { + reader.releaseLock(); + return null; + } + return { reader, primed: first.value }; + } catch (readErr) { + reader.releaseLock(); + throw readErr; + } + }; + + try { + let reader: ReadableStreamDefaultReader<{ + id: string; + chunk: unknown; + timestamp: number; + }>; + let primed: { id: string; chunk: unknown; timestamp: number } | undefined; + + try { + const opened = await connectSseOnce(state.publicAccessToken); + if (opened === null) { + controller.close(); + return; + } + reader = opened.reader; + primed = opened.primed; + } catch (e) { + if (isAuthError(e)) { + const fresh = await this.resolveAccessToken({ chatId }); + state.publicAccessToken = fresh; + this.notifySessionChange(chatId, state); + const opened = await connectSseOnce(fresh); + if (opened === null) { + controller.close(); + return; + } + reader = opened.reader; + primed = opened.primed; + } else { + throw e; + } + } + + while (true) { + let value: { id: string; chunk: unknown; timestamp: number }; + if (primed !== undefined) { + value = primed; + primed = undefined; + } else { + const next = await reader.read(); + if (next.done) { + controller.close(); + return; + } + value = next.value; + } + + if (combinedSignal.aborted) { + internalAbort.abort(); + await reader.cancel(); + controller.close(); + return; + } + + if (value.id) state.lastEventId = value.id; + + // Session SSE delivers raw record bodies as strings (the + // server wraps them in `{data, id}` for S2). Parse so the + // rest of the loop can treat chunks as objects. + let chunkObj: Record | null = null; + if (value.chunk != null) { + if (typeof value.chunk === "string") { + try { + chunkObj = JSON.parse(value.chunk) as Record; + } catch { + chunkObj = null; + } + } else if (typeof value.chunk === "object") { + chunkObj = value.chunk as Record; + } + } + if (!chunkObj) continue; + const chunk = chunkObj; + + if (state.skipToTurnComplete) { + if (chunk.type === "trigger:turn-complete") { + state.skipToTurnComplete = false; + } + continue; + } + + if (chunk.type === "trigger:upgrade-required") { + // Server has already triggered the new run via + // `end-and-continue`; the next chunks on this same `.out` + // stream come from v2. Filter the marker for cleanliness + // and keep reading. + continue; + } + + if (chunk.type === "trigger:turn-complete") { + if (typeof chunk.publicAccessToken === "string") { + state.publicAccessToken = chunk.publicAccessToken; + } + state.isStreaming = false; + this.notifySessionChange(chatId, state); + this.coordinator?.release(chatId); + this.coordinator?.broadcastSession(chatId, { + lastEventId: state.lastEventId, + }); + + if (this.watchMode) continue; + + internalAbort.abort(); + try { + controller.close(); + } catch { + /* already closed */ + } + return; + } + + controller.enqueue(chunk as unknown as UIMessageChunk); + } + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + try { + controller.close(); + } catch { + /* already closed */ + } + return; + } + controller.error(error); + } finally { + teardownWakeListeners(); + this.activeStreams.delete(chatId); + this.coordinator?.release(chatId); + } + }, + }); + } +} + +/** + * Convenience constructor matching {@link TriggerChatTransport}. + */ +export function createChatTransport(options: TriggerChatTransportOptions): TriggerChatTransport { + return new TriggerChatTransport(options); +} + +// Server-side agent chat re-exports. +export { + AgentChat, + ChatStream, + type AgentChatOptions, + type ChatSession, + type ChatStreamResult, + type ChatToolCall, + type ChatToolResult, + type InferChatClientData, + type InferChatUIMessage, +} from "./chat-client.js"; diff --git a/packages/trigger-sdk/src/v3/deployments.ts b/packages/trigger-sdk/src/v3/deployments.ts new file mode 100644 index 00000000000..b6a334b203e --- /dev/null +++ b/packages/trigger-sdk/src/v3/deployments.ts @@ -0,0 +1,56 @@ +import type { + ApiRequestOptions, + RetrieveCurrentDeploymentResponseBody, + ApiDeploymentListOptions, + ApiDeploymentListResponseItem, +} from "@trigger.dev/core/v3"; +import { + apiClientManager, + CursorPagePromise, + isRequestOptions, + mergeRequestOptions, +} from "@trigger.dev/core/v3"; + +export type { RetrieveCurrentDeploymentResponseBody, ApiDeploymentListResponseItem }; + +export const deployments = { + retrieveCurrent: retrieveCurrentDeployment, + list: listDeployments, +}; + +/** + * Retrieve the currently promoted deployment for this environment. + * + * Use inside a task to check whether a newer version has been deployed: + * + * ```ts + * import { deployments } from "@trigger.dev/sdk"; + * + * const current = await deployments.retrieveCurrent(); + * if (current.version !== ctx.run.version) { + * // A newer version is promoted + * } + * ``` + */ +function retrieveCurrentDeployment( + requestOptions?: ApiRequestOptions +): Promise { + const apiClient = apiClientManager.clientOrThrow(); + return apiClient.retrieveCurrentDeployment(requestOptions); +} + +/** + * List deployments for the current environment. + */ +function listDeployments( + options?: ApiDeploymentListOptions, + requestOptions?: ApiRequestOptions +): CursorPagePromise { + const apiClient = apiClientManager.clientOrThrow(); + + if (isRequestOptions(options)) { + return apiClient.listDeployments(undefined, options); + } + + return apiClient.listDeployments(options, requestOptions); +} diff --git a/packages/trigger-sdk/src/v3/index.ts b/packages/trigger-sdk/src/v3/index.ts index 21ffa142871..5e169cbb8d6 100644 --- a/packages/trigger-sdk/src/v3/index.ts +++ b/packages/trigger-sdk/src/v3/index.ts @@ -17,14 +17,15 @@ export * from "./otel.js"; export * from "./schemas.js"; export * from "./heartbeats.js"; export * from "./streams.js"; +export * from "./sessions.js"; export * from "./query.js"; export type { Context }; import type { Context } from "./shared.js"; -import type { ApiClientConfiguration } from "@trigger.dev/core/v3"; +import type { ApiClientConfiguration, TaskRunContext } from "@trigger.dev/core/v3"; -export type { ApiClientConfiguration }; +export type { ApiClientConfiguration, TaskRunContext }; export { ApiError, @@ -39,6 +40,8 @@ export { AbortTaskRunError, OutOfMemoryError, CompleteTaskWithOutput, + ChatChunkTooLargeError, + isChatChunkTooLargeError, logger, type LogLevel, } from "@trigger.dev/core/v3"; @@ -54,9 +57,15 @@ export { type AnyRetrieveRunResult, } from "./runs.js"; export * as schedules from "./schedules/index.js"; +export { + deployments, + type RetrieveCurrentDeploymentResponseBody, + type ApiDeploymentListResponseItem, +} from "./deployments.js"; export * as envvars from "./envvars.js"; export * as queues from "./queues.js"; export type { ImportEnvironmentVariablesParams } from "./envvars.js"; export { configure, auth } from "./auth.js"; export * as prompts from "./prompts.js"; +export * as skills from "./skills.js"; diff --git a/packages/trigger-sdk/src/v3/runs.ts b/packages/trigger-sdk/src/v3/runs.ts index 7081c448d75..88e6d2b701c 100644 --- a/packages/trigger-sdk/src/v3/runs.ts +++ b/packages/trigger-sdk/src/v3/runs.ts @@ -358,6 +358,14 @@ export type SubscribeToRunOptions = { * ``` */ skipColumns?: RealtimeRunSkipColumns; + + /** + * An AbortSignal to cancel the subscription. + * + * When the signal is aborted, the underlying SSE connection is closed + * and the async iterator completes. + */ + signal?: AbortSignal; }; /** @@ -403,6 +411,7 @@ function subscribeToRun( closeOnComplete: typeof options?.stopOnCompletion === "boolean" ? options.stopOnCompletion : true, skipColumns: options?.skipColumns, + signal: options?.signal, }); } diff --git a/packages/trigger-sdk/src/v3/sessions.ts b/packages/trigger-sdk/src/v3/sessions.ts new file mode 100644 index 00000000000..3763a27146a --- /dev/null +++ b/packages/trigger-sdk/src/v3/sessions.ts @@ -0,0 +1,751 @@ +import type { + ApiPromise, + ApiRequestOptions, + AsyncIterableStream, + CloseSessionRequestBody, + CreatedSessionResponseBody, + CreateSessionRequestBody, + InputStreamOnceOptions, + InputStreamOnceResult, + InputStreamWaitOptions, + InputStreamWaitWithIdleTimeoutOptions, + ListSessionsOptions, + ListedSessionItem, + PipeStreamOptions, + PipeStreamResult, + RetrieveSessionResponseBody, + UpdateSessionRequestBody, + WriterStreamOptions, +} from "@trigger.dev/core/v3"; +import { + CursorPagePromise, + InputStreamOncePromise, + ManualWaitpointPromise, + SemanticInternalAttributes, + SessionStreamInstance, + WaitpointTimeoutError, + accessoryAttributes, + apiClientManager, + ensureReadableStream, + mergeRequestOptions, + runtime, + sessionStreams, + taskContext, +} from "@trigger.dev/core/v3"; +import { conditionallyImportAndParsePacket } from "@trigger.dev/core/v3/utils/ioSerialization"; +import { SpanStatusCode } from "@opentelemetry/api"; +import { tracer } from "./tracer.js"; + +export type { + CreatedSessionResponseBody, + CreateSessionRequestBody, + CloseSessionRequestBody, + ListSessionsOptions, + ListedSessionItem, + RetrieveSessionResponseBody, + UpdateSessionRequestBody, +}; + +export const sessions = { + start: startSession, + retrieve: retrieveSession, + update: updateSession, + close: closeSession, + list: listSessions, + open, +}; + +// Test hook: lets `@trigger.dev/sdk/ai/test` replace `sessions.open()` with +// an in-memory handle so unit tests don't hit the network. Not part of the +// public API — only `mockChatAgent` installs it. +type SessionOpenImpl = (sessionIdOrExternalId: string) => SessionHandle; +let sessionOpenImpl: SessionOpenImpl | undefined; + +export function __setSessionOpenImplForTests(impl: SessionOpenImpl | undefined): void { + sessionOpenImpl = impl; +} + +// Test hook for `sessions.start()`. Sessions are task-bound and the +// `start` call atomically creates the row + triggers the first run on +// the server; in unit tests there's no live API to hit, so a fixture +// implementation can be installed via this setter. +type SessionStartImpl = ( + body: CreateSessionRequestBody +) => Promise | CreatedSessionResponseBody; +let sessionStartImpl: SessionStartImpl | undefined; + +export function __setSessionStartImplForTests(impl: SessionStartImpl | undefined): void { + sessionStartImpl = impl; +} + +/** + * Start a {@link Session} — a durable, task-bound, bidirectional I/O + * primitive. The server creates the row (idempotent on `externalId`) + * and triggers the first run from `triggerConfig` in one round-trip. + * Returns the new run's id and a session-scoped public access token + * for browser-side use against `.in/append`, `.out` SSE, and + * `end-and-continue`. + * + * If a session with the same `(env, externalId)` already exists, + * returns the existing row plus the live (or freshly re-triggered) run. + * Two browser tabs of the same chat converge to one session. + */ +function startSession( + body: CreateSessionRequestBody, + requestOptions?: ApiRequestOptions +): ApiPromise { + if (sessionStartImpl) { + const result = sessionStartImpl(body); + return Promise.resolve(result) as ApiPromise; + } + + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "sessions.start()", + icon: "sessions", + attributes: sessionAttributes(body.externalId ?? body.type, { + type: body.type, + ...(body.externalId ? { externalId: body.externalId } : {}), + }), + }, + requestOptions + ); + + return apiClient.createSession(body, $requestOptions); +} + +/** + * Retrieve a Session by `friendlyId` (`session_*`) or user-supplied + * `externalId`. The server disambiguates via the `session_` prefix. + */ +function retrieveSession( + sessionIdOrExternalId: string, + requestOptions?: ApiRequestOptions +): ApiPromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "sessions.retrieve()", + icon: "sessions", + attributes: sessionAttributes(sessionIdOrExternalId), + }, + requestOptions + ); + + return apiClient.retrieveSession(sessionIdOrExternalId, $requestOptions); +} + +/** Update mutable fields on a Session (tags, metadata, externalId). */ +function updateSession( + sessionIdOrExternalId: string, + body: UpdateSessionRequestBody, + requestOptions?: ApiRequestOptions +): ApiPromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "sessions.update()", + icon: "sessions", + attributes: sessionAttributes(sessionIdOrExternalId), + }, + requestOptions + ); + + return apiClient.updateSession(sessionIdOrExternalId, body, $requestOptions); +} + +/** Mark a Session as closed (terminal, idempotent). */ +function closeSession( + sessionIdOrExternalId: string, + body?: CloseSessionRequestBody, + requestOptions?: ApiRequestOptions +): ApiPromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "sessions.close()", + icon: "sessions", + attributes: sessionAttributes(sessionIdOrExternalId, { + ...(body?.reason ? { reason: body.reason } : {}), + }), + }, + requestOptions + ); + + return apiClient.closeSession(sessionIdOrExternalId, body, $requestOptions); +} + +/** + * List Sessions in the current environment with filters + cursor pagination. + * Returns a {@link CursorPagePromise} so callers can iterate pages with + * `for await`. + */ +function listSessions( + options?: ListSessionsOptions, + requestOptions?: ApiRequestOptions +): CursorPagePromise { + const apiClient = apiClientManager.clientOrThrow(); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: "sessions.list()", + icon: "sessions", + attributes: { + ...(options?.type ? { type: toAttr(options.type) } : {}), + ...(options?.tag ? { tag: toAttr(options.tag) } : {}), + ...(options?.status ? { status: toAttr(options.status) } : {}), + ...(options?.externalId ? { externalId: options.externalId } : {}), + }, + }, + requestOptions + ); + + return apiClient.listSessions(options, $requestOptions); +} + +/** + * Open a lightweight handle to a Session's realtime channels. Does not + * perform a network call on its own — each channel method hits the + * corresponding realtime endpoint. + */ +function open(sessionIdOrExternalId: string): SessionHandle { + if (sessionOpenImpl) return sessionOpenImpl(sessionIdOrExternalId); + return new SessionHandle(sessionIdOrExternalId); +} + +export class SessionHandle { + /** + * Producer-to-consumer channel: the task writes records; external + * clients read them. Mirrors `streams.define` — `append` / `pipe` / + * `writer` / `read`. + */ + public readonly out: SessionOutputChannel; + + /** + * Consumer-to-producer channel: external clients call `.send()`; the + * task consumes via `.on` / `.once` / `.peek` / `.wait` / + * `.waitWithIdleTimeout`. Mirrors `streams.input` but keyed on the + * session so a conversation can survive across run boundaries. + */ + public readonly in: SessionInputChannel; + + constructor( + public readonly id: string, + overrides?: { in?: SessionInputChannel; out?: SessionOutputChannel } + ) { + this.out = overrides?.out ?? new SessionOutputChannel(id); + this.in = overrides?.in ?? new SessionInputChannel(id); + } +} + +/** + * Options accepted by {@link SessionOutputChannel.pipe}. Session-scoped, + * so it omits the `target` field (self/parent/root/runId) that run-scoped + * {@link PipeStreamOptions} uses — the session is the target. + */ +export type SessionPipeStreamOptions = Omit; + +/** + * The `.out` side of a Session's bidirectional channel pair. Mirrors the + * consume-side of {@link streams.define}: `pipe` / `writer` / `append` + * for the task to produce records, `read` for external clients to + * consume via SSE. S2 credentials for direct writes are fetched + * internally by `pipe`/`writer` — there's no public `initialize()`. + */ +export class SessionOutputChannel { + constructor(public readonly sessionId: string) {} + + /** + * Append a single record. Routes through {@link writer} internally so + * subscribers receive the same parsed-object shape as multi-record + * writes — the server-side append endpoint wraps the body in a string, + * which would give SSE consumers a JSON-string instead of an object. + * Mirrors how `streams.define.append` delegates to `streams.writer`. + */ + async append(value: T, options?: SessionPipeStreamOptions): Promise { + const { waitUntilComplete } = this.writer({ + ...options, + spanName: "sessions.append()", + execute: ({ write }) => { + write(value); + }, + }); + await waitUntilComplete(); + } + + /** + * Pipe an `AsyncIterable` / `ReadableStream` directly to S2. Fetches + * session S2 credentials internally and streams through + * {@link SessionStreamInstance}. Parallel to {@link streams.pipe} but + * session-scoped — no `target` option because the session is the target. + */ + pipe( + value: AsyncIterable | ReadableStream, + options?: SessionPipeStreamOptions + ): PipeStreamResult { + return this.#pipeInternal(value, options, "sessions.pipe()"); + } + + /** + * Mirror of {@link streams.writer}: runs `execute({ write, merge })` + * against an in-memory queue whose records are piped to S2. Returns + * `{ stream, waitUntilComplete }` so callers can observe the local + * stream and await completion. Span is collapsible via `options.spanName` + * / `options.collapsed`. + */ + writer(options: WriterStreamOptions): PipeStreamResult { + let controller!: ReadableStreamDefaultController; + const ongoingStreamPromises: Promise[] = []; + + const stream = new ReadableStream({ + start(controllerArg) { + controller = controllerArg; + }, + }); + + const safeEnqueue = (data: T) => { + try { + controller.enqueue(data); + } catch { + // Suppress errors when the stream has been closed. + } + }; + + try { + const result = options.execute({ + write(part) { + safeEnqueue(part); + }, + merge(streamArg) { + ongoingStreamPromises.push( + (async () => { + const reader = streamArg.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) break; + safeEnqueue(value); + } + })().catch((error) => { + console.error(error); + }) + ); + }, + }); + + if (result) { + ongoingStreamPromises.push( + result.catch((error) => { + console.error(error); + }) + ); + } + } catch (error) { + console.error(error); + } + + const waitForStreams: Promise = new Promise((resolve, reject) => { + (async () => { + while (ongoingStreamPromises.length > 0) { + await ongoingStreamPromises.shift(); + } + resolve(); + })().catch(reject); + }); + + waitForStreams.finally(() => { + try { + controller.close(); + } catch { + // Already closed. + } + }); + + return this.#pipeInternal(stream, options, options.spanName ?? "sessions.writer()"); + } + + /** + * Subscribe to SSE records on `.out`. Returns an async-iterable stream — + * auto-retry, Last-Event-ID resume, and abort propagation come from the + * shared {@link SSEStreamSubscription} plumbing used by run-scoped + * realtime streams. + */ + async read( + options?: SessionSubscribeOptions + ): Promise> { + const apiClient = apiClientManager.clientOrThrow(); + + return apiClient.subscribeToSessionStream(this.sessionId, "out", { + signal: options?.signal, + timeoutInSeconds: options?.timeoutInSeconds, + lastEventId: + options?.lastEventId != null ? String(options.lastEventId) : undefined, + onPart: options?.onPart, + onComplete: options?.onComplete, + onError: options?.onError, + }); + } + + #pipeInternal( + value: AsyncIterable | ReadableStream, + options: SessionPipeStreamOptions | undefined, + spanName: string + ): PipeStreamResult { + const apiClient = apiClientManager.clientOrThrow(); + const collapsed = (options as WriterStreamOptions | undefined)?.collapsed; + + const span = tracer.startSpan(spanName, { + attributes: { + session: this.sessionId, + io: "out", + [SemanticInternalAttributes.ENTITY_TYPE]: "session-stream", + [SemanticInternalAttributes.ENTITY_ID]: `${this.sessionId}:out`, + [SemanticInternalAttributes.STYLE_ICON]: "sessions", + ...(collapsed ? { [SemanticInternalAttributes.COLLAPSED]: true } : {}), + ...accessoryAttributes({ + items: [{ text: `${this.sessionId}.out`, variant: "normal" }], + style: "codepath", + }), + }, + }); + + const readableStreamSource = ensureReadableStream(value); + + const abortController = new AbortController(); + const combinedSignal = options?.signal + ? AbortSignal.any?.([options.signal, abortController.signal]) ?? abortController.signal + : abortController.signal; + + try { + const instance = new SessionStreamInstance({ + apiClient, + baseUrl: apiClientManager.baseURL ?? "", + sessionId: this.sessionId, + io: "out", + source: readableStreamSource, + signal: combinedSignal, + requestOptions: options?.requestOptions, + }); + + instance.wait().finally(() => { + span.end(); + }); + + return { + stream: instance.stream, + waitUntilComplete: async () => { + return instance.wait(); + }, + }; + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + span.end(); + throw error; + } + + if (error instanceof Error || typeof error === "string") { + span.recordException(error); + } else { + span.recordException(String(error)); + } + + span.setStatus({ code: SpanStatusCode.ERROR }); + span.end(); + + throw error; + } + } +} + +/** + * The `.in` side of a Session's bidirectional channel pair. Mirrors + * {@link streams.input} — consumer-side primitives for the task + * (`on`/`once`/`peek`/`wait`/`waitWithIdleTimeout`) plus `send` for + * external clients. Keyed on the session rather than the run so a + * conversation can survive across run boundaries. + */ +export class SessionInputChannel { + constructor(public readonly sessionId: string) {} + + /** + * Send a single record to the channel. Called by external clients + * (browser, server action, another task) producing input for the run. + * Matches {@link streams.input.send} but session-scoped — the session + * is the address, no `runId` required. + */ + async send(value: unknown, requestOptions?: ApiRequestOptions): Promise { + const apiClient = apiClientManager.clientOrThrow(); + const body = typeof value === "string" ? value : JSON.stringify(value); + + const $requestOptions = mergeRequestOptions( + { + tracer, + name: `sessions.open(${this.sessionId}).in.send()`, + icon: "sessions", + attributes: sessionAttributes(this.sessionId, { io: "in" }), + }, + requestOptions + ); + + await apiClient.appendToSessionStream(this.sessionId, "in", body, $requestOptions); + } + + /** + * Register a handler that fires for every record landing on `.in`. + * Handlers are flushed with any buffered records on attach and cleaned + * up automatically when the task run completes. Returns `{ off }` to + * unsubscribe early. + */ + on(handler: (data: T) => void | Promise): { off: () => void } { + return sessionStreams.on( + this.sessionId, + "in", + handler as (data: unknown) => void | Promise + ); + } + + /** + * Wait for the next record on `.in` without suspending the run. + * Returns `{ ok: true, output }` on arrival or `{ ok: false, error }` + * when the timeout fires. Chain `.unwrap()` to get the data directly. + */ + once(options?: InputStreamOnceOptions): InputStreamOncePromise { + const ctx = taskContext.ctx; + const runId = ctx?.run.id; + + const innerPromise = sessionStreams.once(this.sessionId, "in", options); + + return new InputStreamOncePromise((resolve, reject) => { + tracer + .startActiveSpan( + options?.spanName ?? `sessions.open(${this.sessionId}).in.once()`, + async () => { + const result = await innerPromise; + resolve(result as InputStreamOnceResult); + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "sessions", + [SemanticInternalAttributes.ENTITY_TYPE]: "session-stream", + ...(runId + ? { [SemanticInternalAttributes.ENTITY_ID]: `${runId}:${this.sessionId}:in` } + : {}), + session: this.sessionId, + io: "in", + ...accessoryAttributes({ + items: [{ text: `${this.sessionId}.in`, variant: "normal" }], + style: "codepath", + }), + }, + } + ) + .catch(reject); + }); + } + + /** Non-blocking peek at the head of the `.in` buffer. */ + peek(): T | undefined { + return sessionStreams.peek(this.sessionId, "in") as T | undefined; + } + + /** + * Suspend the current run until the next record arrives on `.in`. + * Unlike {@link once}, `wait()` frees compute while blocked — the + * run-engine waitpoint holds the run until the session append handler + * fires it. Only callable from inside `task.run()`. + */ + wait(options?: InputStreamWaitOptions): ManualWaitpointPromise { + return new ManualWaitpointPromise(async (resolve, reject) => { + try { + const ctx = taskContext.ctx; + + if (!ctx) { + throw new Error("session.in.wait() can only be used from inside a task.run()"); + } + + const apiClient = apiClientManager.clientOrThrow(); + + const response = await apiClient.createSessionStreamWaitpoint(ctx.run.id, { + session: this.sessionId, + io: "in", + timeout: options?.timeout, + idempotencyKey: options?.idempotencyKey, + idempotencyKeyTTL: options?.idempotencyKeyTTL, + tags: options?.tags, + lastSeqNum: sessionStreams.lastSeqNum(this.sessionId, "in"), + }); + + const result = await tracer.startActiveSpan( + options?.spanName ?? `sessions.open(${this.sessionId}).in.wait()`, + async (span) => { + const waitResponse = await apiClient.waitForWaitpointToken({ + runFriendlyId: ctx.run.id, + waitpointFriendlyId: response.waitpointId, + }); + + if (!waitResponse.success) { + throw new Error("Failed to block on session stream waitpoint"); + } + + // Drop the SSE tail + buffer before suspending so the record + // delivered via the waitpoint path isn't re-buffered on resume. + sessionStreams.disconnectStream(this.sessionId, "in"); + + const waitResult = await runtime.waitUntil(response.waitpointId); + + const data = + waitResult.output !== undefined + ? await conditionallyImportAndParsePacket( + { + data: waitResult.output, + dataType: waitResult.outputType ?? "application/json", + }, + apiClient + ) + : undefined; + + if (waitResult.ok) { + // Advance the seq counter so the SSE tail doesn't replay the + // record that was consumed via the waitpoint. + const prevSeq = sessionStreams.lastSeqNum(this.sessionId, "in"); + const nextSeq = (prevSeq ?? -1) + 1; + sessionStreams.setLastSeqNum(this.sessionId, "in", nextSeq); + + return { ok: true as const, output: data as T }; + } else { + const error = new WaitpointTimeoutError(data?.message ?? "Timed out"); + span.recordException(error); + span.setStatus({ code: SpanStatusCode.ERROR }); + return { ok: false as const, error }; + } + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "wait", + [SemanticInternalAttributes.ENTITY_TYPE]: "waitpoint", + [SemanticInternalAttributes.ENTITY_ID]: response.waitpointId, + session: this.sessionId, + io: "in", + ...accessoryAttributes({ + items: [{ text: `${this.sessionId}.in`, variant: "normal" }], + style: "codepath", + }), + }, + } + ); + + resolve(result); + } catch (error) { + reject(error); + } + }); + } + + /** + * Wait for a record with an idle-then-suspend strategy. Keeps the run + * active (using compute) for `idleTimeoutInSeconds`, then suspends via + * {@link wait} if nothing arrives. If a record arrives during the idle + * phase the run responds without suspending. + */ + async waitWithIdleTimeout( + options: InputStreamWaitWithIdleTimeoutOptions + ): Promise<{ ok: true; output: T } | { ok: false; error?: Error }> { + const self = this; + const spanName = + options.spanName ?? `sessions.open(${this.sessionId}).in.waitWithIdleTimeout()`; + + return tracer.startActiveSpan( + spanName, + async (span) => { + if (options.idleTimeoutInSeconds > 0) { + const warm = await sessionStreams.once(self.sessionId, "in", { + timeoutMs: options.idleTimeoutInSeconds * 1000, + }); + if (warm.ok) { + span.setAttribute("wait.resolved", "idle"); + return { ok: true as const, output: warm.output as T }; + } + } + + if (options.skipSuspend) { + // Match the cold-phase `self.wait()` result shape below so any + // caller that does `throw result.error` gets a real error + // instead of `undefined`. + span.setAttribute("wait.resolved", "skipped"); + return { + ok: false as const, + error: new WaitpointTimeoutError( + "Idle timeout elapsed and skipSuspend is set" + ), + }; + } + + if (options.onSuspend) { + await options.onSuspend(); + } + + span.setAttribute("wait.resolved", "suspended"); + const waitResult = await self.wait({ + timeout: options.timeout, + spanName: "suspended", + }); + + if (waitResult.ok && options.onResume) { + await options.onResume(); + } + + return waitResult; + }, + { + attributes: { + [SemanticInternalAttributes.STYLE_ICON]: "sessions", + session: self.sessionId, + io: "in", + ...accessoryAttributes({ + items: [{ text: `${self.sessionId}.in`, variant: "normal" }], + style: "codepath", + }), + }, + } + ); + } +} + +export type SessionSubscribeOptions = { + signal?: AbortSignal; + lastEventId?: string | number; + /** Timeout in seconds for the underlying long-poll (max 600). */ + timeoutInSeconds?: number; + /** Called for each SSE event with the full event metadata (id, timestamp). */ + onPart?: (part: { id: string; chunk: T; timestamp: number }) => void; + /** Called when the server signals end-of-stream. */ + onComplete?: () => void; + /** Called on unrecoverable errors after the retry budget is exhausted. */ + onError?: (error: Error) => void; +}; + +// ─── helpers ──────────────────────────────────────────────────────── + +function sessionAttributes(id: string, extra?: Record) { + return { + session: id, + ...(extra ?? {}), + ...accessoryAttributes({ + items: [{ text: id, variant: "normal" }], + style: "codepath", + }), + }; +} + +function toAttr(value: string | string[]): string { + return Array.isArray(value) ? value.join(",") : value; +} diff --git a/packages/trigger-sdk/src/v3/skill.ts b/packages/trigger-sdk/src/v3/skill.ts new file mode 100644 index 00000000000..a3c145d3836 --- /dev/null +++ b/packages/trigger-sdk/src/v3/skill.ts @@ -0,0 +1,211 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { resourceCatalog } from "@trigger.dev/core/v3"; + +/** + * Parsed `SKILL.md` frontmatter. Only `name` + `description` are required; + * additional keys are preserved but untyped. + */ +export type SkillFrontmatter = { + name: string; + description: string; + [key: string]: unknown; +}; + +/** + * A resolved skill ready to hand to `chat.skills.set()`. Includes the parsed + * SKILL.md content plus the on-disk path to the bundled skill folder. + */ +export type ResolvedSkill = { + id: string; + /** Skill version — `"local"` in Phase 1 until backend-managed overrides land. */ + version: number | "local"; + /** Labels applied to this version — empty in Phase 1. */ + labels: string[]; + /** Full raw `SKILL.md` content (with frontmatter). */ + skillMd: string; + /** Parsed frontmatter fields. */ + frontmatter: SkillFrontmatter; + /** Body of SKILL.md with the frontmatter block stripped. */ + body: string; + /** Absolute path to the bundled skill folder (scripts, references, assets live here). */ + path: string; +}; + +export type SkillOptions = { + id: TIdentifier; + /** Path to the skill source folder, relative to the project root. */ + path: string; +}; + +export type SkillHandle = { + id: TIdentifier; + /** + * Read the bundled `SKILL.md` from disk and return the resolved skill. + * + * This is the Phase 1 path — backend-managed overrides are not available + * yet. Works locally (during `trigger dev`) and in the deploy image. + */ + local(): Promise; + /** + * Resolve the skill against the dashboard (current/override version). + * + * Not available in Phase 1 — throws. Use `local()` until backend-managed + * skills ship. + */ + resolve(): Promise; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnySkillHandle = SkillHandle; + +/** Extract the id literal type from a SkillHandle. */ +export type SkillIdentifier = T extends SkillHandle + ? TId + : string; + +/** + * Bundled skills are copied to `${cwd}/.trigger/skills/{id}/` by the CLI at + * build time. At runtime the same layout holds for both `trigger dev` (cwd + * = dev output dir) and deploy (cwd = /app). + */ +function bundledSkillPath(id: string): string { + return path.resolve(process.cwd(), ".trigger", "skills", id); +} + +const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n*/; + +/** + * Parse a minimal YAML-subset frontmatter block. We only support top-level + * string keys like `name: foo` and `description: bar`. Enough for SKILL.md + * frontmatter without pulling in a YAML dep. + */ +export function parseFrontmatter(content: string): { + frontmatter: SkillFrontmatter; + body: string; +} { + const match = content.match(FRONTMATTER_RE); + if (!match || !match[1]) { + throw new Error( + "Skill: SKILL.md is missing a frontmatter block. " + + "Expected `---\\nname: ...\\ndescription: ...\\n---` at the top of the file." + ); + } + + const raw = match[1]; + const frontmatter: Record = {}; + for (const line of raw.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const idx = trimmed.indexOf(":"); + if (idx === -1) continue; + const key = trimmed.slice(0, idx).trim(); + let value = trimmed.slice(idx + 1).trim(); + // Strip surrounding quotes if present + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + if (key) frontmatter[key] = value; + } + + if (typeof frontmatter.name !== "string" || !frontmatter.name) { + throw new Error("Skill: SKILL.md frontmatter is missing required `name` field."); + } + if (typeof frontmatter.description !== "string" || !frontmatter.description) { + throw new Error("Skill: SKILL.md frontmatter is missing required `description` field."); + } + + const body = content.slice(match[0].length); + + return { frontmatter: frontmatter as SkillFrontmatter, body }; +} + +async function loadLocal(id: string): Promise { + const skillPath = bundledSkillPath(id); + const skillMdPath = path.join(skillPath, "SKILL.md"); + + let skillMd: string; + try { + skillMd = await fs.readFile(skillMdPath, "utf8"); + } catch (err) { + throw new Error( + `Skill "${id}": could not read SKILL.md at ${skillMdPath}. ` + + `Skills must be bundled into .trigger/skills/{id}/ — this usually means ` + + `the CLI build step didn't run, or the skill wasn't registered via ai.defineSkill. ` + + `Underlying error: ${(err as Error).message}` + ); + } + + const { frontmatter, body } = parseFrontmatter(skillMd); + + return { + id, + version: "local", + labels: [], + skillMd, + frontmatter, + body, + path: skillPath, + }; +} + +/** + * Define an agent skill — a developer-authored folder with a `SKILL.md` file + * plus optional `scripts/`, `references/`, and `assets/` subfolders. Registers + * the skill with the resource catalog so the Trigger.dev CLI can bundle it + * into the deploy image automatically (no build extension needed). + * + * Call `.local()` on the returned handle to load the bundled SKILL.md at + * runtime and use it with `chat.skills.set()`. + * + * @example + * ```ts + * // trigger/skills/pdf-processing/SKILL.md + * // trigger/skills/pdf-processing/scripts/extract.py + * import { ai } from "@trigger.dev/sdk"; + * + * export const pdfSkill = ai.defineSkill({ + * id: "pdf-processing", + * path: "./skills/pdf-processing", + * }); + * + * export const agent = chat.agent({ + * id: "docs", + * onChatStart: async () => { + * chat.skills.set([await pdfSkill.local()]); + * }, + * run: async ({ messages, signal }) => { + * return streamText({ + * model: openai("gpt-4o"), + * messages, + * abortSignal: signal, + * ...chat.toStreamTextOptions(), + * }); + * }, + * }); + * ``` + */ +export function defineSkill( + options: SkillOptions +): SkillHandle { + resourceCatalog.registerSkillMetadata({ + id: options.id, + sourcePath: options.path, + }); + + return { + id: options.id, + async local() { + return loadLocal(options.id); + }, + async resolve() { + throw new Error( + `Skill "${options.id}": resolve() is not available yet — backend-managed ` + + `skills ship in Phase 2. Use skill.local() instead.` + ); + }, + }; +} diff --git a/packages/trigger-sdk/src/v3/skills.ts b/packages/trigger-sdk/src/v3/skills.ts new file mode 100644 index 00000000000..6811cda75f4 --- /dev/null +++ b/packages/trigger-sdk/src/v3/skills.ts @@ -0,0 +1,9 @@ +export { defineSkill as define } from "./skill.js"; +export type { + AnySkillHandle, + ResolvedSkill, + SkillFrontmatter, + SkillHandle, + SkillIdentifier, + SkillOptions, +} from "./skill.js"; diff --git a/packages/trigger-sdk/src/v3/test/index.ts b/packages/trigger-sdk/src/v3/test/index.ts new file mode 100644 index 00000000000..cdeded1a7a8 --- /dev/null +++ b/packages/trigger-sdk/src/v3/test/index.ts @@ -0,0 +1,23 @@ +// Importing this module installs an in-memory resource catalog so that +// chat.agent() calls (which run at import time) register their task +// functions where the test harness can find them. +// +// Users should import `@trigger.dev/sdk/ai/test` BEFORE their agent +// modules so the registration side-effect runs first. +import "./setup-catalog.js"; + +export { + mockChatAgent, + type MockChatAgentOptions, + type MockChatAgentHarness, + type MockChatAgentTurn, +} from "./mock-chat-agent.js"; + +// Re-export the lower-level task context harness so consumers can build +// their own test helpers without adding a separate `@trigger.dev/core` +// dependency to their reference projects. +export { + runInMockTaskContext, + type MockTaskContextDrivers, + type MockTaskContextOptions, +} from "@trigger.dev/core/v3/test"; diff --git a/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts b/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts new file mode 100644 index 00000000000..32fdba57cd8 --- /dev/null +++ b/packages/trigger-sdk/src/v3/test/mock-chat-agent.ts @@ -0,0 +1,738 @@ +import type { UIMessage, UIMessageChunk } from "ai"; +import { resourceCatalog } from "@trigger.dev/core/v3"; +import type { LocalsKey } from "@trigger.dev/core/v3"; +import { + runInMockTaskContext, + type MockTaskContextOptions, +} from "@trigger.dev/core/v3/test"; +import { + __setSessionOpenImplForTests, + __setSessionStartImplForTests, +} from "../sessions.js"; +import { + __setReadChatSnapshotImplForTests, + __setReplaySessionOutTailImplForTests, + __setWriteChatSnapshotImplForTests, + type ChatSnapshotV1, +} from "../ai.js"; +import { + createTestSessionHandle, + type TestSessionOutState, +} from "./test-session-handle.js"; + +/** Pre-seed locals before the agent's `run()` starts. */ +export type SetupLocals = (locals: { + set(key: LocalsKey, value: T): void; +}) => void | Promise; + +// The slim wire payload shape used by chat.agent tasks. Kept loose here so we +// don't import from the backend-only ai.ts module. At most ONE message per +// record — runtime rebuilds prior history from snapshot + replay at boot. +type ChatWirePayload = { + /** At most one message — singular under the slim wire. Set on submit-message. */ + message?: UIMessage; + /** Bespoke escape hatch — only set on `trigger: "handover-prepare"`. */ + headStartMessages?: UIMessage[]; + chatId: string; + trigger: + | "submit-message" + | "regenerate-message" + | "preload" + | "close" + | "action" + | "handover-prepare"; + messageId?: string; + metadata?: unknown; + action?: unknown; + continuation?: boolean; + previousRunId?: string; + idleTimeoutInSeconds?: number; + sessionId?: string; +}; + +/** A reference to a `chat.agent` task returned by `chat.agent({ id, ... })`. */ +type ChatAgentHandle = { id: string }; + +/** + * Options for `mockChatAgent`. + */ +export type MockChatAgentOptions = { + /** The chat session id passed into every wire payload. Defaults to `"test-chat"`. */ + chatId?: string; + /** Client-provided metadata (`clientData`) for the session. */ + clientData?: unknown; + /** Task context overrides passed through to {@link runInMockTaskContext}. */ + taskContext?: MockTaskContextOptions; + /** + * Whether to start the task in preload mode. Defaults to `true` so the + * first `sendMessage()` triggers the first turn via the preload path. + * Set to `false` to skip preload — the first `sendMessage()` starts turn 0 directly. + * + * Ignored when `mode: "handover-prepare"` is set. + */ + preload?: boolean; + /** + * Initial trigger the agent boots with. Defaults to `"preload"` (or + * `"submit-message"` when `preload: false`, or `"continuation"` when + * `continuation: true`). + * + * - `"preload"` — fresh chat preloaded via `transport.preload`. Fires + * `onPreload`, waits for the first message. + * - `"submit-message"` — fresh chat with the first message in the boot + * payload (the `chat.createStartSessionAction({ basePayload: { message } })` + * pattern). Goes straight to turn 0. + * - `"continuation"` — new run picking up an existing session after the + * prior run ended (`chat.endRun`, waitpoint timeout, `chat.requestUpgrade`). + * Boots with `trigger` omitted and `continuation: true` — mirrors what + * the server's `ensureRunForSession` / `swapSessionRun` produces in + * production. The SDK enters its continuation-wait branch; `onPreload` + * and `onChatStart` do NOT fire on this run. + * - `"handover-prepare"` — drives the chat.handover wait branch; call + * `sendHandover()` / `sendHandoverSkip()` to dispatch the handover signal. + */ + mode?: "preload" | "submit-message" | "handover-prepare" | "continuation"; + /** + * Pre-seed the snapshot the agent reads at run boot. The runtime's + * snapshot read is replaced with one that returns this snapshot + * (skipping the real S3 GET). Use to drive boot scenarios — fresh + * boot with prior history, OOM-retry boot with stale snapshot, etc. + * Pass `undefined` (the default) to start with no snapshot. + * + * See plan section B.3 for the boot orchestration spec. + */ + snapshot?: ChatSnapshotV1; + /** + * Set `payload.continuation = true` on the initial wire payload. Used + * to simulate a continuation-run boot (a new run picking up after a + * prior run on the same session ended via `chat.endRun`, waitpoint + * timeout, or `chat.requestUpgrade`). + * + * Setting this without specifying `mode` auto-selects `mode: + * "continuation"` — the SDK boot path enters its continuation-wait + * branch and waits silently on `session.in` for the first user + * message. `onPreload` and `onChatStart` do NOT fire on this run. + * + * Defaults to `false` (fresh run). + */ + continuation?: boolean; + /** + * Set `payload.previousRunId` on the initial wire payload. Forwarded + * to `onChatStart` / `onTurnStart` and used by the boot gate as a + * prior-state signal. Usually paired with `continuation: true`. + */ + previousRunId?: string; + /** + * Callback that runs **before** the agent's `run()` is invoked, with a + * `set` function for pre-seeding locals. Use this to inject server-side + * dependencies (database clients, service stubs) that the agent reads + * via `locals.get()` in its hooks. + * + * @example + * ```ts + * import { dbKey } from "./db"; + * + * const harness = mockChatAgent(agent, { + * chatId: "test-1", + * setupLocals: (locals) => { + * locals.set(dbKey, testDb); + * }, + * }); + * ``` + */ + setupLocals?: SetupLocals; +}; + +/** + * Result of a single turn, returned by driver methods like `sendMessage()`. + */ +export type MockChatAgentTurn = { + /** UIMessageChunks emitted during this turn (excludes control chunks like turn-complete). */ + chunks: UIMessageChunk[]; + /** All raw chunks including control chunks (turn-complete, upgrade-required, etc.). */ + rawChunks: unknown[]; +}; + +/** + * Harness returned by `mockChatAgent`. Drives a `chat.agent` task end-to-end + * without network or task runtime. + */ +export type MockChatAgentHarness = { + /** The chat session id used by this harness. */ + readonly chatId: string; + + /** + * Send a single user message (or tool-approval-responded assistant + * message) and wait for the next turn-complete. Returns the chunks + * produced during this turn. + * + * Slim wire: at most ONE message per send. The agent reconstructs prior + * history from snapshot + session.out replay at run boot. + */ + sendMessage(message: UIMessage): Promise; + + /** + * Send a regenerate signal (no message body — slim wire). The agent + * trims trailing assistant messages from its in-memory accumulator and + * re-runs. Waits for turn-complete. + */ + sendRegenerate(): Promise; + + /** + * Drive the head-start path: sends `trigger: "handover-prepare"` with + * `headStartMessages` carrying the first-turn UIMessage history. Used + * only at the very first turn before any snapshot exists. The route + * handler ships full UIMessage history through this path because the + * customer's HTTP endpoint isn't subject to the `/in/append` cap. + */ + sendHeadStart(args: { messages: UIMessage[] }): Promise; + + /** Send a custom action and wait for the next turn-complete. */ + sendAction(action: unknown): Promise; + + /** Fire a stop signal. Does not wait for the turn — the task keeps running. */ + sendStop(message?: string): Promise; + + /** + * Dispatch a `handover` signal — the agent picks up partial assistant + * messages and continues the turn. Only meaningful when the harness + * was started with `mode: "handover-prepare"`. Waits for turn-complete. + * + * `isFinal: false` (default) — agent runs `streamText` which executes + * any pending tool-calls (via the approval round) and resumes from + * step 2. + * + * `isFinal: true` — agent runs lifecycle hooks but skips `streamText`. + * The partial IS the response; `onTurnComplete` fires with it. + */ + sendHandover(args: { + partialAssistantMessage: unknown[]; + isFinal?: boolean; + messageId?: string; + }): Promise; + + /** + * Dispatch a `handover-skip` signal — the agent exits cleanly without + * firing turn hooks. Only meaningful when the harness was started + * with `mode: "handover-prepare"`. Awaits the run finishing. + */ + sendHandoverSkip(): Promise; + + /** + * Pre-seed the snapshot read for the next boot. The runtime's snapshot + * read returns this snapshot (skipping S3). Pass `undefined` to clear — + * the boot then sees no snapshot and falls through to replay-only. + * + * Effective on the next run boot only. Calling mid-turn is a no-op + * because the snapshot read happens once at run boot. + */ + seedSnapshot(snapshot: ChatSnapshotV1 | undefined): void; + + /** + * Pre-seed `session.out` chunks for the next boot's replay. The runtime's + * `replaySessionOutTail` returns whatever the synthetic chunks reduce + * to. Pass `[]` to clear (boot replay returns no messages). + * + * Requires `__setReplaySessionOutTailImplForTests` exported from + * `ai.ts`. The harness throws a clear error at call time if that hook + * isn't available. + */ + seedSessionOutTail(chunks?: UIMessageChunk[]): void; + + /** + * The most recently written snapshot, or `undefined` if no snapshot + * has been written yet. Updated each time `writeChatSnapshot` is + * invoked from the run loop's snapshot-write site (plan section B.6). + */ + getSnapshot(): ChatSnapshotV1 | undefined; + + /** + * Close the chat session cleanly. Sends `trigger: "close"` and awaits the + * task's `run()` function returning. Call this at the end of every test + * (or use `await using`) so the background task isn't left dangling. + */ + close(): Promise; + + /** All UIMessageChunks emitted since the harness was created. */ + readonly allChunks: UIMessageChunk[]; + + /** Every raw chunk (including control chunks) emitted since the harness was created. */ + readonly allRawChunks: unknown[]; +}; + +const CONTROL_CHUNK_TYPES = new Set([ + "trigger:turn-complete", + "trigger:upgrade-required", +]); + +function isControlChunk(chunk: unknown): boolean { + if (typeof chunk !== "object" || chunk === null) return false; + const type = (chunk as { type?: string }).type; + return typeof type === "string" && CONTROL_CHUNK_TYPES.has(type); +} + +/** + * Create an offline test harness for a `chat.agent` task. + * + * The harness starts the agent's `run()` function in a mocked task context, + * waits in preload for the first message, then exposes driver methods for + * sending messages / actions / stop signals and awaiting turn completion. + * + * Users are responsible for mocking the language model themselves — use + * `MockLanguageModelV3` and `simulateReadableStream` from `ai/test` inside + * their agent's `run()` function (typically via DI through `clientData`). + * + * @example + * ```ts + * import { mockChatAgent } from "@trigger.dev/sdk/ai/test"; + * import { MockLanguageModelV3, simulateReadableStream } from "ai/test"; + * import { myAgent } from "./my-agent"; + * + * test("says hello", async () => { + * const harness = mockChatAgent(myAgent, { chatId: "test-1" }); + * try { + * const turn = await harness.sendMessage({ + * id: "m1", + * role: "user", + * parts: [{ type: "text", text: "hi" }], + * }); + * expect(turn.chunks).toContainEqual( + * expect.objectContaining({ type: "text-delta", delta: "hello" }) + * ); + * } finally { + * await harness.close(); + * } + * }); + * ``` + */ +export function mockChatAgent( + agent: ChatAgentHandle, + options: MockChatAgentOptions = {} +): MockChatAgentHarness { + const chatId = options.chatId ?? "test-chat"; + // The agent opens the session with `payload.sessionId ?? payload.chatId`. + // We pass no sessionId, so it falls back to chatId. + const sessionId = chatId; + // `continuation: true` without an explicit mode auto-selects "continuation" + // — the canonical shape for a continuation-run boot. + const mode: "preload" | "submit-message" | "handover-prepare" | "continuation" = + options.mode ?? + (options.continuation === true + ? "continuation" + : options.preload === false + ? "submit-message" + : "preload"); + const clientData = options.clientData; + + const taskEntry = resourceCatalog.getTask(agent.id); + if (!taskEntry) { + throw new Error( + `mockChatAgent: no task registered with id "${agent.id}". ` + + `Import "@trigger.dev/sdk/ai/test" before your agent module so tasks register correctly.` + ); + } + + const runFn = taskEntry.fns.run; + + // Session .out state: chunks + listener registry. Shared between the + // harness and the TestSessionOutputChannel installed via the open-override. + const sessionOutState: TestSessionOutState = { + chunks: [], + listeners: new Set(), + }; + + // Buffers that survive across harness method calls + const allRawChunks: unknown[] = []; + const allChunks: UIMessageChunk[] = []; + + // Promise that resolves when the background task run() function returns. + let taskFinished!: Promise; + let sendSessionInput!: (sessionId: string, data: unknown) => Promise; + let closeSessionInput: ((sessionId: string) => void) | undefined; + let runSignal!: AbortController; + + // A latch that resolves every time `trigger:turn-complete` appears on the chat stream. + // We use a shared pending promise and replace it after each completion. + let turnCompleteResolvers: Array<() => void> = []; + const waitForTurnComplete = () => + new Promise((resolve) => { + turnCompleteResolvers.push(resolve); + }); + + // Signal that the caller is ready to observe output + let harnessReadyResolve!: () => void; + const harnessReady = new Promise((resolve) => { + harnessReadyResolve = resolve; + }); + + // ── Snapshot read/write override state ─────────────────────────────── + // The runtime's snapshot read returns whatever `seededSnapshot` is at + // boot time. The runtime's snapshot write captures into + // `lastWrittenSnapshot` for harness consumers to assert via + // `getSnapshot()`. Installed below alongside the session overrides; + // cleared on close in the same finally block. + let seededSnapshot: ChatSnapshotV1 | undefined = options.snapshot; + let lastWrittenSnapshot: ChatSnapshotV1 | undefined; + let seededReplayChunks: UIMessageChunk[] = []; + + __setReadChatSnapshotImplForTests((_id: string) => { + return seededSnapshot as ChatSnapshotV1 | undefined; + }); + __setWriteChatSnapshotImplForTests((_id: string, snapshot: ChatSnapshotV1) => { + lastWrittenSnapshot = snapshot as ChatSnapshotV1; + }); + + // Replay override: install a default that returns whatever + // `seededReplayChunks` reduces to. Cleared in the same `finally` block + // as the other test overrides. + __setReplaySessionOutTailImplForTests(async () => { + if (seededReplayChunks.length === 0) return []; + return (await reduceChunksToMessages(seededReplayChunks)) as never; + }); + + // Install the session open override so `sessions.open(id)` returns a + // SessionHandle with an in-memory `.out` that captures writes. The + // `.in` channel routes record subscriptions (`on`/`once`/`peek`) + // through the `sessionStreams` global — the mock task context + // installs a `TestSessionStreamManager` there — and stubs `wait()` + // so the suspend path resolves cleanly on `runSignal.abort()` without + // touching the api client. + __setSessionOpenImplForTests((id) => + createTestSessionHandle(id, sessionOutState, () => runSignal?.signal) + ); + + // Install the session start override so any test path that invokes + // `sessions.start()` (typically through a server action shim like + // `chat.createStartSessionAction`) becomes a no-op fixture instead of + // hitting a real API. Most chat.agent tests trigger the run directly + // via `sendPayloadAndWait` and never go through this path, but the + // stub keeps the API safe to call from inside tested code. + __setSessionStartImplForTests((body) => { + if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") { + console.log("[mockChatAgent] sessions.start override:", body); + } + const fakeRunId = `run_test_${body.externalId ?? "anon"}`; + return { + id: `session_test_${body.externalId ?? "anon"}`, + externalId: body.externalId ?? null, + type: body.type, + taskIdentifier: body.taskIdentifier, + triggerConfig: body.triggerConfig, + currentRunId: fakeRunId, + runId: fakeRunId, + publicAccessToken: "tr_test_session_pat", + tags: body.tags ?? [], + metadata: (body.metadata ?? null) as Record | null, + closedAt: null, + closedReason: null, + expiresAt: null, + createdAt: new Date(0), + updatedAt: new Date(0), + isCached: false, + }; + }); + + taskFinished = runInMockTaskContext( + async (drivers) => { + runSignal = new AbortController(); + + // For `mode: "continuation"`, omit `trigger` from the wire payload — + // mirrors what the server's `ensureRunForSession` / `swapSessionRun` + // produces (the continuation overrides clear `trigger` so the SDK + // boot path falls into the continuation-wait branch instead of + // re-firing the basePayload's stale first-run trigger). `continuation: + // true` is set unconditionally for this mode so the boot path's + // continuation-wait condition matches. + const isContinuationMode = mode === "continuation"; + const initialPayload: ChatWirePayload = { + chatId, + ...(isContinuationMode + ? { trigger: undefined as never, continuation: true } + : { trigger: mode }), + metadata: clientData, + ...(!isContinuationMode && options.continuation ? { continuation: true } : {}), + ...(options.previousRunId ? { previousRunId: options.previousRunId } : {}), + }; + + sendSessionInput = drivers.sessions.in.send; + closeSessionInput = drivers.sessions.in.close; + + // Record every chunk written to session.out, detect turn-complete. + const listener = (chunk: unknown) => { + allRawChunks.push(chunk); + if (!isControlChunk(chunk)) { + allChunks.push(chunk as UIMessageChunk); + } + if ( + typeof chunk === "object" && + chunk !== null && + (chunk as { type?: string }).type === "trigger:turn-complete" + ) { + const resolvers = turnCompleteResolvers; + turnCompleteResolvers = []; + for (const resolve of resolvers) resolve(); + } + }; + sessionOutState.listeners.add(listener); + const unsubscribe = () => sessionOutState.listeners.delete(listener); + + if (options.setupLocals) { + await options.setupLocals({ set: drivers.locals.set }); + } + + harnessReadyResolve(); + + try { + if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") { + console.log("[mockChatAgent] Starting runFn with payload:", initialPayload); + } + await runFn(initialPayload, { + ctx: drivers.ctx, + signal: runSignal.signal, + }); + if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") { + console.log("[mockChatAgent] runFn returned"); + } + } catch (err) { + if (process.env.TRIGGER_CHAT_TEST_DEBUG === "1") { + console.log("[mockChatAgent] runFn threw:", err); + } + throw err; + } finally { + unsubscribe(); + // Resolve any outstanding turn-complete waiters so callers don't hang + const resolvers = turnCompleteResolvers; + turnCompleteResolvers = []; + for (const resolve of resolvers) resolve(); + } + }, + options.taskContext + ) + .catch((err) => { + // Propagate errors to pending turn waiters instead of dropping them + const resolvers = turnCompleteResolvers; + turnCompleteResolvers = []; + for (const resolve of resolvers) resolve(); + throw err; + }) + .finally(() => { + // Always clear the test overrides, even if the task threw. + __setSessionOpenImplForTests(undefined); + __setSessionStartImplForTests(undefined); + __setReadChatSnapshotImplForTests(undefined); + __setWriteChatSnapshotImplForTests(undefined); + __setReplaySessionOutTailImplForTests(undefined); + }); + + const sendPayloadAndWait = async ( + payload: ChatWirePayload + ): Promise => { + await harnessReady; + const before = allRawChunks.length; + const turnComplete = waitForTurnComplete(); + await sendSessionInput(sessionId, { kind: "message", payload }); + await turnComplete; + const rawChunks = allRawChunks.slice(before); + const chunks = rawChunks.filter( + (c) => !isControlChunk(c) + ) as UIMessageChunk[]; + return { chunks, rawChunks }; + }; + + const harness: MockChatAgentHarness = { + chatId, + + async sendMessage(message) { + return sendPayloadAndWait({ + message, + chatId, + trigger: "submit-message", + metadata: clientData, + }); + }, + + async sendRegenerate() { + return sendPayloadAndWait({ + chatId, + trigger: "regenerate-message", + metadata: clientData, + }); + }, + + async sendHeadStart({ messages }) { + return sendPayloadAndWait({ + headStartMessages: messages, + chatId, + trigger: "handover-prepare", + metadata: clientData, + }); + }, + + async sendAction(action) { + return sendPayloadAndWait({ + chatId, + trigger: "action", + action, + metadata: clientData, + }); + }, + + async sendStop(message) { + await harnessReady; + await sendSessionInput(sessionId, { kind: "stop", message }); + }, + + async sendHandover(args) { + await harnessReady; + const before = allRawChunks.length; + const turnComplete = waitForTurnComplete(); + await sendSessionInput(sessionId, { + kind: "handover", + partialAssistantMessage: args.partialAssistantMessage, + messageId: args.messageId, + isFinal: args.isFinal ?? false, + }); + await turnComplete; + const rawChunks = allRawChunks.slice(before); + const chunks = rawChunks.filter((c) => !isControlChunk(c)) as UIMessageChunk[]; + return { chunks, rawChunks }; + }, + + async sendHandoverSkip() { + await harnessReady; + // No turn-complete on skip — the agent exits without firing hooks. + // Send the chunk and wait for the run to finish. + await sendSessionInput(sessionId, { kind: "handover-skip" }); + await Promise.race([ + taskFinished.catch(() => {}), + new Promise((resolve) => setTimeout(resolve, 1000)), + ]); + }, + + seedSnapshot(snapshot) { + seededSnapshot = snapshot; + }, + + seedSessionOutTail(chunks) { + seededReplayChunks = chunks ?? []; + }, + + getSnapshot() { + return lastWrittenSnapshot; + }, + + async close() { + await harnessReady; + + // Send a close trigger wrapped as a `kind: "message"` ChatInputChunk. + // The turn loop checks for this after a successful turn and exits + // cleanly. On error-recovery paths the loop just loops back with + // the close payload, so we also close the session input below to + // unblock any pending once() waiters. + try { + await sendSessionInput(sessionId, { + kind: "message", + payload: { + chatId, + trigger: "close", + }, + }); + } catch { + // best-effort + } + // Resolve any pending once() waiters on the session input with a + // timeout error — that makes waitWithIdleTimeout return + // `{ ok: false }` and the turn loop exits cleanly. + closeSessionInput?.(sessionId); + + // Also abort the run signal so anything downstream (streamText, + // deferred work) unwinds promptly. + runSignal?.abort("close"); + + // Wait for run() to return. The loop's error recovery path will + // see !next.ok and exit. Use a bounded wait so tests never hang. + await Promise.race([ + taskFinished.catch(() => {}), + new Promise((resolve) => setTimeout(resolve, 1000)), + ]); + }, + + get allChunks() { + return allChunks.slice(); + }, + + get allRawChunks() { + return allRawChunks.slice(); + }, + }; + + return harness; +} + +/** + * Reduce a synthetic UIMessageChunk[] sequence into the UIMessage[] that + * the runtime's `replaySessionOutTail` would produce. Splits chunks at + * `start` boundaries and feeds each segment through AI SDK's + * `readUIMessageStream`. The trailing un-finished segment goes through + * `cleanupAbortedParts`. Mirrors the production reducer used in + * `ai.ts:replaySessionOutTail`. + */ +async function reduceChunksToMessages(chunks: UIMessageChunk[]): Promise { + if (chunks.length === 0) return []; + const aiModule = (await import("ai")) as { + readUIMessageStream?: (args: { stream: ReadableStream }) => AsyncIterable; + cleanupAbortedParts?: (msg: UIMessage) => UIMessage; + }; + const readUIMessageStream = aiModule.readUIMessageStream; + const cleanupAbortedParts = aiModule.cleanupAbortedParts; + if (!readUIMessageStream) return []; + + type Segment = { chunks: UIMessageChunk[]; closed: boolean }; + const segments: Segment[] = []; + let current: Segment | undefined; + for (const chunk of chunks) { + if (chunk.type === "start") { + current = { chunks: [chunk], closed: false }; + segments.push(current); + continue; + } + if (!current) { + current = { chunks: [], closed: false }; + segments.push(current); + } + current.chunks.push(chunk); + if (chunk.type === "finish") { + current.closed = true; + current = undefined; + } + } + + const out: UIMessage[] = []; + for (let i = 0; i < segments.length; i++) { + const seg = segments[i]!; + const isTrailing = i === segments.length - 1 && !seg.closed; + const segmentStream = new ReadableStream({ + start(controller) { + for (const c of seg.chunks) controller.enqueue(c); + controller.close(); + }, + }); + let last: UIMessage | undefined; + try { + for await (const snapshot of readUIMessageStream({ stream: segmentStream })) { + last = snapshot; + } + } catch { + // Skip malformed segment — tests can assert by inspecting what makes it through. + continue; + } + if (!last) continue; + if (isTrailing && cleanupAbortedParts) { + const cleaned = cleanupAbortedParts(last); + if (!cleaned.parts || cleaned.parts.length === 0) continue; + out.push(cleaned); + } else { + out.push(last); + } + } + return out; +} diff --git a/packages/trigger-sdk/src/v3/test/setup-catalog.ts b/packages/trigger-sdk/src/v3/test/setup-catalog.ts new file mode 100644 index 00000000000..4dece053b98 --- /dev/null +++ b/packages/trigger-sdk/src/v3/test/setup-catalog.ts @@ -0,0 +1,16 @@ +import { resourceCatalog } from "@trigger.dev/core/v3"; +import { StandardResourceCatalog } from "@trigger.dev/core/v3/workers"; + +/** + * Installs an in-memory `StandardResourceCatalog` and seeds a fake file + * context so task definitions (`task()`, `chat.agent()`, etc.) register + * their run functions where the test harness can look them up. + * + * This is invoked as a side-effect of importing `@trigger.dev/sdk/ai/test`. + * + * Without this, `registerTaskMetadata` short-circuits on a missing + * `_currentFileContext` and tasks silently fail to register. + */ +const catalog = new StandardResourceCatalog(); +resourceCatalog.setGlobalResourceCatalog(catalog); +resourceCatalog.setCurrentFileContext("__test__.ts", "__test__"); diff --git a/packages/trigger-sdk/src/v3/test/test-session-handle.ts b/packages/trigger-sdk/src/v3/test/test-session-handle.ts new file mode 100644 index 00000000000..71bc9d8d7b3 --- /dev/null +++ b/packages/trigger-sdk/src/v3/test/test-session-handle.ts @@ -0,0 +1,268 @@ +import type { + AsyncIterableStream, + PipeStreamResult, + StreamWriteResult, + WriterStreamOptions, +} from "@trigger.dev/core/v3"; +import { ensureReadableStream, ManualWaitpointPromise } from "@trigger.dev/core/v3"; +import { + SessionHandle, + SessionInputChannel, + SessionOutputChannel, + SessionPipeStreamOptions, + SessionSubscribeOptions, +} from "../sessions.js"; + +/** + * Stub for `SessionInputChannel.wait` that skips the apiClient round-trip + * the production path makes via `createSessionStreamWaitpoint`. Without + * this override, every test that exercises the suspend fallback (e.g. + * the `chat.handover` idle-timeout case) throws `ApiClientMissingError` + * because `apiClientManager.clientOrThrow()` runs in a test process that + * has no `TRIGGER_SECRET_KEY`. + * + * The promise resolves with `{ ok: false, error }` when the harness + * aborts its run signal — that mimics production semantics (suspended + * until something happens, returns cleanly on abort) without making a + * network call. + */ +class TestSessionInputChannel extends SessionInputChannel { + constructor(sessionId: string, private readonly getAbortSignal: () => AbortSignal | undefined) { + super(sessionId); + } + + // Override only the `wait` path. `on` / `once` / `peek` / `send` + // continue to flow through the real `sessionStreams` global, which + // the mock task context installs as a `TestSessionStreamManager`. + wait(): ManualWaitpointPromise { + return new ManualWaitpointPromise((resolve: (value: { ok: false; error: Error }) => void) => { + const signal = this.getAbortSignal(); + if (!signal) { + // Harness hasn't wired up its run signal yet — nothing to abort + // on. Stay pending; the run loop should never reach this state + // in practice but we don't want to throw here either. + return; + } + const onAbort = () => { + resolve({ + ok: false, + error: new Error("session.in.wait() aborted by test harness"), + }); + }; + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + }); + } +} + +/** + * Per-session in-memory state collected from `.out` writes during a test. + * Owned by the mock-chat-agent harness; updated by {@link TestSessionOutputChannel}. + */ +export type TestSessionOutState = { + /** Every chunk written to `.out`, in order of write. */ + chunks: unknown[]; + /** Registered write listeners (fired for each chunk). */ + listeners: Set<(chunk: unknown) => void>; +}; + +function notify(state: TestSessionOutState, chunk: unknown): void { + state.chunks.push(chunk); + for (const listener of state.listeners) { + try { + listener(chunk); + } catch { + // Never let a listener error break stream writes + } + } +} + +async function drainInto( + source: AsyncIterable | ReadableStream, + state: TestSessionOutState +): Promise { + const readable = ensureReadableStream(source); + const reader = readable.getReader(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) return; + notify(state, value); + } + } finally { + try { + reader.releaseLock(); + } catch { + // ignore + } + } +} + +/** + * `.out` channel that captures writes in memory instead of piping to S2. + * Mirrors {@link SessionOutputChannel}'s public shape — `pipe` / `writer` + * / `append` / `read` — so the agent's existing code paths work unchanged. + */ +export class TestSessionOutputChannel extends SessionOutputChannel { + constructor( + sessionId: string, + private readonly state: TestSessionOutState + ) { + super(sessionId); + } + + async append(value: T, _options?: SessionPipeStreamOptions): Promise { + notify(this.state, value); + } + + pipe( + value: AsyncIterable | ReadableStream, + _options?: SessionPipeStreamOptions + ): PipeStreamResult { + const state = this.state; + const readChunks: T[] = []; + let resolveDone!: () => void; + const done = new Promise((resolve) => { + resolveDone = resolve; + }); + + (async () => { + const readable = ensureReadableStream(value); + const reader = readable.getReader(); + try { + while (true) { + const { done: d, value: v } = await reader.read(); + if (d) return; + readChunks.push(v as T); + notify(state, v); + } + } finally { + try { + reader.releaseLock(); + } catch { + // ignore + } + resolveDone(); + } + })().catch(() => { + resolveDone(); + }); + + const replayStream = new ReadableStream({ + async start(controller) { + await done; + for (const chunk of readChunks) controller.enqueue(chunk); + controller.close(); + }, + }); + + const emptyResult: StreamWriteResult = {}; + + return { + get stream(): AsyncIterableStream { + return replayStream as AsyncIterableStream; + }, + waitUntilComplete: async () => { + await done; + return emptyResult; + }, + }; + } + + writer(options: WriterStreamOptions): PipeStreamResult { + let controller!: ReadableStreamDefaultController; + const ongoing: Promise[] = []; + const state = this.state; + + const stream = new ReadableStream({ + start(c) { + controller = c; + }, + }); + + const safeEnqueue = (data: T) => { + try { + controller.enqueue(data); + } catch { + // Stream already closed + } + }; + + try { + const result = options.execute({ + write(part) { + safeEnqueue(part); + notify(state, part); + }, + merge(streamArg) { + ongoing.push( + drainInto(streamArg, state).catch(() => {}) + ); + }, + }); + + if (result) { + ongoing.push(result.catch(() => {})); + } + } catch { + // Swallow — tests can inspect state.chunks + } + + const done: Promise = (async () => { + while (ongoing.length > 0) { + await ongoing.shift(); + } + })().finally(() => { + try { + controller.close(); + } catch { + // Already closed + } + }); + + const emptyResult: StreamWriteResult = {}; + + return { + get stream(): AsyncIterableStream { + return stream as AsyncIterableStream; + }, + waitUntilComplete: async () => { + await done; + return emptyResult; + }, + }; + } + + async read(_options?: SessionSubscribeOptions): Promise> { + throw new Error( + "TestSessionOutputChannel.read() is not supported in the mock-chat-agent harness — " + + "inspect `harness.allChunks` / `harness.allRawChunks` instead." + ); + } +} + +/** + * Construct a {@link SessionHandle} whose `.out` channel captures writes in + * memory and whose `.in` channel routes through the `sessionStreams` + * global for record subscriptions (`on` / `once` / `peek`) but stubs + * `wait()` to skip the apiClient round-trip — see + * {@link TestSessionInputChannel}. + * + * `getAbortSignal` lets the channel observe the harness's run signal so + * `wait()` resolves cleanly on close. Pass a getter (not the signal + * directly) so the channel reads it lazily — the harness creates its + * `AbortController` after the override is installed. + */ +export function createTestSessionHandle( + sessionId: string, + state: TestSessionOutState, + getAbortSignal: () => AbortSignal | undefined = () => undefined +): SessionHandle { + return new SessionHandle(sessionId, { + in: new TestSessionInputChannel(sessionId, getAbortSignal), + out: new TestSessionOutputChannel(sessionId, state), + }); +} diff --git a/packages/trigger-sdk/test/chat-snapshot.test.ts b/packages/trigger-sdk/test/chat-snapshot.test.ts new file mode 100644 index 00000000000..e7421cdbd9a --- /dev/null +++ b/packages/trigger-sdk/test/chat-snapshot.test.ts @@ -0,0 +1,279 @@ +// Import the test entry point first so the resource catalog is installed — +// not strictly required for these helper-level tests, but keeps parity with +// the rest of the test suite and removes a potential foot-gun if a future +// edit introduces a chat.agent({...}) at module scope. +import "../src/v3/test/index.js"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { apiClientManager } from "@trigger.dev/core/v3"; +import { + __readChatSnapshotProductionPathForTests as readChatSnapshot, + __writeChatSnapshotProductionPathForTests as writeChatSnapshot, + type ChatSnapshotV1, +} from "../src/v3/ai.js"; + +// ── Helpers ──────────────────────────────────────────────────────────── + +/** + * Build a minimal ChatSnapshotV1 with `count` user messages. Used as the + * production-path test payload — `messages` is the only field the runtime + * inspects beyond `version`. + */ +function buildSnapshot(count = 1): ChatSnapshotV1 { + return { + version: 1, + savedAt: 1_000_000, + messages: Array.from({ length: count }, (_, i) => ({ + id: `m${i}`, + role: "user" as const, + parts: [{ type: "text" as const, text: `hello ${i}` }], + })), + lastOutEventId: "evt-42", + lastOutTimestamp: 2_000_000, + }; +} + +/** + * Stub `apiClientManager.clientOrThrow()` so the helpers see a fake API + * client whose `getPayloadUrl` / `createUploadPayloadUrl` resolve with the + * presigned URLs the test wants. Returns spies for assertion. + */ +function stubApiClient(opts: { + getPayloadUrl?: (filename: string) => Promise<{ presignedUrl: string }>; + createUploadPayloadUrl?: (filename: string) => Promise<{ presignedUrl: string }>; +}) { + const getPayloadUrl = vi.fn( + opts.getPayloadUrl ?? (async (_filename: string) => ({ presignedUrl: "https://example.invalid/get" })) + ); + const createUploadPayloadUrl = vi.fn( + opts.createUploadPayloadUrl ?? + (async (_filename: string) => ({ presignedUrl: "https://example.invalid/put" })) + ); + const fakeClient = { + getPayloadUrl, + createUploadPayloadUrl, + }; + vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue( + fakeClient as never + ); + return { getPayloadUrl, createUploadPayloadUrl }; +} + +/** + * Stub global `fetch` so the helpers see whatever Response (or throw) the + * test wants. Returns a spy keyed on the URL passed. + */ +function stubFetch(impl: (url: string, init?: RequestInit) => Promise | Response) { + const spy = vi.fn(impl); + vi.stubGlobal("fetch", spy); + return spy; +} + +// ── Tests ────────────────────────────────────────────────────────────── + +describe("chat snapshot helpers", () => { + // Suppress the runtime's `logger.warn` calls — they pollute output but + // don't change test outcomes. Restored in afterEach. + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + warnSpy.mockRestore(); + }); + + describe("readChatSnapshot", () => { + it("returns the snapshot on a successful GET", async () => { + const { getPayloadUrl } = stubApiClient({}); + const snapshot = buildSnapshot(2); + stubFetch(async () => + new Response(JSON.stringify(snapshot), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + + const result = await readChatSnapshot("session-1"); + expect(getPayloadUrl).toHaveBeenCalledWith("sessions/session-1/snapshot.json"); + expect(result).toMatchObject({ + version: 1, + messages: snapshot.messages, + lastOutEventId: "evt-42", + }); + }); + + it("returns undefined on 404 (fresh session, no snapshot yet)", async () => { + stubApiClient({}); + stubFetch(async () => new Response("Not Found", { status: 404 })); + + const result = await readChatSnapshot("missing-session"); + expect(result).toBeUndefined(); + }); + + it("returns undefined on non-404 non-OK (e.g. 500)", async () => { + stubApiClient({}); + stubFetch(async () => new Response("Internal Error", { status: 500 })); + + const result = await readChatSnapshot("flaky-session"); + expect(result).toBeUndefined(); + }); + + it("returns undefined when the response body is malformed JSON", async () => { + stubApiClient({}); + stubFetch(async () => + new Response("not-json-{[", { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + + const result = await readChatSnapshot("malformed-session"); + expect(result).toBeUndefined(); + }); + + it("returns undefined on version mismatch (forward-compat)", async () => { + stubApiClient({}); + // Future format the current runtime can't decode — runtime ignores it. + const futureSnapshot = { + version: 99, + savedAt: Date.now(), + messages: [], + }; + stubFetch(async () => + new Response(JSON.stringify(futureSnapshot), { + status: 200, + headers: { "content-type": "application/json" }, + }) + ); + + const result = await readChatSnapshot("v99-session"); + expect(result).toBeUndefined(); + }); + + it("returns undefined when `messages` field is missing or wrong type", async () => { + stubApiClient({}); + stubFetch(async () => + new Response(JSON.stringify({ version: 1, savedAt: 1, messages: "not-an-array" }), { + status: 200, + }) + ); + + const result = await readChatSnapshot("bad-shape-session"); + expect(result).toBeUndefined(); + }); + + it("returns undefined when fetch throws (network error)", async () => { + stubApiClient({}); + stubFetch(async () => { + throw new Error("ECONNREFUSED"); + }); + + const result = await readChatSnapshot("offline-session"); + expect(result).toBeUndefined(); + }); + + it("returns undefined when presign call fails", async () => { + stubApiClient({ + getPayloadUrl: async () => { + throw new Error("presign denied"); + }, + }); + // No fetch should fire — presign failed. + const fetchSpy = stubFetch(async () => new Response("nope", { status: 500 })); + + const result = await readChatSnapshot("denied-session"); + expect(result).toBeUndefined(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("returns undefined when the response is not an object", async () => { + stubApiClient({}); + stubFetch(async () => + new Response(JSON.stringify("just-a-string"), { status: 200 }) + ); + + const result = await readChatSnapshot("string-response"); + expect(result).toBeUndefined(); + }); + }); + + describe("writeChatSnapshot", () => { + it("PUTs the snapshot JSON to the presigned URL", async () => { + const { createUploadPayloadUrl } = stubApiClient({}); + const fetchSpy = stubFetch(async () => new Response(null, { status: 200 })); + + const snapshot = buildSnapshot(3); + await writeChatSnapshot("session-2", snapshot); + + expect(createUploadPayloadUrl).toHaveBeenCalledWith("sessions/session-2/snapshot.json"); + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe("https://example.invalid/put"); + expect((init as RequestInit).method).toBe("PUT"); + expect((init as RequestInit).headers).toMatchObject({ + "content-type": "application/json", + }); + // Body is the JSON-stringified snapshot — round-trip to confirm. + const sentBody = JSON.parse((init as RequestInit).body as string); + expect(sentBody).toEqual(snapshot); + }); + + it("returns without throwing on a non-OK PUT response (warns)", async () => { + stubApiClient({}); + stubFetch(async () => new Response("forbidden", { status: 403 })); + + await expect(writeChatSnapshot("forbidden-session", buildSnapshot())).resolves.toBeUndefined(); + }); + + it("returns without throwing on a fetch network error (warns)", async () => { + stubApiClient({}); + stubFetch(async () => { + throw new Error("ETIMEDOUT"); + }); + + await expect(writeChatSnapshot("timeout-session", buildSnapshot())).resolves.toBeUndefined(); + }); + + it("returns without throwing when presign fails (warns)", async () => { + stubApiClient({ + createUploadPayloadUrl: async () => { + throw new Error("presign denied"); + }, + }); + const fetchSpy = stubFetch(async () => new Response(null, { status: 200 })); + + await expect(writeChatSnapshot("denied-session", buildSnapshot())).resolves.toBeUndefined(); + // Presign failed → no PUT attempted. + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("uses the same `snapshotFilename(sessionId)` convention as the read path", async () => { + // Round-trip check: read and write target the same key for a given + // sessionId. The runtime relies on this to make read-after-write + // coherent on subsequent boots. + const { getPayloadUrl } = stubApiClient({ + getPayloadUrl: async () => ({ presignedUrl: "https://example.invalid/get" }), + }); + stubFetch(async () => new Response(null, { status: 404 })); + + // Trigger a read. + await readChatSnapshot("round-trip-session"); + const [readKey] = getPayloadUrl.mock.calls[0]!; + + // Trigger a write to the same session. + const { createUploadPayloadUrl } = stubApiClient({ + createUploadPayloadUrl: async () => ({ presignedUrl: "https://example.invalid/put" }), + }); + stubFetch(async () => new Response(null, { status: 200 })); + await writeChatSnapshot("round-trip-session", buildSnapshot()); + const [writeKey] = createUploadPayloadUrl.mock.calls[0]!; + + expect(readKey).toBe(writeKey); + expect(readKey).toBe("sessions/round-trip-session/snapshot.json"); + }); + }); +}); diff --git a/packages/trigger-sdk/test/chatHandover.test.ts b/packages/trigger-sdk/test/chatHandover.test.ts new file mode 100644 index 00000000000..9e0d69ecb04 --- /dev/null +++ b/packages/trigger-sdk/test/chatHandover.test.ts @@ -0,0 +1,370 @@ +// Import the test harness FIRST — installs the resource catalog so +// `chat.agent()` calls below register their task functions correctly. +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { describe, expect, it, vi } from "vitest"; +import { chat } from "../src/v3/ai.js"; +import { simulateReadableStream, streamText, tool } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { z } from "zod"; + +// ── Helpers ──────────────────────────────────────────────────────────── + +function textStream(text: string): ReadableStream { + return simulateReadableStream({ + chunks: [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, + }, + }, + ], + }); +} + +// ── Tests ────────────────────────────────────────────────────────────── + +describe("chat.handover", () => { + it("handover-skip (error path) exits cleanly without firing turn hooks", async () => { + // `handover-skip` is now only sent when the customer's handler + // ABORTS before producing a finishReason (dispatch error). The + // agent run exits clean, no hooks fire. Normal pure-text and + // tool-call finishes go through `kind: "handover"`. + const onChatStart = vi.fn(); + const onTurnStart = vi.fn(); + const onTurnComplete = vi.fn(); + const onPreload = vi.fn(); + const runFn = vi.fn(); + + const agent = chat.agent({ + id: "chat.handover.skip", + onPreload, + onChatStart, + onTurnStart, + onTurnComplete, + run: async ({ messages, signal }) => { + runFn(); + return streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("should-not-run") }), + }), + messages, + abortSignal: signal, + }); + }, + }); + + const harness = mockChatAgent(agent, { + chatId: "test-handover-skip", + mode: "handover-prepare", + }); + + try { + await harness.sendHandoverSkip(); + // Give any deferred work a tick. + await new Promise((r) => setTimeout(r, 20)); + + // No turn hooks fire on skip — the run boots, waits, and exits. + expect(onPreload).not.toHaveBeenCalled(); + expect(onTurnStart).not.toHaveBeenCalled(); + expect(onTurnComplete).not.toHaveBeenCalled(); + expect(runFn).not.toHaveBeenCalled(); + + // No content chunks were emitted — only the boot scaffolding (if any). + expect(harness.allChunks).toHaveLength(0); + } finally { + await harness.close(); + } + }); + + it("pure-text head-start (isFinal: true) runs full hook chain WITHOUT calling streamText", async () => { + // Pure-text first turn: customer's step 1 produced the final + // response. The agent runs onChatStart → onTurnStart → + // onTurnComplete (so persistence works), but SKIPS the user's + // run() callback entirely (no LLM call, no streamText). + // onTurnComplete fires with the customer's partial as + // `responseMessage`. + const order: string[] = []; + const runFn = vi.fn(); + + let capturedResponse: { id?: string; partTypes?: string[]; firstText?: string } | undefined; + + const agent = chat.agent({ + id: "chat.handover.pure-text", + onChatStart: () => { order.push("onChatStart"); }, + onTurnStart: () => { order.push("onTurnStart"); }, + onTurnComplete: ({ responseMessage }) => { + order.push("onTurnComplete"); + capturedResponse = { + id: responseMessage?.id, + partTypes: (responseMessage?.parts ?? []).map((p) => p.type), + firstText: (responseMessage?.parts ?? []) + .filter((p) => p.type === "text") + .map((p) => (p as { text?: string }).text || "") + .join(""), + }; + }, + run: async ({ messages, signal }) => { + // Should NOT be called for isFinal: true. + runFn(); + return streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("should-not-run") }), + }), + messages, + abortSignal: signal, + }); + }, + }); + + const harness = mockChatAgent(agent, { + chatId: "test-handover-final", + mode: "handover-prepare", + }); + + try { + await harness.sendHandover({ + partialAssistantMessage: [ + { + role: "assistant", + content: [{ type: "text", text: "Hi there, hope you're well." }], + }, + ], + messageId: "asst-msg-1", + isFinal: true, + }); + // `onTurnComplete` fires AFTER the `trigger:turn-complete` chunk, + // and the harness's `sendHandover` resolves on that chunk — + // give onTurnComplete a tick to run. + await new Promise((r) => setTimeout(r, 30)); + + // All three hooks fired in order. + expect(order).toEqual(["onChatStart", "onTurnStart", "onTurnComplete"]); + // The user's run() was NEVER invoked — no LLM call from the agent. + expect(runFn).not.toHaveBeenCalled(); + + // onTurnComplete saw the customer's partial as responseMessage, + // with the matching messageId for browser-side merging. + expect(capturedResponse).toBeDefined(); + expect(capturedResponse!.id).toBe("asst-msg-1"); + expect(capturedResponse!.partTypes).toContain("text"); + expect(capturedResponse!.firstText).toBe("Hi there, hope you're well."); + } finally { + await harness.close(); + } + }); + + it("handover with schema-only pending tool-call resumes via approval-driven execution", async () => { + // Customer-side tools are schema-only (no `execute` fn) — AI SDK + // doesn't execute them, so `result.response.messages` after step 1 + // contains JUST the assistant message with the pending tool-call. + // `chat-server.ts` reshapes this into AI SDK's tool-approval round + // (assistant + tool-approval-request, tool with tool-approval-response) + // before sending the handover signal. That's the wire shape this + // test simulates. + // + // The agent ships the same tool — but with the heavy `execute` fn. + // When the next `streamText` runs, AI SDK's initial-tool-execution + // branch (stream-text.ts:1342-1486) sees the approval round, runs + // the agent-side execute, and synthesizes a tool-result before the + // step-2 LLM call. + const toolExecute = vi.fn(async ({ city }: { city: string }) => ({ + city, + temp: 22, + })); + + const weatherTool = tool({ + description: "Look up weather", + inputSchema: z.object({ city: z.string() }), + execute: toolExecute, + }); + + const stepTwoStream = textStream("the weather in tokyo is 22°C"); + + const agent = chat.agent({ + id: "chat.handover.schema-only-tool", + run: async ({ messages, signal }) => { + return streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: stepTwoStream }), + }), + messages, + tools: { weather: weatherTool }, + abortSignal: signal, + }); + }, + }); + + const harness = mockChatAgent(agent, { + chatId: "test-handover-schema-only", + mode: "handover-prepare", + }); + + try { + const turn = await harness.sendHandover({ + isFinal: false, // pending tool-call → agent runs streamText + partialAssistantMessage: [ + { + role: "assistant", + content: [ + { type: "text", text: "let me check the weather" }, + { + type: "tool-call", + toolCallId: "tc-1", + toolName: "weather", + input: { city: "tokyo" }, + }, + { + type: "tool-approval-request", + approvalId: "handover-approval-1", + toolCallId: "tc-1", + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-approval-response", + approvalId: "handover-approval-1", + approved: true, + }, + ], + }, + ], + }); + + // The agent-side execute ran (this is the whole point of the + // schema-only-on-customer pattern). + expect(toolExecute).toHaveBeenCalledWith( + expect.objectContaining({ city: "tokyo" }), + expect.anything() + ); + + // Step-2 produced text was streamed through session.out. + const text = turn.chunks + .filter((c) => c.type === "text-delta") + .map((c) => (c as { delta: string }).delta) + .join(""); + expect(text).toContain("tokyo"); + expect(text).toContain("22°C"); + } finally { + await harness.close(); + } + }); + + it("onTurnStart fires after the handover signal arrives (lazy)", async () => { + // Hooks should not fire during the wait — only once handover lands + // and a real turn begins. Verifies the order so customers can + // mutate `chat.history` inside `onTurnStart` knowing the partial + // assistant message is in scope. + const events: string[] = []; + + const agent = chat.agent({ + id: "chat.handover.lazy-hooks", + onPreload: () => { + events.push("onPreload"); + }, + onChatStart: () => { + events.push("onChatStart"); + }, + onTurnStart: () => { + events.push("onTurnStart"); + }, + onTurnComplete: () => { + events.push("onTurnComplete"); + }, + run: async ({ messages, signal }) => { + events.push("run"); + return streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }), + messages, + abortSignal: signal, + }); + }, + }); + + const harness = mockChatAgent(agent, { + chatId: "test-handover-lazy", + mode: "handover-prepare", + }); + + try { + // Before the signal lands, no hook should have fired. + await new Promise((r) => setTimeout(r, 20)); + expect(events).toEqual([]); + + await harness.sendHandover({ + isFinal: false, // exercise the full streamText path + partialAssistantMessage: [ + { role: "assistant", content: [{ type: "text", text: "warming up" }] }, + ], + }); + // Let any deferred onTurnComplete fire. + await new Promise((r) => setTimeout(r, 20)); + + // onPreload never fires for handover-prepare. Everything else + // fires once the partial lands — onChatStart still runs (first + // turn invariant), then onTurnStart, run, onTurnComplete. + expect(events).not.toContain("onPreload"); + expect(events).toContain("onChatStart"); + expect(events).toContain("onTurnStart"); + expect(events).toContain("run"); + expect(events).toContain("onTurnComplete"); + // Order: hooks before run, run before onTurnComplete. + expect(events.indexOf("onTurnStart")).toBeLessThan(events.indexOf("run")); + expect(events.indexOf("run")).toBeLessThan(events.indexOf("onTurnComplete")); + } finally { + await harness.close(); + } + }); + + it("idle timeout exits cleanly when no handover signal is sent", async () => { + // Customer's POST handler crashed before signaling. The agent + // should not hang forever — wait the configured idleTimeoutInSeconds + // and exit, just like the handover-skip case. + const onTurnStart = vi.fn(); + const onTurnComplete = vi.fn(); + + const agent = chat.agent({ + id: "chat.handover.idle-timeout", + idleTimeoutInSeconds: 1, // 1s — enough for the wait + exit. + onTurnStart, + onTurnComplete, + run: async ({ messages, signal }) => { + return streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("never") }), + }), + messages, + abortSignal: signal, + }); + }, + }); + + const harness = mockChatAgent(agent, { + chatId: "test-handover-timeout", + mode: "handover-prepare", + }); + + try { + // Wait long enough for the idle timeout to fire. + await new Promise((r) => setTimeout(r, 1500)); + + expect(onTurnStart).not.toHaveBeenCalled(); + expect(onTurnComplete).not.toHaveBeenCalled(); + expect(harness.allChunks).toHaveLength(0); + } finally { + await harness.close(); + } + }); +}); diff --git a/packages/trigger-sdk/test/merge-by-id.test.ts b/packages/trigger-sdk/test/merge-by-id.test.ts new file mode 100644 index 00000000000..1c0091273cc --- /dev/null +++ b/packages/trigger-sdk/test/merge-by-id.test.ts @@ -0,0 +1,158 @@ +// Plan F.1: pure-function correctness tests for `mergeByIdReplaceWins`, +// the helper that combines `snapshot.messages` with `session.out` replay +// at run boot (plan section B.3). Replay wins on id collision because +// `session.out` carries the freshest representation of an assistant +// message. + +import "../src/v3/test/index.js"; + +import type { UIMessage } from "ai"; +import { describe, expect, it } from "vitest"; +import { __mergeByIdReplaceWinsForTests as mergeByIdReplaceWins } from "../src/v3/ai.js"; + +// ── Helpers ──────────────────────────────────────────────────────────── + +function userMessage(id: string, text: string): UIMessage { + return { + id, + role: "user", + parts: [{ type: "text", text }], + }; +} + +function assistantMessage(id: string, text: string): UIMessage { + return { + id, + role: "assistant", + parts: [{ type: "text", text }], + }; +} + +// ── Tests ────────────────────────────────────────────────────────────── + +describe("mergeByIdReplaceWins", () => { + it("returns a copy of `a` when `b` is empty", () => { + const a = [userMessage("u-1", "hello")]; + const result = mergeByIdReplaceWins(a, []); + expect(result).toEqual(a); + // Verify it's a copy (mutating result shouldn't touch a). + result.push(assistantMessage("a-1", "extra")); + expect(a).toHaveLength(1); + }); + + it("returns a copy of `b` when `a` is empty", () => { + const b = [assistantMessage("a-1", "world")]; + const result = mergeByIdReplaceWins([], b); + expect(result).toEqual(b); + result.push(userMessage("u-extra", "extra")); + expect(b).toHaveLength(1); + }); + + it("returns [] when both inputs are empty", () => { + expect(mergeByIdReplaceWins([], [])).toEqual([]); + }); + + it("appends fresh ids from `b` after `a`'s entries", () => { + const a = [userMessage("u-1", "hi")]; + const b = [assistantMessage("a-1", "ok")]; + const result = mergeByIdReplaceWins(a, b); + expect(result.map((m) => m.id)).toEqual(["u-1", "a-1"]); + expect(result[0]!.role).toBe("user"); + expect(result[1]!.role).toBe("assistant"); + }); + + it("replaces by id when `b` has a colliding entry — replay wins", () => { + const a = [ + userMessage("u-1", "hi"), + assistantMessage("a-1", "stale-version"), + ]; + const b = [assistantMessage("a-1", "fresh-version")]; + const result = mergeByIdReplaceWins(a, b); + expect(result).toHaveLength(2); + expect(result[1]!.id).toBe("a-1"); + expect((result[1]!.parts[0] as { text: string }).text).toBe("fresh-version"); + }); + + it("preserves order from `a` even when entries are replaced", () => { + const a = [ + userMessage("u-1", "first"), + assistantMessage("a-1", "stale"), + userMessage("u-2", "second"), + assistantMessage("a-2", "also-stale"), + ]; + const b = [ + assistantMessage("a-1", "fresh-1"), + assistantMessage("a-2", "fresh-2"), + ]; + const result = mergeByIdReplaceWins(a, b); + expect(result.map((m) => m.id)).toEqual(["u-1", "a-1", "u-2", "a-2"]); + expect((result[1]!.parts[0] as { text: string }).text).toBe("fresh-1"); + expect((result[3]!.parts[0] as { text: string }).text).toBe("fresh-2"); + }); + + it("appends `b` entries with no id collision after the merged set", () => { + const a = [userMessage("u-1", "first")]; + const b = [ + assistantMessage("a-1", "reply-1"), + userMessage("u-2", "second"), + assistantMessage("a-2", "reply-2"), + ]; + const result = mergeByIdReplaceWins(a, b); + expect(result.map((m) => m.id)).toEqual(["u-1", "a-1", "u-2", "a-2"]); + }); + + it("treats messages without an id as always-append (no collision possible)", () => { + const a = [ + userMessage("u-1", "first"), + // Synthetic message missing the id field — should append, never replace. + { id: "" as string, role: "assistant", parts: [{ type: "text", text: "no-id-a" }] } as UIMessage, + ]; + const b = [ + { id: "" as string, role: "assistant", parts: [{ type: "text", text: "no-id-b" }] } as UIMessage, + ]; + const result = mergeByIdReplaceWins(a, b); + expect(result).toHaveLength(3); + // Both empty-id messages survive — no merge happens. + const noIdParts = result + .filter((m) => m.id === "") + .map((m) => (m.parts[0] as { text: string }).text); + expect(noIdParts).toEqual(["no-id-a", "no-id-b"]); + }); + + it("handles consecutive replays of the same id in `b` — last one wins", () => { + // Edge case: `b` has two entries with the same id (shouldn't happen + // for assistants in practice, but the helper must be deterministic). + const a = [assistantMessage("a-1", "v0")]; + const b = [assistantMessage("a-1", "v1"), assistantMessage("a-1", "v2")]; + const result = mergeByIdReplaceWins(a, b); + expect(result).toHaveLength(1); + expect((result[0]!.parts[0] as { text: string }).text).toBe("v2"); + }); + + it("preserves user messages (only assistants come from replay) — semantic check", () => { + // The runtime contract: `session.out` contains assistant chunks only, + // so `b` should never contain user messages. If it does (defensively), + // the merge still works — but we lock down the typical pattern here. + const a = [ + userMessage("u-1", "first"), + assistantMessage("a-1", "stale"), + userMessage("u-2", "second"), + ]; + const b = [assistantMessage("a-1", "fresh")]; + const result = mergeByIdReplaceWins(a, b); + // User messages from snapshot survive untouched. + expect(result.filter((m) => m.role === "user").map((m) => m.id)).toEqual(["u-1", "u-2"]); + }); + + it("does not mutate either input array", () => { + const a = [userMessage("u-1", "hi"), assistantMessage("a-1", "stale")]; + const b = [assistantMessage("a-1", "fresh"), userMessage("u-2", "next")]; + const aSnapshot = JSON.stringify(a); + const bSnapshot = JSON.stringify(b); + + mergeByIdReplaceWins(a, b); + + expect(JSON.stringify(a)).toBe(aSnapshot); + expect(JSON.stringify(b)).toBe(bSnapshot); + }); +}); diff --git a/packages/trigger-sdk/test/mockChatAgent.test.ts b/packages/trigger-sdk/test/mockChatAgent.test.ts new file mode 100644 index 00000000000..c0b2b6eaa3a --- /dev/null +++ b/packages/trigger-sdk/test/mockChatAgent.test.ts @@ -0,0 +1,1542 @@ +// Import the test harness FIRST — this installs the resource catalog so +// `chat.agent()` calls below register their task functions correctly. +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { describe, expect, it, vi } from "vitest"; +import { chat } from "../src/v3/ai.js"; +import { locals } from "@trigger.dev/core/v3"; +import { simulateReadableStream, streamText } from "ai"; +import { MockLanguageModelV3 } from "ai/test"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; + +// ── Helpers ──────────────────────────────────────────────────────────── + +function userMessage(text: string, id = "u-" + Math.random().toString(36).slice(2)) { + return { + id, + role: "user" as const, + parts: [{ type: "text" as const, text }], + }; +} + +function textStream(text: string) { + const chunks: LanguageModelV3StreamPart[] = [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, + }, + }, + ]; + return simulateReadableStream({ chunks }); +} + +// ── Tests ────────────────────────────────────────────────────────────── + +describe("mockChatAgent", () => { + it("throws when no agent is registered with the given id", () => { + expect(() => mockChatAgent({ id: "does-not-exist" })).toThrow(/no task registered/); + }); + + it("drives a chat.agent through a single turn and captures output chunks", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("hello world") }), + }); + + const agent = chat.agent({ + id: "mockChatAgent.basic-flow", + run: async ({ messages, signal }) => { + return streamText({ + model, + messages, + abortSignal: signal, + }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-basic" }); + try { + const turn = await harness.sendMessage(userMessage("hi")); + + const textDeltas = turn.chunks + .filter((c) => c.type === "text-delta") + .map((c) => (c as { delta: string }).delta) + .join(""); + expect(textDeltas).toBe("hello world"); + } finally { + await harness.close(); + } + }); + + it("fires onTurnStart and onTurnComplete hooks in order", async () => { + const events: string[] = []; + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("hi") }), + }); + + const agent = chat.agent({ + id: "mockChatAgent.hook-order", + onChatStart: async () => { + events.push("onChatStart"); + }, + onTurnStart: async () => { + events.push("onTurnStart"); + }, + onBeforeTurnComplete: async () => { + events.push("onBeforeTurnComplete"); + }, + onTurnComplete: async () => { + events.push("onTurnComplete"); + }, + run: async ({ messages, signal }) => { + events.push("run"); + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-hooks" }); + try { + await harness.sendMessage(userMessage("hello")); + // onTurnComplete may fire after the turn-complete chunk is written, + // so give it a tick to run before we assert. + await new Promise((r) => setTimeout(r, 20)); + expect(events).toEqual([ + "onChatStart", + "onTurnStart", + "run", + "onBeforeTurnComplete", + "onTurnComplete", + ]); + } finally { + await harness.close(); + } + }); + + it("can send multiple messages across turns", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("reply") }), + }); + + const seenMessages: number[] = []; + const agent = chat.agent({ + id: "mockChatAgent.multi-turn", + run: async ({ messages, signal }) => { + seenMessages.push(messages.length); + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-multi" }); + try { + await harness.sendMessage(userMessage("first")); + await harness.sendMessage(userMessage("second")); + await harness.sendMessage(userMessage("third")); + + // Each turn sees an accumulator growing by (user + assistant) * turn + // Turn 1: just the user message + // Turn 2: user + assistant + user = 3 messages + // Turn 3: 5 messages + expect(seenMessages).toEqual([1, 3, 5]); + } finally { + await harness.close(); + } + }); + + it("invokes hydrateMessages on every turn with incoming wire messages", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + + const hydrateSpy = vi.fn(async ({ incomingMessages }) => { + // Echo back whatever the frontend sent + return incomingMessages; + }); + + const agent = chat.agent({ + id: "mockChatAgent.hydrate", + hydrateMessages: hydrateSpy, + run: async ({ messages, signal }) => { + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-hydrate" }); + try { + await harness.sendMessage(userMessage("hi", "u-first")); + + expect(hydrateSpy).toHaveBeenCalledTimes(1); + const call = hydrateSpy.mock.calls[0]![0] as { incomingMessages: { id: string }[] }; + expect(call.incomingMessages).toHaveLength(1); + expect(call.incomingMessages[0]!.id).toBe("u-first"); + } finally { + await harness.close(); + } + }); + + it("merges HITL tool answer onto head assistant when AI SDK regenerates the id", async () => { + // Regression for TRI-9137: customers (Arena AI) report that the AI SDK + // intermittently mints a fresh id on `addToolOutput` resume, breaking + // id-based dedup. Our SDK records `toolCallId → head messageId` whenever + // an assistant with tool parts lands in the accumulator and uses that + // map as a fallback in the merge so a fresh-id incoming still attaches + // to the right head. + const { z } = await import("zod"); + const { tool } = await import("ai"); + + const askUserTool = tool({ + description: "Ask the user a question.", + inputSchema: z.object({ question: z.string() }), + // No execute — HITL round-trip via addToolOutput. + }); + + const HEAD_TOOL_CALL_ID = "tc_regression_9137"; + + // Turn 1: model emits a tool-call for askUser. No text, no finish-reason + // logic beyond `tool-calls`. Agent's response will carry a tool-input- + // available part with HEAD_TOOL_CALL_ID. + const turn1Stream = simulateReadableStream({ + chunks: [ + { type: "tool-input-start", id: HEAD_TOOL_CALL_ID, toolName: "askUser" }, + { + type: "tool-input-delta", + id: HEAD_TOOL_CALL_ID, + delta: JSON.stringify({ question: "what color?" }), + }, + { type: "tool-input-end", id: HEAD_TOOL_CALL_ID }, + { + type: "tool-call", + toolCallId: HEAD_TOOL_CALL_ID, + toolName: "askUser", + input: JSON.stringify({ question: "what color?" }), + }, + { + type: "finish", + finishReason: { unified: "tool-calls", raw: "tool_calls" }, + usage: { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 0, reasoning: undefined }, + }, + }, + ] as LanguageModelV3StreamPart[], + }); + + // Turn 2: model produces a final text response — exercises the post-HITL + // continuation streamText after the tool answer is merged in. + const turn2Stream = textStream("blue is great"); + + let callIdx = 0; + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: callIdx++ === 0 ? turn1Stream : turn2Stream }), + }); + + const turnsSeen: { turn: number; uiMessages: any[] }[] = []; + + const agent = chat.agent({ + id: "mockChatAgent.hitl-id-regen", + onTurnComplete: async ({ turn, uiMessages }) => { + turnsSeen.push({ + turn, + uiMessages: uiMessages.map((m) => ({ + id: m.id, + role: m.role, + toolStates: (m.parts ?? []) + .filter((p: any) => typeof p?.toolCallId === "string") + .map((p: any) => ({ toolCallId: p.toolCallId, state: p.state })), + })), + }); + }, + run: async ({ messages, signal }) => { + return streamText({ model, messages, tools: { askUser: askUserTool }, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-hitl-id-regen" }); + try { + // Turn 1: user message → agent emits tool-input-available for askUser + await harness.sendMessage(userMessage("hi")); + await new Promise((r) => setTimeout(r, 50)); + + // Capture the head assistant id the agent produced. + const turn1 = turnsSeen.at(-1); + const headAssistant = turn1?.uiMessages.find( + (m) => m.role === "assistant" && m.toolStates.length > 0 + ); + expect(headAssistant?.id).toBeTruthy(); + const HEAD_ID = headAssistant!.id as string; + + // Turn 2: simulate AI SDK regenerating the assistant id on + // addToolOutput resume — fresh id, but the same toolCallId in + // tool-output-available state. + const FRESH_ID = "regenerated-by-ai-sdk-" + Math.random().toString(36).slice(2); + const toolAnswerMessage = { + id: FRESH_ID, + role: "assistant" as const, + parts: [ + { + type: "tool-askUser", + toolCallId: HEAD_TOOL_CALL_ID, + state: "output-available" as const, + input: { question: "what color?" }, + output: { color: "blue" }, + }, + ], + }; + await harness.sendMessage(toolAnswerMessage as any); + await new Promise((r) => setTimeout(r, 50)); + + // The merge must rewrite FRESH_ID back to HEAD_ID via the toolCallId + // map, attaching the tool answer to the existing head — no duplicate. + const turn2 = turnsSeen.at(-1); + expect(turn2).toBeTruthy(); + const assistantsWithToolCall = turn2!.uiMessages.filter( + (m) => + m.role === "assistant" && + m.toolStates.some((t: any) => t.toolCallId === HEAD_TOOL_CALL_ID) + ); + expect(assistantsWithToolCall).toHaveLength(1); + expect(assistantsWithToolCall[0]!.id).toBe(HEAD_ID); + expect(turn2!.uiMessages.find((m) => m.id === FRESH_ID)).toBeUndefined(); + } finally { + await harness.close(); + } + }); + + it("routes custom actions through actionSchema + onAction", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + + const onActionSpy = vi.fn(); + + const { z } = await import("zod"); + const agent = chat.agent({ + id: "mockChatAgent.actions", + actionSchema: z.object({ + type: z.literal("undo"), + }), + onAction: async (event) => { + onActionSpy(event.action); + }, + run: async ({ messages, signal }) => { + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-action" }); + try { + await harness.sendMessage(userMessage("start")); + await harness.sendAction({ type: "undo" }); + + expect(onActionSpy).toHaveBeenCalledWith({ type: "undo" }); + } finally { + await harness.close(); + } + }); + + it("actions returning void do not fire turn hooks or call run()", async () => { + const onChatStart = vi.fn(); + const onTurnStart = vi.fn(); + const onBeforeTurnComplete = vi.fn(); + const onTurnComplete = vi.fn(); + const onAction = vi.fn(); + const runSpy = vi.fn(); + const model = new MockLanguageModelV3({ + doStream: async () => { + runSpy(); + return { stream: textStream("nope") }; + }, + }); + + const { z } = await import("zod"); + const agent = chat.agent({ + id: "mockChatAgent.actions.void", + actionSchema: z.object({ type: z.literal("undo") }), + onChatStart, + onTurnStart, + onBeforeTurnComplete, + onTurnComplete, + onAction: async (...args) => { + onAction(...args); + // void → side-effect only + }, + run: async ({ messages, signal }) => { + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-void-action" }); + try { + // Bootstrap with a message so the message-turn hooks fire once. + await harness.sendMessage(userMessage("hi")); + // sendMessage resolves on `trigger:turn-complete`, but onTurnComplete + // fires as a separate microtask after — let it settle before snapshotting. + await new Promise((r) => setTimeout(r, 50)); + + // Snapshot call counts after the bootstrap — we'll assert these + // don't change for the action below. + const baselineRun = runSpy.mock.calls.length; + const baselineChatStart = onChatStart.mock.calls.length; + const baselineTurnStart = onTurnStart.mock.calls.length; + const baselineBeforeComplete = onBeforeTurnComplete.mock.calls.length; + const baselineComplete = onTurnComplete.mock.calls.length; + + const actionTurn = await harness.sendAction({ type: "undo" }); + await new Promise((r) => setTimeout(r, 50)); + + // onAction fired exactly once; no turn hooks fired; run() / LLM did not. + expect(onAction).toHaveBeenCalledTimes(1); + expect(runSpy.mock.calls.length).toBe(baselineRun); + expect(onChatStart.mock.calls.length).toBe(baselineChatStart); + expect(onTurnStart.mock.calls.length).toBe(baselineTurnStart); + expect(onBeforeTurnComplete.mock.calls.length).toBe(baselineBeforeComplete); + expect(onTurnComplete.mock.calls.length).toBe(baselineComplete); + + // Stream still terminates cleanly with trigger:turn-complete so + // the frontend's useChat transitions back to ready. + const sawTurnComplete = actionTurn.rawChunks.some( + (c) => + typeof c === "object" && + c !== null && + (c as { type?: string }).type === "trigger:turn-complete" + ); + expect(sawTurnComplete).toBe(true); + } finally { + await harness.close(); + } + }); + + it("actions returning a stream pipe the response without firing turn hooks", async () => { + const onTurnStart = vi.fn(); + const onTurnComplete = vi.fn(); + const actionModel = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("regenerated") }), + }); + const turnModel = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("normal-response") }), + }); + + const { z } = await import("zod"); + const agent = chat.agent({ + id: "mockChatAgent.actions.stream", + actionSchema: z.object({ type: z.literal("regenerate") }), + onTurnStart, + onTurnComplete, + onAction: async ({ messages }) => { + return streamText({ model: actionModel, messages }); + }, + run: async ({ messages, signal }) => { + return streamText({ model: turnModel, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-stream-action" }); + try { + await harness.sendMessage(userMessage("hi")); + await new Promise((r) => setTimeout(r, 50)); + const baselineTurnStart = onTurnStart.mock.calls.length; + const baselineTurnComplete = onTurnComplete.mock.calls.length; + + const actionTurn = await harness.sendAction({ type: "regenerate" }); + await new Promise((r) => setTimeout(r, 50)); + + // No turn hooks fired during the action. + expect(onTurnStart.mock.calls.length).toBe(baselineTurnStart); + expect(onTurnComplete.mock.calls.length).toBe(baselineTurnComplete); + + // Action's streamText output landed on the response. + const text = actionTurn.chunks + .filter((c) => c.type === "text-delta") + .map((c) => (c as { delta: string }).delta) + .join(""); + expect(text).toBe("regenerated"); + } finally { + await harness.close(); + } + }); + + it("warns once and emits turn-complete when an action arrives without onAction", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const runSpy = vi.fn(); + const model = new MockLanguageModelV3({ + doStream: async () => { + runSpy(); + return { stream: textStream("nope") }; + }, + }); + + const { z } = await import("zod"); + const agent = chat.agent({ + id: "mockChatAgent.actions.no-handler", + actionSchema: z.object({ type: z.literal("undo") }), + run: async ({ messages, signal }) => { + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-no-handler" }); + try { + await harness.sendMessage(userMessage("hi")); + const baselineRun = runSpy.mock.calls.length; + + const actionTurn = await harness.sendAction({ type: "undo" }); + + // No additional model call; console.warn fired with our marker text. + expect(runSpy.mock.calls.length).toBe(baselineRun); + expect( + warnSpy.mock.calls.some((args) => + (args[0] as string).includes("no `onAction` handler") + ) + ).toBe(true); + + const sawTurnComplete = actionTurn.rawChunks.some( + (c) => + typeof c === "object" && + c !== null && + (c as { type?: string }).type === "trigger:turn-complete" + ); + expect(sawTurnComplete).toBe(true); + } finally { + await harness.close(); + warnSpy.mockRestore(); + } + }); + + it("passes clientData through to run() and hooks", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + + let capturedClientData: unknown; + const agent = chat.agent({ + id: "mockChatAgent.client-data", + run: async ({ messages, clientData, signal }) => { + capturedClientData = clientData; + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { + chatId: "test-client-data", + clientData: { userId: "u1", role: "admin" }, + }); + try { + await harness.sendMessage(userMessage("hi")); + expect(capturedClientData).toEqual({ userId: "u1", role: "admin" }); + } finally { + await harness.close(); + } + }); + + it("chat.endRun() exits the loop after the current turn", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("bye") }), + }); + + let turnCount = 0; + const agent = chat.agent({ + id: "mockChatAgent.end-run", + run: async ({ messages, signal }) => { + turnCount++; + chat.endRun(); + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-end-run" }); + try { + await harness.sendMessage(userMessage("hello")); + // Give the loop a tick to exit after the turn-complete chunk + await new Promise((r) => setTimeout(r, 50)); + expect(turnCount).toBe(1); + // Subsequent sends after endRun should not produce another run — the + // loop has exited. We can't easily assert this via sendMessage (it + // would block waiting for turn-complete), but we can verify the task + // has finished. + } finally { + // close() is a no-op here since the task already exited, but call + // for symmetry with other tests. + await harness.close(); + } + }); + + it("exposes finishReason on the onTurnComplete event", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("hi") }), + }); + + let seenReason: string | undefined; + const agent = chat.agent({ + id: "mockChatAgent.finish-reason", + onTurnComplete: async ({ finishReason }) => { + seenReason = finishReason; + }, + run: async ({ messages, signal }) => { + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-finish-reason" }); + try { + await harness.sendMessage(userMessage("hello")); + await new Promise((r) => setTimeout(r, 20)); + expect(seenReason).toBe("stop"); + } finally { + await harness.close(); + } + }); + + it("seeds locals before run() via setupLocals (DI pattern)", async () => { + type FakeDb = { findUser(id: string): Promise<{ id: string; name: string }> }; + const dbKey = locals.create("test-db"); + + const fakeDb: FakeDb = { + findUser: async (id) => ({ id, name: `user-${id}` }), + }; + + let userInHook: { id: string; name: string } | undefined; + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + + const agent = chat.agent({ + id: "mockChatAgent.locals-di", + hydrateMessages: async ({ incomingMessages }) => { + const db = locals.getOrThrow(dbKey); + userInHook = await db.findUser("u-1"); + return incomingMessages; + }, + run: async ({ messages, signal }) => { + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { + chatId: "test-locals-di", + setupLocals: ({ set }) => { + set(dbKey, fakeDb); + }, + }); + try { + await harness.sendMessage(userMessage("hi")); + expect(userInHook).toEqual({ id: "u-1", name: "user-u-1" }); + } finally { + await harness.close(); + } + }); + + describe("chat.history read primitives", () => { + // These tests drive a chat.agent through realistic streams and read + // chat.history inside hooks/tools where the accumulator is in the + // expected state. The pure walks themselves are exercised end-to-end + // rather than via direct internal access. + + function toolCallStream(opts: { toolCallId: string; toolName: string; input: object }) { + return simulateReadableStream({ + chunks: [ + { type: "tool-input-start", id: opts.toolCallId, toolName: opts.toolName }, + { type: "tool-input-delta", id: opts.toolCallId, delta: JSON.stringify(opts.input) }, + { type: "tool-input-end", id: opts.toolCallId }, + { + type: "tool-call", + toolCallId: opts.toolCallId, + toolName: opts.toolName, + input: JSON.stringify(opts.input), + }, + { + type: "finish", + finishReason: { unified: "tool-calls", raw: "tool_calls" }, + usage: { + inputTokens: { total: 5, noCache: 5, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 5, text: 0, reasoning: undefined }, + }, + }, + ] as LanguageModelV3StreamPart[], + }); + } + + it("getPendingToolCalls returns input-available parts on the leaf assistant", async () => { + const { z } = await import("zod"); + const { tool } = await import("ai"); + const askUser = tool({ + description: "Ask the user.", + inputSchema: z.object({ q: z.string() }), + }); + const TC = "tc_pending_1"; + const model = new MockLanguageModelV3({ + doStream: async () => ({ + stream: toolCallStream({ toolCallId: TC, toolName: "askUser", input: { q: "?" } }), + }), + }); + + let pending: any; + const agent = chat.agent({ + id: "mockChatAgent.history.pending", + onTurnComplete: async () => { + pending = chat.history.getPendingToolCalls(); + }, + run: async ({ messages, signal }) => { + return streamText({ model, messages, tools: { askUser }, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-history-pending" }); + try { + await harness.sendMessage(userMessage("hi")); + await new Promise((r) => setTimeout(r, 50)); + + expect(pending).toHaveLength(1); + expect(pending[0]).toMatchObject({ toolCallId: TC, toolName: "askUser" }); + expect(typeof pending[0].messageId).toBe("string"); + } finally { + await harness.close(); + } + }); + + it("getPendingToolCalls returns [] when the leaf assistant has no pending tool calls", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("hello") }), + }); + let pending: any; + const agent = chat.agent({ + id: "mockChatAgent.history.pending-empty", + onTurnComplete: async () => { + pending = chat.history.getPendingToolCalls(); + }, + run: async ({ messages, signal }) => { + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-history-pending-empty" }); + try { + await harness.sendMessage(userMessage("hi")); + await new Promise((r) => setTimeout(r, 50)); + expect(pending).toEqual([]); + } finally { + await harness.close(); + } + }); + + it("getResolvedToolCalls walks all messages after a HITL answer lands", async () => { + const { z } = await import("zod"); + const { tool } = await import("ai"); + const askUser = tool({ + description: "Ask the user.", + inputSchema: z.object({ q: z.string() }), + }); + const TC = "tc_resolved_1"; + + let callIdx = 0; + const model = new MockLanguageModelV3({ + doStream: async () => ({ + stream: + callIdx++ === 0 + ? toolCallStream({ toolCallId: TC, toolName: "askUser", input: { q: "?" } }) + : textStream("done"), + }), + }); + + const turnsResolved: any[] = []; + const agent = chat.agent({ + id: "mockChatAgent.history.resolved", + onTurnComplete: async () => { + turnsResolved.push(chat.history.getResolvedToolCalls()); + }, + run: async ({ messages, signal }) => { + return streamText({ model, messages, tools: { askUser }, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-history-resolved" }); + try { + await harness.sendMessage(userMessage("hi")); + await new Promise((r) => setTimeout(r, 50)); + + // Send a HITL tool answer that resolves TC. The merge attaches the + // output to the head assistant — it now shows in `output-available` + // state. + const toolAnswer = { + id: "ai-sdk-fresh-id", + role: "assistant" as const, + parts: [ + { + type: "tool-askUser", + toolCallId: TC, + state: "output-available" as const, + input: { q: "?" }, + output: { answer: "hi" }, + }, + ], + }; + await harness.sendMessage(toolAnswer as any); + await new Promise((r) => setTimeout(r, 50)); + + // After turn 1 only, no tool call is resolved yet (input-available). + // After the HITL answer is merged, the merged head shows the tool + // in `output-available` — getResolvedToolCalls reflects that. + const last = turnsResolved.at(-1) ?? []; + expect(last).toHaveLength(1); + expect(last[0]).toMatchObject({ toolCallId: TC, toolName: "askUser" }); + } finally { + await harness.close(); + } + }); + + it("extractNewToolResults dedups against already-resolved toolCallIds", async () => { + // Pure-function smoke test: feed a synthetic chain via a tool that + // calls extractNewToolResults() during execution. The chain is + // overridden via chat.history.set() inside run(), so we control + // exactly what's in scope. + let extracted: any; + const agent = chat.agent({ + id: "mockChatAgent.history.extract", + run: async ({ messages, signal }) => { + chat.history.set([ + { + id: "a-seed", + role: "assistant", + parts: [ + { + type: "tool-askUser", + toolCallId: "tc-1", + state: "output-available", + input: { q: "?" }, + output: { color: "red" }, + }, + ], + } as any, + { id: "u-1", role: "user", parts: [{ type: "text", text: "u" }] } as any, + ]); + + const incoming = { + id: "a-incoming", + role: "assistant" as const, + parts: [ + { + type: "tool-askUser", + toolCallId: "tc-1", + state: "output-available" as const, + input: { q: "?" }, + output: { color: "red" }, + }, + { + type: "tool-search", + toolCallId: "tc-2", + state: "output-available" as const, + input: { q: "x" }, + output: { hits: 7 }, + }, + { + type: "tool-search", + toolCallId: "tc-err", + state: "output-error" as const, + input: { q: "y" }, + errorText: "boom", + }, + ], + }; + extracted = chat.history.extractNewToolResults(incoming as any); + + return streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }), + messages, + abortSignal: signal, + }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-history-extract" }); + try { + await harness.sendMessage(userMessage("kick")); + await new Promise((r) => setTimeout(r, 50)); + + expect(extracted).toEqual([ + { toolCallId: "tc-2", toolName: "search", output: { hits: 7 } }, + { toolCallId: "tc-err", toolName: "search", output: undefined, errorText: "boom" }, + ]); + } finally { + await harness.close(); + } + }); + + it("findMessage returns the message by id, or undefined when missing", async () => { + let foundUser: any; + let foundAssistant: any; + let missing: any; + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + const agent = chat.agent({ + id: "mockChatAgent.history.find", + onTurnComplete: async ({ uiMessages }) => { + // Locate ids the agent actually produced/saw, then probe findMessage. + const userId = uiMessages.find((m) => m.role === "user")?.id ?? "u-fixed"; + const asstId = uiMessages.find((m) => m.role === "assistant")?.id; + foundUser = chat.history.findMessage(userId); + foundAssistant = asstId ? chat.history.findMessage(asstId) : undefined; + missing = chat.history.findMessage("definitely-not-here"); + }, + run: async ({ messages, signal }) => { + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-history-find" }); + try { + await harness.sendMessage(userMessage("hello", "u-fixed")); + await new Promise((r) => setTimeout(r, 50)); + + expect(foundUser?.id).toBe("u-fixed"); + expect(foundAssistant).toBeTruthy(); + expect(missing).toBeUndefined(); + } finally { + await harness.close(); + } + }); + + it("extractNewToolResults dedups against a real-stream-built chain", async () => { + // Build the chain through real model streams (no chat.history.set seed) + // and assert extractNewToolResults compares against the post-merge state. + const { z } = await import("zod"); + const { tool } = await import("ai"); + const askUser = tool({ + description: "Ask the user.", + inputSchema: z.object({ q: z.string() }), + }); + const TC = "tc_real_chain_1"; + + let callIdx = 0; + const model = new MockLanguageModelV3({ + doStream: async () => ({ + stream: + callIdx++ === 0 + ? toolCallStream({ toolCallId: TC, toolName: "askUser", input: { q: "?" } }) + : textStream("done"), + }), + }); + + let extractedAgainstRealChain: any; + const agent = chat.agent({ + id: "mockChatAgent.history.extract-real", + onTurnComplete: async () => { + // After the HITL answer turn, the chain has TC resolved. An + // incoming "echo" message carrying TC again should yield []. + // A second new TC should yield exactly one entry. + const incoming = { + id: "echo", + role: "assistant" as const, + parts: [ + { + type: "tool-askUser", + toolCallId: TC, + state: "output-available" as const, + input: { q: "?" }, + output: { answer: "hi" }, + }, + { + type: "tool-askUser", + toolCallId: "tc_real_chain_2", + state: "output-available" as const, + input: { q: "second" }, + output: { answer: "yes" }, + }, + ], + }; + extractedAgainstRealChain = chat.history.extractNewToolResults(incoming as any); + }, + run: async ({ messages, signal }) => { + return streamText({ model, messages, tools: { askUser }, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-history-extract-real" }); + try { + await harness.sendMessage(userMessage("hi")); + await new Promise((r) => setTimeout(r, 50)); + // HITL answer for TC, lands via the runtime merger. + const toolAnswer = { + id: "ai-sdk-fresh-id-real", + role: "assistant" as const, + parts: [ + { + type: "tool-askUser", + toolCallId: TC, + state: "output-available" as const, + input: { q: "?" }, + output: { answer: "hi" }, + }, + ], + }; + await harness.sendMessage(toolAnswer as any); + await new Promise((r) => setTimeout(r, 50)); + + expect(extractedAgainstRealChain).toEqual([ + { toolCallId: "tc_real_chain_2", toolName: "askUser", output: { answer: "yes" } }, + ]); + } finally { + await harness.close(); + } + }); + + it("extractNewToolResults surfaces output-error parts via the runtime merger", async () => { + // The runtime merges incoming tool-answer messages onto the head + // assistant via the toolCallId map. Here we send an answer in + // `output-error` state and verify (a) getResolvedToolCalls reports + // it, and (b) extractNewToolResults emits it with errorText set. + const { z } = await import("zod"); + const { tool } = await import("ai"); + const search = tool({ + description: "Search.", + inputSchema: z.object({ q: z.string() }), + }); + const TC = "tc_err_via_merger"; + + let callIdx = 0; + const model = new MockLanguageModelV3({ + doStream: async () => ({ + stream: + callIdx++ === 0 + ? toolCallStream({ toolCallId: TC, toolName: "search", input: { q: "x" } }) + : textStream("noted"), + }), + }); + + let resolved: any; + let extracted: any; + const agent = chat.agent({ + id: "mockChatAgent.history.extract-error", + onTurnComplete: async () => { + resolved = chat.history.getResolvedToolCalls(); + // An echo carrying the same error toolCallId — should NOT surface + // as new because it's already resolved on the chain. + const echo = { + id: "echo-err", + role: "assistant" as const, + parts: [ + { + type: "tool-search", + toolCallId: TC, + state: "output-error" as const, + input: { q: "x" }, + errorText: "boom", + }, + ], + }; + extracted = chat.history.extractNewToolResults(echo as any); + }, + run: async ({ messages, signal }) => { + return streamText({ model, messages, tools: { search }, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-history-extract-error" }); + try { + await harness.sendMessage(userMessage("kick")); + await new Promise((r) => setTimeout(r, 50)); + // HITL answer arriving as output-error. + const errAnswer = { + id: "ai-sdk-err-fresh", + role: "assistant" as const, + parts: [ + { + type: "tool-search", + toolCallId: TC, + state: "output-error" as const, + input: { q: "x" }, + errorText: "boom", + }, + ], + }; + await harness.sendMessage(errAnswer as any); + await new Promise((r) => setTimeout(r, 50)); + + expect(resolved).toHaveLength(1); + expect(resolved[0]).toMatchObject({ toolCallId: TC, toolName: "search" }); + // Echo of the same error toolCallId is already resolved → [] + expect(extracted).toEqual([]); + } finally { + await harness.close(); + } + }); + + it("extractNewToolResults handles a multi-tool message where only one is new", async () => { + // Pure-helper edge: incoming message has two tool parts with the + // same toolName but different toolCallIds — one already resolved + // on the chain, one fresh. Only the fresh one should surface. + let extracted: any; + const agent = chat.agent({ + id: "mockChatAgent.history.extract-multi", + run: async ({ messages, signal }) => { + chat.history.set([ + { + id: "a-seed", + role: "assistant", + parts: [ + { + type: "tool-search", + toolCallId: "tc-old", + state: "output-available", + input: { q: "old" }, + output: { hits: 1 }, + }, + ], + } as any, + { id: "u-1", role: "user", parts: [{ type: "text", text: "u" }] } as any, + ]); + + const incoming = { + id: "a-incoming", + role: "assistant" as const, + parts: [ + // Same tool, already-resolved id — should be filtered. + { + type: "tool-search", + toolCallId: "tc-old", + state: "output-available" as const, + input: { q: "old" }, + output: { hits: 1 }, + }, + // Same tool, fresh id — should surface. + { + type: "tool-search", + toolCallId: "tc-new", + state: "output-available" as const, + input: { q: "new" }, + output: { hits: 9 }, + }, + // Duplicate of tc-new in the same message — must collapse + // to a single emission (within-message dedup). + { + type: "tool-search", + toolCallId: "tc-new", + state: "output-available" as const, + input: { q: "new" }, + output: { hits: 9 }, + }, + ], + }; + extracted = chat.history.extractNewToolResults(incoming as any); + + return streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }), + messages, + abortSignal: signal, + }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-history-extract-multi" }); + try { + await harness.sendMessage(userMessage("kick")); + await new Promise((r) => setTimeout(r, 50)); + + expect(extracted).toEqual([ + { toolCallId: "tc-new", toolName: "search", output: { hits: 9 } }, + ]); + } finally { + await harness.close(); + } + }); + + it("getPendingToolCalls still returns the assistant's pending calls when a user message follows", async () => { + // Edge: the chain is [assistant(input-available), user]. The most + // recent assistant is the one with the pending tool call, even + // though the strict tail is a user message. The walk-back semantic + // means pending stays pending until the assistant is mutated. + let pendingAfterUser: any; + const agent = chat.agent({ + id: "mockChatAgent.history.pending-after-user", + run: async ({ messages, signal }) => { + chat.history.set([ + { + id: "a-pending", + role: "assistant", + parts: [ + { + type: "tool-askUser", + toolCallId: "tc-still-pending", + state: "input-available", + input: { q: "?" }, + }, + ], + } as any, + { id: "u-after", role: "user", parts: [{ type: "text", text: "anyway..." }] } as any, + ]); + pendingAfterUser = chat.history.getPendingToolCalls(); + return streamText({ + model: new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }), + messages, + abortSignal: signal, + }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-history-pending-after-user" }); + try { + await harness.sendMessage(userMessage("kick")); + await new Promise((r) => setTimeout(r, 50)); + + expect(pendingAfterUser).toEqual([ + { toolCallId: "tc-still-pending", toolName: "askUser", messageId: "a-pending" }, + ]); + } finally { + await harness.close(); + } + }); + + it("getChain returns a defensive copy parallel to all()", async () => { + let chainCopy: any; + let allCopy: any; + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + const agent = chat.agent({ + id: "mockChatAgent.history.chain", + onTurnComplete: async () => { + chainCopy = chat.history.getChain(); + allCopy = chat.history.all(); + // Mutate one — must not affect the other. + chainCopy.push({ id: "stray", role: "user", parts: [] }); + }, + run: async ({ messages, signal }) => { + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "test-history-chain" }); + try { + await harness.sendMessage(userMessage("a")); + await new Promise((r) => setTimeout(r, 50)); + + expect(chainCopy.length).toBeGreaterThan(0); + expect(allCopy.length).toBe(chainCopy.length - 1); + expect(allCopy.find((m: any) => m.id === "stray")).toBeUndefined(); + } finally { + await harness.close(); + } + }); + }); + + it("cleans up properly after close() so the next harness starts fresh", async () => { + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("first") }), + }); + + const agent = chat.agent({ + id: "mockChatAgent.cleanup", + run: async ({ messages, signal }) => { + return streamText({ model, messages, abortSignal: signal }); + }, + }); + + // First harness + const h1 = mockChatAgent(agent, { chatId: "test-cleanup-1" }); + await h1.sendMessage(userMessage("a")); + await h1.close(); + + // Second harness should work independently + const h2 = mockChatAgent(agent, { chatId: "test-cleanup-2" }); + try { + const turn = await h2.sendMessage(userMessage("b")); + const text = turn.chunks + .filter((c) => c.type === "text-delta") + .map((c) => (c as { delta: string }).delta) + .join(""); + expect(text).toBe("first"); + // Chunks from h1 should NOT be visible here + expect(h2.allChunks).toEqual(turn.chunks); + } finally { + await h2.close(); + } + }); + + describe("slim wire harness primitives (snapshot + replay)", () => { + // Plan E.1 + F.2: exercise the new harness driver methods so future + // edits don't accidentally drop boot scenarios that the runtime now + // depends on (snapshot read, replay tail, head-start seeding, + // hydrateMessages short-circuit). + + it("getSnapshot returns the most recent writeChatSnapshot value", async () => { + // Run a single turn end-to-end and verify the runtime's post-turn + // snapshot write is captured by the harness's getSnapshot primitive. + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("captured") }), + }); + const agent = chat.agent({ + id: "mockChatAgent.snapshot.write", + run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { chatId: "snap-write" }); + try { + expect(harness.getSnapshot()).toBeUndefined(); + await harness.sendMessage(userMessage("hi")); + // onTurnComplete -> writeChatSnapshot fires AFTER turn-complete chunk; + // give it a tick to settle. + await new Promise((r) => setTimeout(r, 50)); + const snap = harness.getSnapshot(); + expect(snap).toBeDefined(); + expect(snap!.version).toBe(1); + // The snapshot reflects the post-turn accumulator: 1 user + 1 assistant. + const roles = snap!.messages.map((m) => m.role); + expect(roles).toEqual(["user", "assistant"]); + } finally { + await harness.close(); + } + }); + + it("seedSnapshot pre-populates the accumulator on boot — onChatStart sees prior history", async () => { + // Plan B.3: the boot calls readChatSnapshot before onChatStart fires. + // A seeded snapshot lets the test simulate a "continuation" boot. + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ack") }), + }); + let messagesAtChatStart: any[] = []; + const agent = chat.agent({ + id: "mockChatAgent.snapshot.seed", + onChatStart: async ({ messages }) => { + messagesAtChatStart = messages; + }, + run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { + chatId: "snap-seed", + snapshot: { + version: 1, + savedAt: Date.now(), + messages: [ + { id: "u-prev", role: "user", parts: [{ type: "text", text: "earlier" }] }, + { id: "a-prev", role: "assistant", parts: [{ type: "text", text: "ok" }] }, + ], + }, + }); + try { + await harness.sendMessage(userMessage("now")); + await new Promise((r) => setTimeout(r, 50)); + // onChatStart sees ModelMessage[] (not UIMessage[]). The exact + // count varies because `toModelMessages` may split a single UI + // message into multiple ModelMessages depending on parts. The + // load-bearing assertion is that prior history was loaded — + // `messages` is non-empty before turn 0 even though the wire + // payload only carried the new user message. + expect(messagesAtChatStart.length).toBeGreaterThan(0); + } finally { + await harness.close(); + } + }); + + it("sendRegenerate (no-args) trims trailing assistant and re-runs", async () => { + // Plan B.4: regenerate-message wire carries no message body. The + // agent trims trailing assistants from its accumulator and runs + // streamText again. Verify the harness's no-arg sendRegenerate + // drives this path end-to-end. + let callIdx = 0; + const model = new MockLanguageModelV3({ + doStream: async () => ({ + stream: textStream(callIdx++ === 0 ? "first-reply" : "regenerated"), + }), + }); + const agent = chat.agent({ + id: "mockChatAgent.regenerate.slim", + run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { chatId: "regen-slim" }); + try { + const t1 = await harness.sendMessage(userMessage("question")); + const t1Text = t1.chunks + .filter((c) => c.type === "text-delta") + .map((c) => (c as { delta: string }).delta) + .join(""); + expect(t1Text).toBe("first-reply"); + + // No-arg regenerate — the agent re-runs streamText; second call + // emits the regenerated stream. + const t2 = await harness.sendRegenerate(); + const t2Text = t2.chunks + .filter((c) => c.type === "text-delta") + .map((c) => (c as { delta: string }).delta) + .join(""); + expect(t2Text).toBe("regenerated"); + } finally { + await harness.close(); + } + }); + + it("sendHeadStart seeds accumulator from headStartMessages on turn 0", async () => { + // Plan B.3 head-start bootstrap: when trigger is `handover-prepare` + // and accumulator is empty, the runtime seeds from + // payload.headStartMessages. Verify the harness drives this with + // the customer's first-turn UIMessage[] history through the head- + // start route. + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("post-handover") }), + }); + let messagesAtChatStart: any[] = []; + const agent = chat.agent({ + id: "mockChatAgent.headstart.slim", + onChatStart: async ({ messages }) => { + messagesAtChatStart = messages; + }, + run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { + chatId: "headstart-slim", + mode: "handover-prepare", + }); + try { + // The head-start payload carries the full first-turn history. + // After it lands, the agent's `chat.handover` flow normally + // dispatches a `handover` signal; we use the simpler primitive + // here just to verify the seeding takes effect at boot. + const handover = harness.sendHandover({ + partialAssistantMessage: [{ type: "text", text: "from-handover" }], + isFinal: true, + }); + // Drive through to turn-complete + await handover; + await new Promise((r) => setTimeout(r, 50)); + // No assertion on seeding here beyond turn-complete reaching us — + // sendHeadStart routes via session.in for tests that need the + // wire-level path; the per-test wiring above exercises the + // handover branch which is the production path for this scenario. + } finally { + await harness.close(); + } + }); + + it("hydrateMessages registered short-circuits snapshot read/write", async () => { + // Plan B.1/B.6: with hydrateMessages set, the runtime skips both + // readChatSnapshot at boot AND writeChatSnapshot after onTurnComplete. + // Verify by asserting `getSnapshot()` stays undefined across a turn. + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + const agent = chat.agent({ + id: "mockChatAgent.hydrate.skip-snapshot", + hydrateMessages: async ({ incomingMessages }) => incomingMessages, + run: async ({ messages, signal }) => streamText({ model, messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { chatId: "hydrate-skip" }); + try { + await harness.sendMessage(userMessage("hi")); + await new Promise((r) => setTimeout(r, 50)); + // No snapshot was written — customer with hydrateMessages owns + // persistence themselves. + expect(harness.getSnapshot()).toBeUndefined(); + } finally { + await harness.close(); + } + }); + }); + + describe("onChatStart fires exactly once per chat", () => { + // Contract: `onChatStart` fires only on the chat's very first user + // message ever. It does NOT re-fire on continuation runs (post-`endRun`, + // post-waitpoint-timeout, post-`chat.requestUpgrade`) or on OOM-retry + // attempts. Customers put one-time chat-setup work there (Chat DB row + // create, user-context init) and that contract relies on once-per-chat + // semantics. + + it("fires on a fresh first message (baseline)", async () => { + const onChatStart = vi.fn(); + const onTurnStart = vi.fn(); + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("hi") }), + }); + const agent = chat.agent({ + id: "onChatStart-gate.fresh-baseline", + onChatStart, + onTurnStart, + run: async ({ messages, signal }) => + streamText({ model, messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { chatId: "fresh-baseline" }); + try { + await harness.sendMessage(userMessage("hello")); + await new Promise((r) => setTimeout(r, 20)); + expect(onChatStart).toHaveBeenCalledTimes(1); + expect(onTurnStart).toHaveBeenCalledTimes(1); + } finally { + await harness.close(); + } + }); + + it("does NOT fire on a continuation run (continuation: true at boot)", async () => { + const onChatStart = vi.fn(); + const onTurnStart = vi.fn(); + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + const agent = chat.agent({ + id: "onChatStart-gate.continuation-skip", + // hydrateMessages registered so the boot path doesn't try to read a + // snapshot the harness doesn't have — keeps the continuation-wait + // branch clean. + hydrateMessages: async ({ incomingMessages }) => incomingMessages, + onChatStart, + onTurnStart, + run: async ({ messages, signal }) => + streamText({ model, messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { + chatId: "continuation-skip", + // `continuation: true` auto-selects `mode: "continuation"` — + // boots with `trigger` omitted (mirroring what the server's + // continuation overrides produce in production) and enters the + // SDK's continuation-wait branch. + continuation: true, + previousRunId: "run_test_prior", + }); + try { + // The continuation-wait branch parks until the first session.in + // message arrives — sending one wakes it and runs turn 0. + await harness.sendMessage(userMessage("first user message of this run")); + await new Promise((r) => setTimeout(r, 20)); + expect(onChatStart).not.toHaveBeenCalled(); + expect(onTurnStart).toHaveBeenCalledTimes(1); + } finally { + await harness.close(); + } + }); + + it("does NOT fire on an OOM-retry attempt (ctx.attempt.number > 1)", async () => { + const onChatStart = vi.fn(); + const onTurnStart = vi.fn(); + const model = new MockLanguageModelV3({ + doStream: async () => ({ stream: textStream("ok") }), + }); + const agent = chat.agent({ + id: "onChatStart-gate.oom-retry-skip", + hydrateMessages: async ({ incomingMessages }) => incomingMessages, + onChatStart, + onTurnStart, + run: async ({ messages, signal }) => + streamText({ model, messages, abortSignal: signal }), + }); + const harness = mockChatAgent(agent, { + chatId: "oom-retry-skip", + taskContext: { + ctx: { attempt: { number: 2, startedAt: new Date(0) } }, + }, + }); + try { + await harness.sendMessage(userMessage("hi")); + await new Promise((r) => setTimeout(r, 20)); + expect(onChatStart).not.toHaveBeenCalled(); + expect(onTurnStart).toHaveBeenCalledTimes(1); + } finally { + await harness.close(); + } + }); + }); +}); diff --git a/packages/trigger-sdk/test/replay-session-out.test.ts b/packages/trigger-sdk/test/replay-session-out.test.ts new file mode 100644 index 00000000000..802f4ff0c41 --- /dev/null +++ b/packages/trigger-sdk/test/replay-session-out.test.ts @@ -0,0 +1,307 @@ +// Import the test entry point first so the resource catalog is installed. +import "../src/v3/test/index.js"; + +import type { UIMessageChunk } from "ai"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { apiClientManager } from "@trigger.dev/core/v3"; +import { __replaySessionOutTailProductionPathForTests as replaySessionOutTail } from "../src/v3/ai.js"; + +// ── Helpers ──────────────────────────────────────────────────────────── + +/** + * Build the canonical chunk sequence the AI SDK emits for a single text + * turn from message `id`. Includes a trailing `finish` so the segment is + * marked closed (i.e. NOT subject to `cleanupAbortedParts`). + */ +function textTurn(id: string, text: string, role: "assistant" = "assistant"): UIMessageChunk[] { + return [ + { type: "start", messageId: id, messageMetadata: { role } } as UIMessageChunk, + { type: "text-start", id: `${id}.t1` } as UIMessageChunk, + { type: "text-delta", id: `${id}.t1`, delta: text } as UIMessageChunk, + { type: "text-end", id: `${id}.t1` } as UIMessageChunk, + { type: "finish" } as UIMessageChunk, + ]; +} + +/** + * Same as `textTurn` but omits the trailing `finish` chunk — simulates a + * crashed turn whose stream ended mid-message. The runtime's reducer + * should run `cleanupAbortedParts` on the resulting trailing message. + */ +function partialTurn(id: string, text: string): UIMessageChunk[] { + return [ + { type: "start", messageId: id, messageMetadata: { role: "assistant" } } as UIMessageChunk, + { type: "text-start", id: `${id}.t1` } as UIMessageChunk, + { type: "text-delta", id: `${id}.t1`, delta: text } as UIMessageChunk, + // No text-end, no finish. + ]; +} + +/** + * Stub `apiClientManager.clientOrThrow().readSessionStreamRecords` so the + * helper sees a `{ records: StreamRecord[] }` response. Each StreamRecord + * is `{ data: string, id, seqNum }` — `data` is the JSON-encoded chunk + * body the runtime then `JSON.parse`s. + * + * Pass either a `UIMessageChunk` (will be JSON.stringify'd) or a raw + * string (used as `data` directly — for tests that need pre-stringified + * or deliberately-malformed bodies). + * + * Captures the `afterEventId` argument for resume-from-cursor assertions. + */ +function stubReadRecordsWithChunks(chunks: unknown[]) { + const records = chunks.map((chunk, i) => ({ + data: typeof chunk === "string" ? chunk : JSON.stringify(chunk), + id: `evt-${i + 1}`, + seqNum: i + 1, + })); + const readRecordsSpy = vi.fn( + async (_id: string, _io: "in" | "out", _options?: { afterEventId?: string }) => ({ + records, + }) + ); + vi.spyOn(apiClientManager, "clientOrThrow").mockReturnValue({ + readSessionStreamRecords: readRecordsSpy, + } as never); + return readRecordsSpy; +} + +// ── Tests ────────────────────────────────────────────────────────────── + +describe("replaySessionOutTail", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + warnSpy.mockRestore(); + }); + + it("returns [] for an empty session.out stream", async () => { + stubReadRecordsWithChunks([]); + const result = await replaySessionOutTail("empty-session"); + expect(result).toEqual([]); + }); + + it("reduces a single text turn into one assistant UIMessage", async () => { + stubReadRecordsWithChunks(textTurn("a-1", "hello world")); + const result = await replaySessionOutTail("text-session"); + expect(result).toHaveLength(1); + expect(result[0]).toMatchObject({ id: "a-1", role: "assistant" }); + const text = (result[0]!.parts as Array<{ type: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text) + .join(""); + expect(text).toBe("hello world"); + }); + + it("reduces multiple sequential turns into multiple UIMessages", async () => { + stubReadRecordsWithChunks([ + ...textTurn("a-1", "first"), + ...textTurn("a-2", "second"), + ...textTurn("a-3", "third"), + ]); + + const result = await replaySessionOutTail("multi-session"); + expect(result).toHaveLength(3); + expect(result.map((m) => m.id)).toEqual(["a-1", "a-2", "a-3"]); + }); + + it("filters out `trigger:*` control chunks (turn-complete, etc.)", async () => { + stubReadRecordsWithChunks([ + ...textTurn("a-1", "hello"), + { type: "trigger:turn-complete", lastEventId: "evt-1", lastEventTimestamp: 1 }, + { type: "trigger:upgrade-required" }, + ...textTurn("a-2", "second"), + ]); + + const result = await replaySessionOutTail("control-session"); + // Two assistant messages reduced — the trigger:* records are dropped + // before reaching the reducer. + expect(result).toHaveLength(2); + expect(result.map((m) => m.id)).toEqual(["a-1", "a-2"]); + }); + + it("never emits user-role messages (session.out is assistant-only)", async () => { + // session.out conceptually only carries assistant chunks (the user's + // messages live on session.in). Even if a user-role start somehow + // landed there, the reducer wouldn't surface a user message via this + // helper's contract. + stubReadRecordsWithChunks(textTurn("a-1", "ok")); + const result = await replaySessionOutTail("assistant-only"); + expect(result.every((m) => m.role !== "user")).toBe(true); + }); + + it("passes `lastEventId` through as `afterEventId` to readSessionStreamRecords", async () => { + // The replay helper accepts `lastEventId` from the caller (matching + // the snapshot's persisted cursor name) and forwards it as + // `afterEventId` on the records endpoint — that's the field name on + // the new non-SSE route. + const readRecordsSpy = stubReadRecordsWithChunks(textTurn("a-1", "ok")); + await replaySessionOutTail("resume-session", { lastEventId: "evt-99" }); + + expect(readRecordsSpy).toHaveBeenCalledWith( + "resume-session", + "out", + expect.objectContaining({ afterEventId: "evt-99" }) + ); + }); + + it("uses the non-SSE records endpoint (drain-and-close, no long-poll)", async () => { + // Replay no longer subscribes to the SSE stream — that imposed a ~1s + // long-poll tax on every fresh chat boot. The new path hits + // `readSessionStreamRecords` (one synchronous GET that returns + // whatever's already in the stream) and returns immediately when + // empty. Lock the call site down so a regression to SSE shows up + // here. + const readRecordsSpy = stubReadRecordsWithChunks([]); + const result = await replaySessionOutTail("drain-session"); + + expect(readRecordsSpy).toHaveBeenCalledWith("drain-session", "out", expect.any(Object)); + expect(result).toEqual([]); + }); + + it("strips orphaned in-flight tool parts from a partial trailing assistant", async () => { + // The runtime applies `cleanupAbortedParts` only on the trailing + // segment when its closure flag is `false` (no `finish` chunk + // received). The cleanup removes tool parts that never reached a + // terminal state — `input-streaming`, `output-pending`, etc. — + // because those represent partial in-flight work that won't resolve. + // + // Text parts with already-streamed content are preserved (the user + // already saw them), so we test the tool-part path specifically. + stubReadRecordsWithChunks([ + ...textTurn("a-1", "previous-turn-finished"), + // Trailing turn: starts a tool call but never resolves it. + { type: "start", messageId: "a-2", messageMetadata: { role: "assistant" } } as UIMessageChunk, + { type: "tool-input-start", toolCallId: "tc-cut", toolName: "search" } as UIMessageChunk, + { type: "tool-input-delta", toolCallId: "tc-cut", inputTextDelta: '{"q":"x"}' } as UIMessageChunk, + // No tool-input-end, no tool-call, no finish → orphaned. + ]); + + const result = await replaySessionOutTail("partial-tool-session"); + // The closed turn survives. + expect(result.find((m) => m.id === "a-1")).toBeTruthy(); + // Trailing message either gets dropped (cleanup empties it) or its + // orphaned tool part is stripped to a terminal state. Either way, + // no `tc-cut` part should be left in `input-streaming` state — that + // would represent a tool the next turn would re-process. + const trailing = result.find((m) => m.id === "a-2"); + if (trailing) { + const orphanedToolPart = (trailing.parts as Array<{ type: string; toolCallId?: string; state?: string }>).find( + (p) => p.toolCallId === "tc-cut" && p.state === "input-streaming" + ); + expect(orphanedToolPart).toBeUndefined(); + } + }); + + it("drops a trailing message whose only parts are stripped by cleanup", async () => { + // Trailing turn whose ONLY content is an orphaned tool — after + // cleanup the message has no parts left, so the helper drops it + // entirely (it never reached the next turn's accumulator). + stubReadRecordsWithChunks([ + ...textTurn("a-1", "complete"), + { type: "start", messageId: "a-orphan", messageMetadata: { role: "assistant" } } as UIMessageChunk, + { type: "tool-input-start", toolCallId: "tc-orph", toolName: "search" } as UIMessageChunk, + // No tool-input-end, no tool-call, no finish. + ]); + + const result = await replaySessionOutTail("dropped-trailing"); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("a-1"); + }); + + it("preserves a complete trailing assistant (cleanup is a no-op)", async () => { + // Trailing turn that DID end with `finish` is closed — cleanupAbortedParts + // doesn't fire. Use this to lock down that closed segments survive + // unchanged. + stubReadRecordsWithChunks(textTurn("a-1", "fully-finished")); + const result = await replaySessionOutTail("closed-session"); + expect(result).toHaveLength(1); + const text = (result[0]!.parts as Array<{ type: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text) + .join(""); + expect(text).toBe("fully-finished"); + }); + + it("JSON-decodes each record.data (every record arrives pre-serialized)", async () => { + // The records endpoint hands each chunk back as a JSON string in + // `record.data` — the agent JSON.parses it client-side so the + // server's hot path doesn't pay the parse cost. Verify a normal + // turn round-trips through JSON encode→decode. + const stringChunks = textTurn("a-1", "from-string").map((c) => JSON.stringify(c)); + stubReadRecordsWithChunks(stringChunks); + + const result = await replaySessionOutTail("string-chunks"); + expect(result).toHaveLength(1); + const text = (result[0]!.parts as Array<{ type: string; text?: string }>) + .filter((p) => p.type === "text") + .map((p) => p.text) + .join(""); + expect(text).toBe("from-string"); + }); + + it("skips records whose data is unparseable JSON", async () => { + // The replay helper wraps the per-record JSON.parse in try/catch so + // a single malformed record can't sink the rest of the replay. The + // server should never serve a malformed `data`, but the defensive + // catch lets a poisoned record skip cleanly. + stubReadRecordsWithChunks([ + "not-json-{[", + ...textTurn("a-1", "survived"), + ]); + + const result = await replaySessionOutTail("garbage-session"); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("a-1"); + }); + + it("skips records whose decoded data is not an object", async () => { + // After JSON.parse, the helper requires `chunk` to be a non-null + // object with a string `type` field. Records that decode to + // primitives (number, string, etc.) are dropped silently. + stubReadRecordsWithChunks([ + JSON.stringify(42), + JSON.stringify(null), + JSON.stringify("just-a-string"), + ...textTurn("a-1", "survived"), + ]); + + const result = await replaySessionOutTail("primitive-data-session"); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("a-1"); + }); + + it("ignores chunks missing a `type` field", async () => { + stubReadRecordsWithChunks([ + { foo: "bar" }, + { type: 42 }, + ...textTurn("a-1", "valid"), + ]); + + const result = await replaySessionOutTail("typeless-session"); + expect(result).toHaveLength(1); + expect(result[0]!.id).toBe("a-1"); + }); + + it("recovers from a malformed segment by skipping it (logs a warn)", async () => { + // The reducer for one segment throws (e.g. invalid chunk sequence). + // The helper logs the warning and proceeds with the next segment — + // a single corrupt segment shouldn't sink the entire replay. + stubReadRecordsWithChunks([ + // Malformed: text-end with no preceding text-start. + { type: "start", messageId: "bad-1", messageMetadata: { role: "assistant" } } as UIMessageChunk, + { type: "text-end", id: "no-such-text" } as UIMessageChunk, + { type: "finish" } as UIMessageChunk, + ...textTurn("a-1", "after-bad"), + ]); + + const result = await replaySessionOutTail("recovery-session"); + // The valid turn after the malformed one must still surface. + expect(result.find((m) => m.id === "a-1")).toBeTruthy(); + }); +}); diff --git a/packages/trigger-sdk/test/skill.test.ts b/packages/trigger-sdk/test/skill.test.ts new file mode 100644 index 00000000000..61e497933b6 --- /dev/null +++ b/packages/trigger-sdk/test/skill.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtemp, mkdir, realpath, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { defineSkill, parseFrontmatter } from "../src/v3/skill.js"; + +describe("parseFrontmatter", () => { + it("parses name + description", () => { + const { frontmatter, body } = parseFrontmatter( + `---\nname: pdf-processing\ndescription: Extract text from PDFs.\n---\n\n# Body\n\nhello\n` + ); + expect(frontmatter.name).toBe("pdf-processing"); + expect(frontmatter.description).toBe("Extract text from PDFs."); + expect(body).toBe("# Body\n\nhello\n"); + }); + + it("strips surrounding quotes", () => { + const { frontmatter } = parseFrontmatter( + `---\nname: "quoted-name"\ndescription: 'single quoted'\n---\nbody\n` + ); + expect(frontmatter.name).toBe("quoted-name"); + expect(frontmatter.description).toBe("single quoted"); + }); + + it("throws on missing frontmatter block", () => { + expect(() => parseFrontmatter("# just a heading\n")).toThrow(/missing a frontmatter block/); + }); + + it("throws on missing required name", () => { + expect(() => parseFrontmatter(`---\ndescription: desc\n---\nbody`)).toThrow( + /missing required `name`/ + ); + }); + + it("throws on missing required description", () => { + expect(() => parseFrontmatter(`---\nname: foo\n---\nbody`)).toThrow( + /missing required `description`/ + ); + }); +}); + +describe("defineSkill.local()", () => { + const originalCwd = process.cwd(); + let workdir: string; + + beforeEach(async () => { + workdir = await realpath(await mkdtemp(path.join(tmpdir(), "skill-test-"))); + process.chdir(workdir); + }); + + afterEach(async () => { + process.chdir(originalCwd); + await rm(workdir, { recursive: true, force: true }); + }); + + it("reads a bundled SKILL.md and returns a ResolvedSkill", async () => { + const skillDir = path.join(workdir, ".trigger", "skills", "pdf"); + await mkdir(skillDir, { recursive: true }); + await writeFile( + path.join(skillDir, "SKILL.md"), + `---\nname: pdf\ndescription: Extract PDF text.\n---\n\n# PDF skill\n\nUse scripts/extract.py.\n` + ); + + const skill = defineSkill({ id: "pdf", path: "./skills/pdf" }); + const resolved = await skill.local(); + + expect(resolved.id).toBe("pdf"); + expect(resolved.version).toBe("local"); + expect(resolved.labels).toEqual([]); + expect(resolved.frontmatter.name).toBe("pdf"); + expect(resolved.frontmatter.description).toBe("Extract PDF text."); + expect(resolved.body).toContain("# PDF skill"); + expect(resolved.body).toContain("Use scripts/extract.py"); + expect(resolved.path).toBe(skillDir); + }); + + it("throws a useful error when SKILL.md is missing", async () => { + const skill = defineSkill({ id: "missing", path: "./skills/missing" }); + await expect(skill.local()).rejects.toThrow(/could not read SKILL.md/); + }); + + it("resolve() throws with a helpful Phase 1 message", async () => { + const skill = defineSkill({ id: "phase-2", path: "./skills/phase-2" }); + await expect(skill.resolve()).rejects.toThrow(/not available yet.*Phase 2.*local/s); + }); +}); diff --git a/packages/trigger-sdk/test/skillsRuntime.test.ts b/packages/trigger-sdk/test/skillsRuntime.test.ts new file mode 100644 index 00000000000..125471ff795 --- /dev/null +++ b/packages/trigger-sdk/test/skillsRuntime.test.ts @@ -0,0 +1,221 @@ +// Import the test harness FIRST so the resource catalog is installed +import { mockChatAgent } from "../src/v3/test/index.js"; + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtemp, mkdir, realpath, writeFile, rm, chmod } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import type { LanguageModelV3StreamPart } from "@ai-sdk/provider"; +import { MockLanguageModelV3 } from "ai/test"; +import { simulateReadableStream, streamText } from "ai"; +import { buildSkillTools, chat } from "../src/v3/ai.js"; +import { defineSkill } from "../src/v3/skill.js"; + +function userMessage(text: string, id?: string) { + return { + id: id ?? `u-${Math.random().toString(36).slice(2)}`, + role: "user" as const, + parts: [{ type: "text" as const, text }], + }; +} + +function textStream(text: string) { + const chunks: LanguageModelV3StreamPart[] = [ + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: text }, + { type: "text-end", id: "t1" }, + { + type: "finish", + finishReason: { unified: "stop", raw: "stop" }, + usage: { + inputTokens: { total: 10, noCache: 10, cacheRead: undefined, cacheWrite: undefined }, + outputTokens: { total: 10, text: 10, reasoning: undefined }, + }, + }, + ]; + return simulateReadableStream({ chunks }); +} + +const originalCwd = process.cwd(); +let workdir: string; + +beforeEach(async () => { + workdir = await realpath(await mkdtemp(path.join(tmpdir(), "skills-runtime-"))); + process.chdir(workdir); + + // Bundled skill layout + const skillDir = path.join(workdir, ".trigger", "skills", "demo"); + await mkdir(path.join(skillDir, "scripts"), { recursive: true }); + await mkdir(path.join(skillDir, "references"), { recursive: true }); + + await writeFile( + path.join(skillDir, "SKILL.md"), + `---\nname: demo\ndescription: Demo skill for tests.\n---\n\n# Demo\n\nUse scripts/hello.sh to say hello.\n` + ); + + const scriptPath = path.join(skillDir, "scripts", "hello.sh"); + await writeFile(scriptPath, `#!/usr/bin/env bash\necho "hi from $1"\n`); + await chmod(scriptPath, 0o755); + + await writeFile(path.join(skillDir, "references", "notes.txt"), "Reference note.\n"); +}); + +afterEach(async () => { + process.chdir(originalCwd); + await rm(workdir, { recursive: true, force: true }); +}); + +describe("chat.skills runtime integration", () => { + it("injects skills preamble into the system prompt", async () => { + let capturedSystem: string | undefined; + + const model = new MockLanguageModelV3({ + doStream: async (opts) => { + const system = opts.prompt.find((m) => m.role === "system"); + capturedSystem = system ? JSON.stringify(system.content) : undefined; + return { stream: textStream("ok") }; + }, + }); + + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + + const agent = chat.agent({ + id: "skills-runtime.system-prompt", + onChatStart: async () => { + chat.skills.set([await skill.local()]); + }, + run: async ({ messages, signal }) => { + return streamText({ + model, + messages, + abortSignal: signal, + ...chat.toStreamTextOptions(), + }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "t1" }); + try { + await harness.sendMessage(userMessage("hi")); + await new Promise((r) => setTimeout(r, 20)); + expect(capturedSystem).toContain("Available skills"); + expect(capturedSystem).toContain("demo: Demo skill for tests"); + } finally { + await harness.close(); + } + }); + + it("auto-wires loadSkill / readFile / bash tools", async () => { + let capturedToolNames: string[] = []; + + const model = new MockLanguageModelV3({ + doStream: async (opts) => { + capturedToolNames = (opts.tools ?? []).map((t) => t.name); + return { stream: textStream("ok") }; + }, + }); + + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + + const agent = chat.agent({ + id: "skills-runtime.auto-tools", + onChatStart: async () => { + chat.skills.set([await skill.local()]); + }, + run: async ({ messages, signal }) => { + return streamText({ + model, + messages, + abortSignal: signal, + ...chat.toStreamTextOptions(), + }); + }, + }); + + const harness = mockChatAgent(agent, { chatId: "t2" }); + try { + await harness.sendMessage(userMessage("hi")); + await new Promise((r) => setTimeout(r, 20)); + expect(capturedToolNames).toEqual(expect.arrayContaining(["loadSkill", "readFile", "bash"])); + } finally { + await harness.close(); + } + }); +}); + +describe("buildSkillTools — direct execute", () => { + it("loadSkill returns body + path for a known skill", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const resolved = await skill.local(); + const tools = buildSkillTools([resolved]); + + const out = await (tools.loadSkill as any).execute({ name: "demo" }); + expect(out.name).toBe("demo"); + expect(out.body).toContain("# Demo"); + expect(out.path).toBe(resolved.path); + }); + + it("loadSkill returns an error for an unknown skill", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const tools = buildSkillTools([await skill.local()]); + + const out = await (tools.loadSkill as any).execute({ name: "missing" }); + expect(out.error).toContain('Skill "missing" not found'); + }); + + it("readFile reads a bundled reference", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const tools = buildSkillTools([await skill.local()]); + + const out = await (tools.readFile as any).execute({ + skill: "demo", + path: "references/notes.txt", + }); + expect(out.content).toBe("Reference note.\n"); + }); + + it("readFile rejects path traversal", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const tools = buildSkillTools([await skill.local()]); + + const out = await (tools.readFile as any).execute({ + skill: "demo", + path: "../../../../etc/passwd", + }); + expect(out.error).toMatch(/escapes the skill directory/); + }); + + it("readFile rejects absolute paths", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const tools = buildSkillTools([await skill.local()]); + + const out = await (tools.readFile as any).execute({ + skill: "demo", + path: "/etc/passwd", + }); + expect(out.error).toMatch(/must be relative/); + }); + + it("bash runs a bundled script and captures stdout", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const tools = buildSkillTools([await skill.local()]); + + const out = await (tools.bash as any).execute({ + skill: "demo", + command: "bash scripts/hello.sh world", + }); + expect(out.exitCode).toBe(0); + expect(out.stdout).toContain("hi from world"); + }); + + it("bash reports non-zero exit code", async () => { + const skill = defineSkill({ id: "demo", path: "./skills/demo" }); + const tools = buildSkillTools([await skill.local()]); + + const out = await (tools.bash as any).execute({ + skill: "demo", + command: "exit 7", + }); + expect(out.exitCode).toBe(7); + }); +}); diff --git a/packages/trigger-sdk/test/wire-shape.test.ts b/packages/trigger-sdk/test/wire-shape.test.ts new file mode 100644 index 00000000000..fd24fe00bba --- /dev/null +++ b/packages/trigger-sdk/test/wire-shape.test.ts @@ -0,0 +1,249 @@ +// The slim wire payload shape is the contract between the transport +// (`TriggerChatTransport.sendMessages` etc.) and the agent runtime. This +// test locks the shape down at the type and JSON-roundtrip level so a +// future change either holds the wire stable or breaks loudly. +// +// Plan F.1: verify `messages` is gone, `message`/`headStartMessages` are +// typed correctly. See plan section A.1. + +import "../src/v3/test/index.js"; + +import type { UIMessage } from "ai"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import type { ChatInputChunk, ChatTaskWirePayload } from "../src/v3/ai-shared.js"; + +describe("ChatTaskWirePayload (slim wire shape)", () => { + it("encodes and decodes a submit-message payload through JSON", () => { + const userMsg: UIMessage = { + id: "u-1", + role: "user", + parts: [{ type: "text", text: "hi" }], + }; + const wire: ChatTaskWirePayload = { + message: userMsg, + chatId: "chat-1", + trigger: "submit-message", + metadata: { userId: "u-1" }, + }; + + const encoded = JSON.stringify(wire); + const decoded = JSON.parse(encoded) as ChatTaskWirePayload; + + expect(decoded).toEqual(wire); + expect(decoded.message).toEqual(userMsg); + expect(decoded.trigger).toBe("submit-message"); + }); + + it("encodes and decodes a regenerate-message payload (no message body)", () => { + const wire: ChatTaskWirePayload = { + chatId: "chat-1", + trigger: "regenerate-message", + metadata: undefined, + }; + + const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload; + + expect(decoded.trigger).toBe("regenerate-message"); + expect(decoded.message).toBeUndefined(); + expect(decoded.headStartMessages).toBeUndefined(); + }); + + it("encodes and decodes a handover-prepare payload with headStartMessages", () => { + const history: UIMessage[] = [ + { + id: "u-1", + role: "user", + parts: [{ type: "text", text: "first" }], + }, + { + id: "a-1", + role: "assistant", + parts: [{ type: "text", text: "ok" }], + }, + ]; + const wire: ChatTaskWirePayload = { + headStartMessages: history, + chatId: "chat-1", + trigger: "handover-prepare", + }; + + const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload; + + expect(decoded.headStartMessages).toEqual(history); + expect(decoded.message).toBeUndefined(); + }); + + it("encodes and decodes a preload payload (no message, no headStartMessages)", () => { + const wire: ChatTaskWirePayload = { + chatId: "chat-1", + trigger: "preload", + }; + + const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload; + expect(decoded.trigger).toBe("preload"); + expect(decoded.message).toBeUndefined(); + expect(decoded.headStartMessages).toBeUndefined(); + }); + + it("encodes and decodes a close payload", () => { + const wire: ChatTaskWirePayload = { + chatId: "chat-1", + trigger: "close", + }; + const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload; + expect(decoded.trigger).toBe("close"); + }); + + it("encodes and decodes an action payload (carries `action`, no message)", () => { + const wire: ChatTaskWirePayload = { + chatId: "chat-1", + trigger: "action", + action: { type: "undo" }, + }; + const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload; + + expect(decoded.trigger).toBe("action"); + expect(decoded.action).toEqual({ type: "undo" }); + expect(decoded.message).toBeUndefined(); + }); + + it("preserves continuation / previousRunId / sessionId across the wire", () => { + const wire: ChatTaskWirePayload = { + message: { + id: "u-2", + role: "user", + parts: [{ type: "text", text: "continued" }], + }, + chatId: "chat-1", + trigger: "submit-message", + continuation: true, + previousRunId: "run_abc", + sessionId: "sess_xyz", + idleTimeoutInSeconds: 42, + }; + const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload; + + expect(decoded.continuation).toBe(true); + expect(decoded.previousRunId).toBe("run_abc"); + expect(decoded.sessionId).toBe("sess_xyz"); + expect(decoded.idleTimeoutInSeconds).toBe(42); + }); + + it("preserves a tool-approval-responded assistant message in `message`", () => { + // The HITL slim-wire path sends an assistant message with + // `state: "approval-responded"` tool parts in `message`, not the + // full chain. The agent merges by id. + const approvalMsg: UIMessage = { + id: "a-1", + role: "assistant", + parts: [ + { + type: "tool-search", + toolCallId: "tc-42", + state: "output-available", + input: { q: "x" }, + output: { hits: 7 }, + } as never, + ], + }; + const wire: ChatTaskWirePayload = { + message: approvalMsg, + chatId: "chat-1", + trigger: "submit-message", + }; + + const decoded = JSON.parse(JSON.stringify(wire)) as ChatTaskWirePayload; + expect(decoded.message).toEqual(approvalMsg); + }); +}); + +describe("ChatTaskWirePayload (compile-time shape)", () => { + it("does NOT have a `messages` array field (slim wire removed it)", () => { + // If a future edit reintroduces `messages: TMessage[]`, this assertion + // forces a compile error rather than letting the wire silently grow + // back. + type WirePayloadKeys = keyof ChatTaskWirePayload; + expectTypeOf().not.toEqualTypeOf<"messages" | Exclude>(); + // Also confirm the absence at the value level — a payload literal + // with `messages` would be a TS error if uncommented: + // + // const bad: ChatTaskWirePayload = { messages: [], chatId: "x", trigger: "submit-message" }; + // + // Leaving as a comment for clarity; the type assertion above is the + // load-bearing check. + }); + + it("has `message?: UIMessage` (singular, optional)", () => { + expectTypeOf().toEqualTypeOf(); + }); + + it("has `headStartMessages?: UIMessage[]` (escape hatch)", () => { + expectTypeOf().toEqualTypeOf< + UIMessage[] | undefined + >(); + }); + + it("requires `chatId: string` and `trigger: `", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + | "submit-message" + | "regenerate-message" + | "preload" + | "close" + | "action" + | "handover-prepare" + >(); + }); +}); + +describe("ChatInputChunk envelope", () => { + it("wraps a wire payload in `kind: \"message\"` shape", () => { + const userMsg: UIMessage = { + id: "u-1", + role: "user", + parts: [{ type: "text", text: "hello" }], + }; + const chunk: ChatInputChunk = { + kind: "message", + payload: { + message: userMsg, + chatId: "chat-1", + trigger: "submit-message", + }, + }; + + const decoded = JSON.parse(JSON.stringify(chunk)) as ChatInputChunk; + expect(decoded.kind).toBe("message"); + if (decoded.kind === "message") { + expect(decoded.payload.message).toEqual(userMsg); + } + }); + + it("supports `kind: \"stop\"` records (no payload)", () => { + const chunk: ChatInputChunk = { kind: "stop", message: "user-canceled" }; + const decoded = JSON.parse(JSON.stringify(chunk)) as ChatInputChunk; + expect(decoded.kind).toBe("stop"); + if (decoded.kind === "stop") { + expect(decoded.message).toBe("user-canceled"); + } + }); + + it("supports `kind: \"handover\"` records (with partialAssistantMessage)", () => { + const chunk: ChatInputChunk = { + kind: "handover", + partialAssistantMessage: [ + { role: "assistant", content: [{ type: "text", text: "partial" }] }, + ], + messageId: "a-1", + isFinal: false, + }; + const decoded = JSON.parse(JSON.stringify(chunk)) as ChatInputChunk; + expect(decoded.kind).toBe("handover"); + }); + + it("supports `kind: \"handover-skip\"` records", () => { + const chunk: ChatInputChunk = { kind: "handover-skip" }; + const decoded = JSON.parse(JSON.stringify(chunk)) as ChatInputChunk; + expect(decoded.kind).toBe("handover-skip"); + }); +}); diff --git a/patches/streamdown@2.5.0.patch b/patches/streamdown@2.5.0.patch new file mode 100644 index 00000000000..c50eb41313f --- /dev/null +++ b/patches/streamdown@2.5.0.patch @@ -0,0 +1,14 @@ +diff --git a/dist/chunk-BO2N2NFS.js b/dist/chunk-BO2N2NFS.js +index 98d8387007f38ae92dd47bcde203f37cce43db9e..e2c7b7734d862e59716692d76cb17b4e0ee17d76 100644 +--- a/dist/chunk-BO2N2NFS.js ++++ b/dist/chunk-BO2N2NFS.js +@@ -1,7 +1,7 @@ + "use client"; +-import {createContext,memo,useContext,useMemo,lazy,isValidElement,useId,useTransition,useRef,useEffect,useState,cloneElement,createElement,useCallback,Suspense}from'react';import {harden}from'rehype-harden';import Yo from'rehype-raw';import kn,{defaultSchema}from'rehype-sanitize';import $s from'remark-gfm';import Ws from'remend';import {visitParents,SKIP}from'unist-util-visit-parents';import {clsx}from'clsx';import {twMerge}from'tailwind-merge';import {jsx,jsxs,Fragment}from'react/jsx-runtime';import {createPortal}from'react-dom';import {toJsxRuntime}from'hast-util-to-jsx-runtime';import {urlAttributes}from'html-url-attributes';import gs from'remark-parse';import bs from'remark-rehype';import {unified}from'unified';import {visit}from'unist-util-visit';import {Lexer}from'marked';var Bn=300,An="300px",On=500;function Rt(e={}){let{immediate:t=false,debounceDelay:o=Bn,rootMargin:n=An,idleTimeout:r=On}=e,[s,a]=useState(false),l=useRef(null),i=useRef(null),d=useRef(null),c=useMemo(()=>u=>{let f=Date.now();return window.setTimeout(()=>{u({didTimeout:false,timeRemaining:()=>Math.max(0,50-(Date.now()-f))});},1)},[]),p=useMemo(()=>typeof window!="undefined"&&window.requestIdleCallback?(u,f)=>window.requestIdleCallback(u,f):c,[c]),m=useMemo(()=>typeof window!="undefined"&&window.cancelIdleCallback?u=>window.cancelIdleCallback(u):u=>{clearTimeout(u);},[]);return useEffect(()=>{if(t){a(true);return}let u=l.current;if(!u)return;i.current&&(clearTimeout(i.current),i.current=null),d.current&&(m(d.current),d.current=null);let f=()=>{i.current&&(clearTimeout(i.current),i.current=null),d.current&&(m(d.current),d.current=null);},h=v=>{d.current=p(w=>{w.timeRemaining()>0||w.didTimeout?(a(true),v.disconnect()):d.current=p(()=>{a(true),v.disconnect();},{timeout:r/2});},{timeout:r});},b=v=>{f(),i.current=window.setTimeout(()=>{var M,H;let w=v.takeRecords();(w.length===0||(H=(M=w.at(-1))==null?void 0:M.isIntersecting)!=null&&H)&&h(v);},o);},g=(v,w)=>{v.isIntersecting?b(w):f();},T=new IntersectionObserver(v=>{for(let w of v)g(w,T);},{rootMargin:n,threshold:0});return T.observe(u),()=>{i.current&&clearTimeout(i.current),d.current&&m(d.current),T.disconnect();}},[t,o,n,r,m,p]),{shouldRender:s,containerRef:l}}var St=/\s/,Fn=/^\s+$/,zn=new Set(["code","pre","svg","math","annotation"]),_n=e=>typeof e=="object"&&e!==null&&"type"in e&&e.type==="element",qn=e=>e.some(t=>_n(t)&&zn.has(t.tagName)),$n=e=>{let t=[],o="",n=false;for(let r of e){let s=St.test(r);s!==n&&o&&(t.push(o),o=""),o+=r,n=s;}return o&&t.push(o),t},Wn=e=>{let t=[],o="";for(let n of e)St.test(n)?o+=n:(o&&(t.push(o),o=""),t.push(n));return o&&t.push(o),t},Zn=(e,t,o,n,r,s)=>{let a=`--sd-animation:sd-${t};--sd-duration:${r?0:o}ms;--sd-easing:${n}`;return s&&(a+=`;--sd-delay:${s}ms`),{type:"element",tagName:"span",properties:{"data-sd-animate":true,style:a},children:[{type:"text",value:e}]}},Xn=(e,t,o,n,r)=>{let s=t.at(-1);if(!(s&&"children"in s))return;if(qn(t))return SKIP;let a=s,l=a.children.indexOf(e);if(l===-1)return;let i=e.value;if(!i.trim()){r.count+=i.length;return}let d=o.sep==="char"?Wn(i):$n(i),c=n.prevContentLength,p=d.map(m=>{let u=r.count;if(r.count+=m.length,Fn.test(m))return {type:"text",value:m};let f=c>0&&uc=>{let p={count:0,newIndex:0};visitParents(c,"text",(m,u)=>Xn(m,u,t,o,p)),o.lastRenderCharCount=p.count,o.prevContentLength=0;};return Object.defineProperty(r,"name",{value:`rehypeAnimate$${n}`}),{name:"animate",type:"animate",rehypePlugin:r,setPrevContentLength(c){o.prevContentLength=c;},getLastRenderCharCount(){let c=o.lastRenderCharCount;return o.lastRenderCharCount=0,c}}}be();var et=createContext(false),tt=()=>useContext(et);var he=(...e)=>twMerge(clsx(e)),Gn=(e,t)=>{if(!e||!t)return t;let o=`${e}:`;return t.split(/\s+/).filter(Boolean).map(n=>n.startsWith(o)?n:`${e}:${n}`).join(" ")},Dt=e=>e?(...t)=>Gn(e,twMerge(clsx(t))):he,W=(e,t,o)=>{let n=typeof t=="string"&&o.startsWith("text/csv")?"\uFEFF":"",r=typeof t=="string"?new Blob([n+t],{type:o}):t,s=URL.createObjectURL(r),a=document.createElement("a");a.href=s,a.download=e,document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(s);};var Ee=createContext(he),y=()=>useContext(Ee);var tr=he("block","before:content-[counter(line)]","before:inline-block","before:[counter-increment:line]","before:w-6","before:mr-4","before:text-[13px]","before:text-right","before:text-muted-foreground/50","before:font-mono","before:select-none"),or=e=>{let t={};for(let o of e.split(";")){let n=o.indexOf(":");if(n>0){let r=o.slice(0,n).trim(),s=o.slice(n+1).trim();r&&s&&(t[r]=s);}}return t},At=memo(({children:e,result:t,language:o,className:n,startLine:r,lineNumbers:s=true,...a})=>{let l=y(),i=useMemo(()=>l(tr),[l]),d=useMemo(()=>{let c={};return t.bg&&(c["--sdm-bg"]=t.bg),t.fg&&(c["--sdm-fg"]=t.fg),t.rootStyle&&Object.assign(c,or(t.rootStyle)),c},[t.bg,t.fg,t.rootStyle]);return jsx("div",{className:l(n,"overflow-x-auto rounded-md border border-border bg-background p-4 text-sm"),"data-language":o,"data-streamdown":"code-block-body",...a,children:jsx("pre",{className:l(n,"bg-[var(--sdm-bg,inherit]","dark:bg-[var(--shiki-dark-bg,var(--sdm-bg,inherit)]"),style:d,children:jsx("code",{className:s?l("[counter-increment:line_0] [counter-reset:line]"):void 0,style:s&&r&&r>1?{counterReset:`line ${r-1}`}:void 0,children:t.tokens.map((c,p)=>jsx("span",{className:s?i:void 0,children:c.length===0||c.length===1&&c[0].content===""?` ++import {createContext,memo,useContext,useMemo,lazy,isValidElement,useId,useTransition,useRef,useEffect,useState,cloneElement,createElement,useCallback,Suspense}from'react';import {HighlightedCodeBlockBody as _HB}from'./highlighted-body-OFNGDK62.js';import {harden}from'rehype-harden';import Yo from'rehype-raw';import kn,{defaultSchema}from'rehype-sanitize';import $s from'remark-gfm';import Ws from'remend';import {visitParents,SKIP}from'unist-util-visit-parents';import {clsx}from'clsx';import {twMerge}from'tailwind-merge';import {jsx,jsxs,Fragment}from'react/jsx-runtime';import {createPortal}from'react-dom';import {toJsxRuntime}from'hast-util-to-jsx-runtime';import {urlAttributes}from'html-url-attributes';import gs from'remark-parse';import bs from'remark-rehype';import {unified}from'unified';import {visit}from'unist-util-visit';import {Lexer}from'marked';var Bn=300,An="300px",On=500;function Rt(e={}){let{immediate:t=false,debounceDelay:o=Bn,rootMargin:n=An,idleTimeout:r=On}=e,[s,a]=useState(false),l=useRef(null),i=useRef(null),d=useRef(null),c=useMemo(()=>u=>{let f=Date.now();return window.setTimeout(()=>{u({didTimeout:false,timeRemaining:()=>Math.max(0,50-(Date.now()-f))});},1)},[]),p=useMemo(()=>typeof window!="undefined"&&window.requestIdleCallback?(u,f)=>window.requestIdleCallback(u,f):c,[c]),m=useMemo(()=>typeof window!="undefined"&&window.cancelIdleCallback?u=>window.cancelIdleCallback(u):u=>{clearTimeout(u);},[]);return useEffect(()=>{if(t){a(true);return}let u=l.current;if(!u)return;i.current&&(clearTimeout(i.current),i.current=null),d.current&&(m(d.current),d.current=null);let f=()=>{i.current&&(clearTimeout(i.current),i.current=null),d.current&&(m(d.current),d.current=null);},h=v=>{d.current=p(w=>{w.timeRemaining()>0||w.didTimeout?(a(true),v.disconnect()):d.current=p(()=>{a(true),v.disconnect();},{timeout:r/2});},{timeout:r});},b=v=>{f(),i.current=window.setTimeout(()=>{var M,H;let w=v.takeRecords();(w.length===0||(H=(M=w.at(-1))==null?void 0:M.isIntersecting)!=null&&H)&&h(v);},o);},g=(v,w)=>{v.isIntersecting?b(w):f();},T=new IntersectionObserver(v=>{for(let w of v)g(w,T);},{rootMargin:n,threshold:0});return T.observe(u),()=>{i.current&&clearTimeout(i.current),d.current&&m(d.current),T.disconnect();}},[t,o,n,r,m,p]),{shouldRender:s,containerRef:l}}var St=/\s/,Fn=/^\s+$/,zn=new Set(["code","pre","svg","math","annotation"]),_n=e=>typeof e=="object"&&e!==null&&"type"in e&&e.type==="element",qn=e=>e.some(t=>_n(t)&&zn.has(t.tagName)),$n=e=>{let t=[],o="",n=false;for(let r of e){let s=St.test(r);s!==n&&o&&(t.push(o),o=""),o+=r,n=s;}return o&&t.push(o),t},Wn=e=>{let t=[],o="";for(let n of e)St.test(n)?o+=n:(o&&(t.push(o),o=""),t.push(n));return o&&t.push(o),t},Zn=(e,t,o,n,r,s)=>{let a=`--sd-animation:sd-${t};--sd-duration:${r?0:o}ms;--sd-easing:${n}`;return s&&(a+=`;--sd-delay:${s}ms`),{type:"element",tagName:"span",properties:{"data-sd-animate":true,style:a},children:[{type:"text",value:e}]}},Xn=(e,t,o,n,r)=>{let s=t.at(-1);if(!(s&&"children"in s))return;if(qn(t))return SKIP;let a=s,l=a.children.indexOf(e);if(l===-1)return;let i=e.value;if(!i.trim()){r.count+=i.length;return}let d=o.sep==="char"?Wn(i):$n(i),c=n.prevContentLength,p=d.map(m=>{let u=r.count;if(r.count+=m.length,Fn.test(m))return {type:"text",value:m};let f=c>0&&uc=>{let p={count:0,newIndex:0};visitParents(c,"text",(m,u)=>Xn(m,u,t,o,p)),o.lastRenderCharCount=p.count,o.prevContentLength=0;};return Object.defineProperty(r,"name",{value:`rehypeAnimate$${n}`}),{name:"animate",type:"animate",rehypePlugin:r,setPrevContentLength(c){o.prevContentLength=c;},getLastRenderCharCount(){let c=o.lastRenderCharCount;return o.lastRenderCharCount=0,c}}}be();var et=createContext(false),tt=()=>useContext(et);var he=(...e)=>twMerge(clsx(e)),Gn=(e,t)=>{if(!e||!t)return t;let o=`${e}:`;return t.split(/\s+/).filter(Boolean).map(n=>n.startsWith(o)?n:`${e}:${n}`).join(" ")},Dt=e=>e?(...t)=>Gn(e,twMerge(clsx(t))):he,W=(e,t,o)=>{let n=typeof t=="string"&&o.startsWith("text/csv")?"\uFEFF":"",r=typeof t=="string"?new Blob([n+t],{type:o}):t,s=URL.createObjectURL(r),a=document.createElement("a");a.href=s,a.download=e,document.body.appendChild(a),a.click(),document.body.removeChild(a),URL.revokeObjectURL(s);};var Ee=createContext(he),y=()=>useContext(Ee);var tr=he("block","before:content-[counter(line)]","before:inline-block","before:[counter-increment:line]","before:w-6","before:mr-4","before:text-[13px]","before:text-right","before:text-muted-foreground/50","before:font-mono","before:select-none"),or=e=>{let t={};for(let o of e.split(";")){let n=o.indexOf(":");if(n>0){let r=o.slice(0,n).trim(),s=o.slice(n+1).trim();r&&s&&(t[r]=s);}}return t},At=memo(({children:e,result:t,language:o,className:n,startLine:r,lineNumbers:s=true,...a})=>{let l=y(),i=useMemo(()=>l(tr),[l]),d=useMemo(()=>{let c={};return t.bg&&(c["--sdm-bg"]=t.bg),t.fg&&(c["--sdm-fg"]=t.fg),t.rootStyle&&Object.assign(c,or(t.rootStyle)),c},[t.bg,t.fg,t.rootStyle]);return jsx("div",{className:l(n,"overflow-x-auto rounded-md border border-border bg-background p-4 text-sm"),"data-language":o,"data-streamdown":"code-block-body",...a,children:jsx("pre",{className:l(n,"bg-[var(--sdm-bg,inherit]","dark:bg-[var(--shiki-dark-bg,var(--sdm-bg,inherit)]"),style:d,children:jsx("code",{className:s?l("[counter-increment:line_0] [counter-reset:line]"):void 0,style:s&&r&&r>1?{counterReset:`line ${r-1}`}:void 0,children:t.tokens.map((c,p)=>jsx("span",{className:s?i:void 0,children:c.length===0||c.length===1&&c[0].content===""?` + `:c.map((m,u)=>{let f={},h=!!m.bgColor;if(m.color&&(f["--sdm-c"]=m.color),m.bgColor&&(f["--sdm-tbg"]=m.bgColor),m.htmlStyle)for(let[b,g]of Object.entries(m.htmlStyle))b==="color"?f["--sdm-c"]=g:b==="background-color"?(f["--sdm-tbg"]=g,h=true):f[b]=g;return jsx("span",{className:l("text-[var(--sdm-c,inherit)]","dark:text-[var(--shiki-dark,var(--sdm-c,inherit))]",h&&"bg-[var(--sdm-tbg)]",h&&"dark:bg-[var(--shiki-dark-bg,var(--sdm-tbg))]"),style:f,...m.htmlAttrs,children:m.content},u)})},p))})})})},(e,t)=>e.result===t.result&&e.language===t.language&&e.className===t.className&&e.startLine===t.startLine&&e.lineNumbers===t.lineNumbers);var ot=({className:e,language:t,style:o,isIncomplete:n,...r})=>{let s=y();return jsx("div",{className:s("my-4 flex w-full flex-col gap-2 rounded-xl border border-border bg-sidebar p-2",e),"data-incomplete":n||void 0,"data-language":t,"data-streamdown":"code-block",style:{contentVisibility:"auto",containIntrinsicSize:"auto 200px",...o},...r})};var nt=createContext({code:""}),He=()=>useContext(nt);var rt=({language:e})=>{let t=y();return jsx("div",{className:t("flex h-8 items-center text-muted-foreground text-xs"),"data-language":e,"data-streamdown":"code-block-header",children:jsx("span",{className:t("ml-1 font-mono lowercase"),children:e})})};var lr=e=>{let t=e.length;for(;t>0&&e[t-1]===` +-`;)t--;return e.slice(0,t)},cr=lazy(()=>import('./highlighted-body-OFNGDK62.js').then(e=>({default:e.HighlightedCodeBlockBody}))),st=({code:e,language:t,className:o,children:n,isIncomplete:r=false,startLine:s,lineNumbers:a,...l})=>{let i=y(),d=useMemo(()=>lr(e),[e]),c=useMemo(()=>({bg:"transparent",fg:"inherit",tokens:d.split(` ++`;)t--;return e.slice(0,t)},cr=_HB,st=({code:e,language:t,className:o,children:n,isIncomplete:r=false,startLine:s,lineNumbers:a,...l})=>{let i=y(),d=useMemo(()=>lr(e),[e]),c=useMemo(()=>({bg:"transparent",fg:"inherit",tokens:d.split(` + `).map(p=>[{content:p,color:"inherit",bgColor:"transparent",htmlStyle:{},offset:0}])}),[d]);return jsx(nt.Provider,{value:{code:e},children:jsxs(ot,{isIncomplete:r,language:t,children:[jsx(rt,{language:t}),n?jsx("div",{className:i("pointer-events-none sticky top-2 z-10 -mt-10 flex h-8 items-center justify-end"),children:jsx("div",{className:i("pointer-events-auto flex shrink-0 items-center gap-2 rounded-md border border-sidebar bg-sidebar/80 px-1.5 py-1 supports-[backdrop-filter]:bg-sidebar/70 supports-[backdrop-filter]:backdrop-blur"),"data-streamdown":"code-block-actions",children:n})}):null,jsx(Suspense,{fallback:jsx(At,{className:o,language:t,lineNumbers:a,result:c,startLine:s,...l}),children:jsx(cr,{className:o,code:d,language:t,lineNumbers:a,raw:c,startLine:s,...l})})]})})};var jt=e=>jsx("svg",{color:"currentColor",height:16,strokeLinejoin:"round",viewBox:"0 0 16 16",width:16,...e,children:jsx("path",{clipRule:"evenodd",d:"M15.5607 3.99999L15.0303 4.53032L6.23744 13.3232C5.55403 14.0066 4.44599 14.0066 3.76257 13.3232L4.2929 12.7929L3.76257 13.3232L0.969676 10.5303L0.439346 9.99999L1.50001 8.93933L2.03034 9.46966L4.82323 12.2626C4.92086 12.3602 5.07915 12.3602 5.17678 12.2626L13.9697 3.46966L14.5 2.93933L15.5607 3.99999Z",fill:"currentColor",fillRule:"evenodd"})}),Ft=e=>jsx("svg",{color:"currentColor",height:16,strokeLinejoin:"round",viewBox:"0 0 16 16",width:16,...e,children:jsx("path",{clipRule:"evenodd",d:"M2.75 0.5C1.7835 0.5 1 1.2835 1 2.25V9.75C1 10.7165 1.7835 11.5 2.75 11.5H3.75H4.5V10H3.75H2.75C2.61193 10 2.5 9.88807 2.5 9.75V2.25C2.5 2.11193 2.61193 2 2.75 2H8.25C8.38807 2 8.5 2.11193 8.5 2.25V3H10V2.25C10 1.2835 9.2165 0.5 8.25 0.5H2.75ZM7.75 4.5C6.7835 4.5 6 5.2835 6 6.25V13.75C6 14.7165 6.7835 15.5 7.75 15.5H13.25C14.2165 15.5 15 14.7165 15 13.75V6.25C15 5.2835 14.2165 4.5 13.25 4.5H7.75ZM7.5 6.25C7.5 6.11193 7.61193 6 7.75 6H13.25C13.3881 6 13.5 6.11193 13.5 6.25V13.75C13.5 13.8881 13.3881 14 13.25 14H7.75C7.61193 14 7.5 13.8881 7.5 13.75V6.25Z",fill:"currentColor",fillRule:"evenodd"})}),zt=e=>jsx("svg",{color:"currentColor",height:16,strokeLinejoin:"round",viewBox:"0 0 16 16",width:16,...e,children:jsx("path",{clipRule:"evenodd",d:"M8.75 1V1.75V8.68934L10.7197 6.71967L11.25 6.18934L12.3107 7.25L11.7803 7.78033L8.70711 10.8536C8.31658 11.2441 7.68342 11.2441 7.29289 10.8536L4.21967 7.78033L3.68934 7.25L4.75 6.18934L5.28033 6.71967L7.25 8.68934V1.75V1H8.75ZM13.5 9.25V13.5H2.5V9.25V8.5H1V9.25V14C1 14.5523 1.44771 15 2 15H14C14.5523 15 15 14.5523 15 14V9.25V8.5H13.5V9.25Z",fill:"currentColor",fillRule:"evenodd"})}),_t=e=>jsxs("svg",{color:"currentColor",height:16,strokeLinejoin:"round",viewBox:"0 0 16 16",width:16,...e,children:[jsx("path",{d:"M8 0V4",stroke:"currentColor",strokeWidth:"1.5"}),jsx("path",{d:"M8 16V12",opacity:"0.5",stroke:"currentColor",strokeWidth:"1.5"}),jsx("path",{d:"M3.29773 1.52783L5.64887 4.7639",opacity:"0.9",stroke:"currentColor",strokeWidth:"1.5"}),jsx("path",{d:"M12.7023 1.52783L10.3511 4.7639",opacity:"0.1",stroke:"currentColor",strokeWidth:"1.5"}),jsx("path",{d:"M12.7023 14.472L10.3511 11.236",opacity:"0.4",stroke:"currentColor",strokeWidth:"1.5"}),jsx("path",{d:"M3.29773 14.472L5.64887 11.236",opacity:"0.6",stroke:"currentColor",strokeWidth:"1.5"}),jsx("path",{d:"M15.6085 5.52783L11.8043 6.7639",opacity:"0.2",stroke:"currentColor",strokeWidth:"1.5"}),jsx("path",{d:"M0.391602 10.472L4.19583 9.23598",opacity:"0.7",stroke:"currentColor",strokeWidth:"1.5"}),jsx("path",{d:"M15.6085 10.4722L11.8043 9.2361",opacity:"0.3",stroke:"currentColor",strokeWidth:"1.5"}),jsx("path",{d:"M0.391602 5.52783L4.19583 6.7639",opacity:"0.8",stroke:"currentColor",strokeWidth:"1.5"})]}),qt=e=>jsx("svg",{color:"currentColor",height:16,strokeLinejoin:"round",viewBox:"0 0 16 16",width:16,...e,children:jsx("path",{clipRule:"evenodd",d:"M1 5.25V6H2.5V5.25V2.5H5.25H6V1H5.25H2C1.44772 1 1 1.44772 1 2V5.25ZM5.25 14.9994H6V13.4994H5.25H2.5V10.7494V9.99939H1V10.7494V13.9994C1 14.5517 1.44772 14.9994 2 14.9994H5.25ZM15 10V10.75V14C15 14.5523 14.5523 15 14 15H10.75H10V13.5H10.75H13.5V10.75V10H15ZM10.75 1H10V2.5H10.75H13.5V5.25V6H15V5.25V2C15 1.44772 14.5523 1 14 1H10.75Z",fill:"currentColor",fillRule:"evenodd"})}),$t=e=>jsx("svg",{color:"currentColor",height:16,strokeLinejoin:"round",viewBox:"0 0 16 16",width:16,...e,children:jsx("path",{clipRule:"evenodd",d:"M13.5 8C13.5 4.96643 11.0257 2.5 7.96452 2.5C5.42843 2.5 3.29365 4.19393 2.63724 6.5H5.25H6V8H5.25H0.75C0.335787 8 0 7.66421 0 7.25V2.75V2H1.5V2.75V5.23347C2.57851 2.74164 5.06835 1 7.96452 1C11.8461 1 15 4.13001 15 8C15 11.87 11.8461 15 7.96452 15C5.62368 15 3.54872 13.8617 2.27046 12.1122L1.828 11.5066L3.03915 10.6217L3.48161 11.2273C4.48831 12.6051 6.12055 13.5 7.96452 13.5C11.0257 13.5 13.5 11.0336 13.5 8Z",fill:"currentColor",fillRule:"evenodd"})}),Wt=e=>jsx("svg",{color:"currentColor",height:16,strokeLinejoin:"round",viewBox:"0 0 16 16",width:16,...e,children:jsx("path",{clipRule:"evenodd",d:"M12.4697 13.5303L13 14.0607L14.0607 13L13.5303 12.4697L9.06065 7.99999L13.5303 3.53032L14.0607 2.99999L13 1.93933L12.4697 2.46966L7.99999 6.93933L3.53032 2.46966L2.99999 1.93933L1.93933 2.99999L2.46966 3.53032L6.93933 7.99999L2.46966 12.4697L1.93933 13L2.99999 14.0607L3.53032 13.5303L7.99999 9.06065L12.4697 13.5303Z",fill:"currentColor",fillRule:"evenodd"})}),Zt=e=>jsx("svg",{color:"currentColor",height:16,strokeLinejoin:"round",viewBox:"0 0 16 16",width:16,...e,children:jsx("path",{clipRule:"evenodd",d:"M13.5 10.25V13.25C13.5 13.3881 13.3881 13.5 13.25 13.5H2.75C2.61193 13.5 2.5 13.3881 2.5 13.25L2.5 2.75C2.5 2.61193 2.61193 2.5 2.75 2.5H5.75H6.5V1H5.75H2.75C1.7835 1 1 1.7835 1 2.75V13.25C1 14.2165 1.7835 15 2.75 15H13.25C14.2165 15 15 14.2165 15 13.25V10.25V9.5H13.5V10.25ZM9 1H9.75H14.2495C14.6637 1 14.9995 1.33579 14.9995 1.75V6.25V7H13.4995V6.25V3.56066L8.53033 8.52978L8 9.06011L6.93934 7.99945L7.46967 7.46912L12.4388 2.5H9.75H9V1Z",fill:"currentColor",fillRule:"evenodd"})}),Xt=e=>jsx("svg",{color:"currentColor",height:16,strokeLinejoin:"round",viewBox:"0 0 16 16",width:16,...e,children:jsx("path",{clipRule:"evenodd",d:"M1.5 6.5C1.5 3.73858 3.73858 1.5 6.5 1.5C9.26142 1.5 11.5 3.73858 11.5 6.5C11.5 9.26142 9.26142 11.5 6.5 11.5C3.73858 11.5 1.5 9.26142 1.5 6.5ZM6.5 0C2.91015 0 0 2.91015 0 6.5C0 10.0899 2.91015 13 6.5 13C8.02469 13 9.42677 12.475 10.5353 11.596L13.9697 15.0303L14.5 15.5607L15.5607 14.5L15.0303 13.9697L11.596 10.5353C12.475 9.42677 13 8.02469 13 6.5C13 2.91015 10.0899 0 6.5 0ZM4.125 5.875H4.75H5.875V4.75V4.125H7.125V4.75V5.875H8.25H8.875V7.125H8.25H7.125V8.25V8.875H5.875V8.25V7.125H4.75H4.125V5.875Z",fill:"currentColor",fillRule:"evenodd"})}),Jt=e=>jsx("svg",{color:"currentColor",height:16,strokeLinejoin:"round",viewBox:"0 0 16 16",width:16,...e,children:jsx("path",{clipRule:"evenodd",d:"M1.5 6.5C1.5 3.73858 3.73858 1.5 6.5 1.5C9.26142 1.5 11.5 3.73858 11.5 6.5C11.5 9.26142 9.26142 11.5 6.5 11.5C3.73858 11.5 1.5 9.26142 1.5 6.5ZM6.5 0C2.91015 0 0 2.91015 0 6.5C0 10.0899 2.91015 13 6.5 13C8.02469 13 9.42677 12.475 10.5353 11.596L13.9697 15.0303L14.5 15.5607L15.5607 14.5L15.0303 13.9697L11.596 10.5353C12.475 9.42677 13 8.02469 13 6.5C13 2.91015 10.0899 0 6.5 0ZM4.125 5.875H4.75H8.25H8.875V7.125H8.25H4.75H4.125V5.875Z",fill:"currentColor",fillRule:"evenodd"})});var we={CheckIcon:jt,CopyIcon:Ft,DownloadIcon:zt,ExternalLinkIcon:Zt,Loader2Icon:_t,Maximize2Icon:qt,RotateCcwIcon:$t,XIcon:Wt,ZoomInIcon:Xt,ZoomOutIcon:Jt},Ut=createContext(we),fr=(e,t)=>{if(e===t)return true;if(!(e&&t))return e===t;let o=Object.keys(e),n=Object.keys(t);return o.length!==n.length?false:o.every(r=>e[r]===t[r])},at=({icons:e,children:t})=>{let o=useRef(e),n=useRef(e?{...we,...e}:we);fr(o.current,e)||(o.current=e,n.current=e?{...we,...e}:we);let r=n.current;return jsx(Ut.Provider,{value:r,children:t})},L=()=>useContext(Ut);var De={copyCode:"Copy Code",downloadFile:"Download file",downloadDiagram:"Download diagram",downloadDiagramAsSvg:"Download diagram as SVG",downloadDiagramAsPng:"Download diagram as PNG",downloadDiagramAsMmd:"Download diagram as MMD",viewFullscreen:"View fullscreen",exitFullscreen:"Exit fullscreen",mermaidFormatSvg:"SVG",mermaidFormatPng:"PNG",mermaidFormatMmd:"MMD",copyTable:"Copy table",copyTableAsMarkdown:"Copy table as Markdown",copyTableAsCsv:"Copy table as CSV",copyTableAsTsv:"Copy table as TSV",downloadTable:"Download table",downloadTableAsCsv:"Download table as CSV",downloadTableAsMarkdown:"Download table as Markdown",tableFormatMarkdown:"Markdown",tableFormatCsv:"CSV",tableFormatTsv:"TSV",imageNotAvailable:"Image not available",downloadImage:"Download image",openExternalLink:"Open external link?",externalLinkWarning:"You're about to visit an external website.",close:"Close",copyLink:"Copy link",copied:"Copied",openLink:"Open link"},Be=createContext(De),D=()=>useContext(Be);var Ae=({onCopy:e,onError:t,timeout:o=2e3,children:n,className:r,code:s,...a})=>{let l=y(),[i,d]=useState(false),c=useRef(0),{code:p}=He(),{isAnimating:m}=useContext(R),u=D(),f=s!=null?s:p,h=async()=>{var T;if(typeof window=="undefined"||!((T=navigator==null?void 0:navigator.clipboard)!=null&&T.writeText)){t==null||t(new Error("Clipboard API not available"));return}try{i||(await navigator.clipboard.writeText(f),d(!0),e==null||e(),c.current=window.setTimeout(()=>d(!1),o));}catch(v){t==null||t(v);}};useEffect(()=>()=>{window.clearTimeout(c.current);},[]);let b=L(),g=i?b.CheckIcon:b.CopyIcon;return jsx("button",{className:l("cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50",r),"data-streamdown":"code-block-copy-button",disabled:m,onClick:h,title:u.copyCode,type:"button",...a,children:n!=null?n:jsx(g,{size:14})})};var Yt={"1c":"1c","1c-query":"1cq",abap:"abap","actionscript-3":"as",ada:"ada",adoc:"adoc","angular-html":"html","angular-ts":"ts",apache:"conf",apex:"cls",apl:"apl",applescript:"applescript",ara:"ara",asciidoc:"adoc",asm:"asm",astro:"astro",awk:"awk",ballerina:"bal",bash:"sh",bat:"bat",batch:"bat",be:"be",beancount:"beancount",berry:"berry",bibtex:"bib",bicep:"bicep",blade:"blade.php",bsl:"bsl",c:"c","c#":"cs","c++":"cpp",cadence:"cdc",cairo:"cairo",cdc:"cdc",clarity:"clar",clj:"clj",clojure:"clj","closure-templates":"soy",cmake:"cmake",cmd:"cmd",cobol:"cob",codeowners:"CODEOWNERS",codeql:"ql",coffee:"coffee",coffeescript:"coffee","common-lisp":"lisp",console:"sh",coq:"v",cpp:"cpp",cql:"cql",crystal:"cr",cs:"cs",csharp:"cs",css:"css",csv:"csv",cue:"cue",cypher:"cql",d:"d",dart:"dart",dax:"dax",desktop:"desktop",diff:"diff",docker:"dockerfile",dockerfile:"dockerfile",dotenv:"env","dream-maker":"dm",edge:"edge",elisp:"el",elixir:"ex",elm:"elm","emacs-lisp":"el",erb:"erb",erl:"erl",erlang:"erl",f:"f","f#":"fs",f03:"f03",f08:"f08",f18:"f18",f77:"f77",f90:"f90",f95:"f95",fennel:"fnl",fish:"fish",fluent:"ftl",for:"for","fortran-fixed-form":"f","fortran-free-form":"f90",fs:"fs",fsharp:"fs",fsl:"fsl",ftl:"ftl",gdresource:"tres",gdscript:"gd",gdshader:"gdshader",genie:"gs",gherkin:"feature","git-commit":"gitcommit","git-rebase":"gitrebase",gjs:"js",gleam:"gleam","glimmer-js":"js","glimmer-ts":"ts",glsl:"glsl",gnuplot:"plt",go:"go",gql:"gql",graphql:"graphql",groovy:"groovy",gts:"gts",hack:"hack",haml:"haml",handlebars:"hbs",haskell:"hs",haxe:"hx",hbs:"hbs",hcl:"hcl",hjson:"hjson",hlsl:"hlsl",hs:"hs",html:"html","html-derivative":"html",http:"http",hxml:"hxml",hy:"hy",imba:"imba",ini:"ini",jade:"jade",java:"java",javascript:"js",jinja:"jinja",jison:"jison",jl:"jl",js:"js",json:"json",json5:"json5",jsonc:"jsonc",jsonl:"jsonl",jsonnet:"jsonnet",jssm:"jssm",jsx:"jsx",julia:"jl",kotlin:"kt",kql:"kql",kt:"kt",kts:"kts",kusto:"kql",latex:"tex",lean:"lean",lean4:"lean",less:"less",liquid:"liquid",lisp:"lisp",lit:"lit",llvm:"ll",log:"log",logo:"logo",lua:"lua",luau:"luau",make:"mak",makefile:"mak",markdown:"md",marko:"marko",matlab:"m",md:"md",mdc:"mdc",mdx:"mdx",mediawiki:"wiki",mermaid:"mmd",mips:"s",mipsasm:"s",mmd:"mmd",mojo:"mojo",move:"move",nar:"nar",narrat:"narrat",nextflow:"nf",nf:"nf",nginx:"conf",nim:"nim",nix:"nix",nu:"nu",nushell:"nu",objc:"m","objective-c":"m","objective-cpp":"mm",ocaml:"ml",pascal:"pas",perl:"pl",perl6:"p6",php:"php",plsql:"pls",po:"po",polar:"polar",postcss:"pcss",pot:"pot",potx:"potx",powerquery:"pq",powershell:"ps1",prisma:"prisma",prolog:"pl",properties:"properties",proto:"proto",protobuf:"proto",ps:"ps",ps1:"ps1",pug:"pug",puppet:"pp",purescript:"purs",py:"py",python:"py",ql:"ql",qml:"qml",qmldir:"qmldir",qss:"qss",r:"r",racket:"rkt",raku:"raku",razor:"cshtml",rb:"rb",reg:"reg",regex:"regex",regexp:"regexp",rel:"rel",riscv:"s",rs:"rs",rst:"rst",ruby:"rb",rust:"rs",sas:"sas",sass:"sass",scala:"scala",scheme:"scm",scss:"scss",sdbl:"sdbl",sh:"sh",shader:"shader",shaderlab:"shader",shell:"sh",shellscript:"sh",shellsession:"sh",smalltalk:"st",solidity:"sol",soy:"soy",sparql:"rq",spl:"spl",splunk:"spl",sql:"sql","ssh-config":"config",stata:"do",styl:"styl",stylus:"styl",svelte:"svelte",swift:"swift","system-verilog":"sv",systemd:"service",talon:"talon",talonscript:"talon",tasl:"tasl",tcl:"tcl",templ:"templ",terraform:"tf",tex:"tex",tf:"tf",tfvars:"tfvars",toml:"toml",ts:"ts","ts-tags":"ts",tsp:"tsp",tsv:"tsv",tsx:"tsx",turtle:"ttl",twig:"twig",typ:"typ",typescript:"ts",typespec:"tsp",typst:"typ",v:"v",vala:"vala",vb:"vb",verilog:"v",vhdl:"vhdl",vim:"vim",viml:"vim",vimscript:"vim",vue:"vue","vue-html":"html","vue-vine":"vine",vy:"vy",vyper:"vy",wasm:"wasm",wenyan:"wy",wgsl:"wgsl",wiki:"wiki",wikitext:"wiki",wit:"wit",wl:"wl",wolfram:"wl",xml:"xml",xsl:"xsl",yaml:"yaml",yml:"yml",zenscript:"zs",zig:"zig",zsh:"zsh",\u6587\u8A00:"wy"},it=({onDownload:e,onError:t,language:o,children:n,className:r,code:s,...a})=>{let l=y(),{code:i}=He(),{isAnimating:d}=useContext(R),c=D(),p=L(),m=s!=null?s:i,f=`file.${o&&o in Yt?Yt[o]:"txt"}`,h="text/plain",b=()=>{try{W(f,m,h),e==null||e();}catch(g){t==null||t(g);}};return jsx("button",{className:l("cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50",r),"data-streamdown":"code-block-download-button",disabled:d,onClick:b,title:c.downloadFile,type:"button",...a,children:n!=null?n:jsx(p.DownloadIcon,{size:14})})};var Oe=()=>{let{Loader2Icon:e}=L(),t=y();return jsxs("div",{className:t("w-full divide-y divide-border overflow-hidden rounded-xl border border-border"),children:[jsx("div",{className:t("h-[46px] w-full bg-muted/80")}),jsx("div",{className:t("flex w-full items-center justify-center p-4"),children:jsx(e,{className:t("size-4 animate-spin")})})]})};var Mr=/\.[^/.]+$/,oo=({node:e,className:t,src:o,alt:n,onLoad:r,onError:s,...a})=>{let{DownloadIcon:l}=L(),i=y(),d=useRef(null),[c,p]=useState(false),[m,u]=useState(false),f=D(),h=a.width!=null||a.height!=null,b=(c||h)&&!m,g=m&&!h;useEffect(()=>{let P=d.current;if(P!=null&&P.complete){let M=P.naturalWidth>0;p(M),u(!M);}},[]);let T=useCallback(P=>{p(true),u(false),r==null||r(P);},[r]),v=useCallback(P=>{p(false),u(true),s==null||s(P);},[s]),w=async()=>{if(o)try{let M=await(await fetch(o)).blob(),S=new URL(o,window.location.origin).pathname.split("/").pop()||"",F=S.split(".").pop(),j=S.includes(".")&&F!==void 0&&F.length<=4,z="";if(j)z=S;else {let B=M.type,_="png";B.includes("jpeg")||B.includes("jpg")?_="jpg":B.includes("png")?_="png":B.includes("svg")?_="svg":B.includes("gif")?_="gif":B.includes("webp")&&(_="webp"),z=`${(n||S||"image").replace(Mr,"")}.${_}`;}W(z,M,M.type);}catch(P){window.open(o,"_blank");}};return o?jsxs("div",{className:i("group relative my-4 inline-block"),"data-streamdown":"image-wrapper",children:[jsx("img",{alt:n,className:i("max-w-full rounded-lg",g&&"hidden",t),"data-streamdown":"image",onError:v,onLoad:T,ref:d,src:o,...a}),g&&jsx("span",{className:i("text-muted-foreground text-xs italic"),"data-streamdown":"image-fallback",children:f.imageNotAvailable}),jsx("div",{className:i("pointer-events-none absolute inset-0 hidden rounded-lg bg-black/10 group-hover:block")}),b&&jsx("button",{className:i("absolute right-2 bottom-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-border bg-background/90 shadow-sm backdrop-blur-sm transition-all duration-200 hover:bg-background","opacity-0 group-hover:opacity-100"),onClick:w,title:f.downloadImage,type:"button",children:jsx(l,{size:14})})]}):null};var ke=0,le=()=>{ke+=1,ke===1&&(document.body.style.overflow="hidden");},ce=()=>{ke=Math.max(0,ke-1),ke===0&&(document.body.style.overflow="");};var so=({url:e,isOpen:t,onClose:o,onConfirm:n})=>{let{CheckIcon:r,CopyIcon:s,ExternalLinkIcon:a,XIcon:l}=L(),i=y(),[d,c]=useState(false),p=D(),m=useCallback(async()=>{try{await navigator.clipboard.writeText(e),c(!0),setTimeout(()=>c(!1),2e3);}catch(f){}},[e]),u=useCallback(()=>{n(),o();},[n,o]);return useEffect(()=>{if(t){le();let f=h=>{h.key==="Escape"&&o();};return document.addEventListener("keydown",f),()=>{document.removeEventListener("keydown",f),ce();}}},[t,o]),t?jsx("div",{className:i("fixed inset-0 z-50 flex items-center justify-center bg-background/50 backdrop-blur-sm"),"data-streamdown":"link-safety-modal",onClick:o,onKeyDown:f=>{f.key==="Escape"&&o();},role:"button",tabIndex:0,children:jsxs("div",{className:i("relative mx-4 flex w-full max-w-md flex-col gap-4 rounded-xl border bg-background p-6 shadow-lg"),onClick:f=>f.stopPropagation(),onKeyDown:f=>f.stopPropagation(),role:"presentation",children:[jsx("button",{className:i("absolute top-4 right-4 rounded-md p-1 text-muted-foreground transition-all hover:bg-muted hover:text-foreground"),onClick:o,title:p.close,type:"button",children:jsx(l,{size:16})}),jsxs("div",{className:i("flex flex-col gap-2"),children:[jsxs("div",{className:i("flex items-center gap-2 font-semibold text-lg"),children:[jsx(a,{size:20}),jsx("span",{children:p.openExternalLink})]}),jsx("p",{className:i("text-muted-foreground text-sm"),children:p.externalLinkWarning})]}),jsx("div",{className:i("break-all rounded-md bg-muted p-3 font-mono text-sm",e.length>100&&"max-h-32 overflow-y-auto"),children:e}),jsxs("div",{className:i("flex gap-2"),children:[jsx("button",{className:i("flex flex-1 items-center justify-center gap-2 rounded-md border bg-background px-4 py-2 font-medium text-sm transition-all hover:bg-muted"),onClick:m,type:"button",children:d?jsxs(Fragment,{children:[jsx(r,{size:14}),jsx("span",{children:p.copied})]}):jsxs(Fragment,{children:[jsx(s,{size:14}),jsx("span",{children:p.copyLink})]})}),jsxs("button",{className:i("flex flex-1 items-center justify-center gap-2 rounded-md bg-primary px-4 py-2 font-medium text-primary-foreground text-sm transition-all hover:bg-primary/90"),onClick:u,type:"button",children:[jsx(a,{size:14}),jsx("span",{children:p.openLink})]})]})]})}):null};var Ve=createContext(null),ct=()=>useContext(Ve),Li=()=>{var t;let e=ct();return (t=e==null?void 0:e.code)!=null?t:null},de=()=>{var t;let e=ct();return (t=e==null?void 0:e.mermaid)!=null?t:null};var ao=e=>{var o;let t=ct();return t!=null&&t.renderers&&e&&(o=t.renderers.find(n=>Array.isArray(n.language)?n.language.includes(e):n.language===e))!=null?o:null};var io=(e,t)=>{var n;let o=(n=void 0)!=null?n:5;return new Promise((r,s)=>{let a="data:image/svg+xml;base64,"+btoa(unescape(encodeURIComponent(e))),l=new Image;l.crossOrigin="anonymous",l.onload=()=>{let i=document.createElement("canvas"),d=l.width*o,c=l.height*o;i.width=d,i.height=c;let p=i.getContext("2d");if(!p){s(new Error("Failed to create 2D canvas context for PNG export"));return}p.drawImage(l,0,0,d,c),i.toBlob(m=>{if(!m){s(new Error("Failed to create PNG blob"));return}r(m);},"image/png");},l.onerror=()=>s(new Error("Failed to load SVG image")),l.src=a;})};var co=({chart:e,children:t,className:o,onDownload:n,config:r,onError:s})=>{let a=y(),[l,i]=useState(false),d=useRef(null),{isAnimating:c}=useContext(R),p=L(),m=de(),u=D(),f=async h=>{try{if(h==="mmd"){W("diagram.mmd",e,"text/plain"),i(!1),n==null||n(h);return}if(!m){s==null||s(new Error("Mermaid plugin not available"));return}let b=m.getMermaid(r),g=e.split("").reduce((w,P)=>(w<<5)-w+P.charCodeAt(0)|0,0),T=`mermaid-${Math.abs(g)}-${Date.now()}-${Math.random().toString(36).substring(2,9)}`,{svg:v}=await b.render(T,e);if(!v){s==null||s(new Error("SVG not found. Please wait for the diagram to render."));return}if(h==="svg"){W("diagram.svg",v,"image/svg+xml"),i(!1),n==null||n(h);return}if(h==="png"){let w=await io(v);W("diagram.png",w,"image/png"),n==null||n(h),i(!1);return}}catch(b){s==null||s(b);}};return useEffect(()=>{let h=b=>{let g=b.composedPath();d.current&&!g.includes(d.current)&&i(false);};return document.addEventListener("mousedown",h),()=>{document.removeEventListener("mousedown",h);}},[]),jsxs("div",{className:a("relative"),ref:d,children:[jsx("button",{className:a("cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50",o),disabled:c,onClick:()=>i(!l),title:u.downloadDiagram,type:"button",children:t!=null?t:jsx(p.DownloadIcon,{size:14})}),l?jsxs("div",{className:a("absolute top-full right-0 z-10 mt-1 min-w-[120px] overflow-hidden rounded-md border border-border bg-background shadow-lg"),children:[jsx("button",{className:a("w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40"),onClick:()=>f("svg"),title:u.downloadDiagramAsSvg,type:"button",children:u.mermaidFormatSvg}),jsx("button",{className:a("w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40"),onClick:()=>f("png"),title:u.downloadDiagramAsPng,type:"button",children:u.mermaidFormatPng}),jsx("button",{className:a("w-full px-3 py-2 text-left text-sm transition-colors hover:bg-muted/40"),onClick:()=>f("mmd"),title:u.downloadDiagramAsMmd,type:"button",children:u.mermaidFormatMmd})]}):null]})};var fo=({chart:e,config:t,onFullscreen:o,onExit:n,className:r,...s})=>{let{Maximize2Icon:a,XIcon:l}=L(),i=y(),[d,c]=useState(false),{isAnimating:p,controls:m}=useContext(R),u=D(),f=(()=>{if(typeof m=="boolean")return m;let b=m.mermaid;return b===false?false:b===true||b===void 0?true:b.panZoom!==false})(),h=()=>{c(!d);};return useEffect(()=>{if(d){le();let b=g=>{g.key==="Escape"&&c(false);};return document.addEventListener("keydown",b),()=>{document.removeEventListener("keydown",b),ce();}}},[d]),useEffect(()=>{d?o==null||o():n&&n();},[d,o,n]),jsxs(Fragment,{children:[jsx("button",{className:i("cursor-pointer p-1 text-muted-foreground transition-all hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50",r),disabled:p,onClick:h,title:u.viewFullscreen,type:"button",...s,children:jsx(a,{size:14})}),d?createPortal(jsxs("div",{className:i("fixed inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm"),onClick:h,onKeyDown:b=>{b.key==="Escape"&&h();},role:"button",tabIndex:0,children:[jsx("button",{className:i("absolute top-4 right-4 z-10 rounded-md p-2 text-muted-foreground transition-all hover:bg-muted hover:text-foreground"),onClick:h,title:u.exitFullscreen,type:"button",children:jsx(l,{size:20})}),jsx("div",{className:i("flex size-full items-center justify-center p-4"),onClick:b=>b.stopPropagation(),onKeyDown:b=>b.stopPropagation(),role:"presentation",children:jsx(po,{chart:e,className:i("size-full [&_svg]:h-auto [&_svg]:w-auto"),config:t,fullscreen:true,showControls:f})})]}),document.body):null]})};var ue=e=>{var s,a;let t=[],o=[],n=e.querySelectorAll("thead th");for(let l of n)t.push(((s=l.textContent)==null?void 0:s.trim())||"");let r=e.querySelectorAll("tbody tr");for(let l of r){let i=[],d=l.querySelectorAll("td");for(let c of d)i.push(((a=c.textContent)==null?void 0:a.trim())||"");o.push(i);}return {headers:t,rows:o}},ne=e=>{let{headers:t,rows:o}=e,n=l=>{let i=false,d=false;for(let c of l){if(c==='"'){i=true,d=true;break}(c===","||c===` + `)&&(i=true);}return i?d?`"${l.replace(/"/g,'""')}"`:`"${l}"`:l},r=t.length>0?o.length+1:o.length,s=new Array(r),a=0;t.length>0&&(s[a]=t.map(n).join(","),a+=1);for(let l of o)s[a]=l.map(n).join(","),a+=1;return s.join(` + `)},dt=e=>{let{headers:t,rows:o}=e,n=l=>{let i=false;for(let c of l)if(c===" "||c===` diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58ef3508d2c..17f73d9a252 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -67,6 +67,9 @@ patchedDependencies: redlock@5.0.0-beta.2: hash: 52b17ac642f5f9776bf05e2229f4dd79588d37b0039d835c7684c478464632f2 path: patches/redlock@5.0.0-beta.2.patch + streamdown@2.5.0: + hash: 36211d09153a59c880b6a2bce2a0a0f011c99c73c20c8ceca78cc77e47623f06 + path: patches/streamdown@2.5.0.patch importers: @@ -99,7 +102,7 @@ importers: version: 20.14.14 '@vitest/coverage-v8': specifier: 3.1.4 - version: 3.1.4(vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3)) + version: 3.1.4(vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3)) autoprefixer: specifier: ^10.4.12 version: 10.4.13(postcss@8.5.10) @@ -129,7 +132,7 @@ importers: version: 4.0.5(typescript@5.5.4) vitest: specifier: 3.1.4 - version: 3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3) + version: 3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3) apps/coordinator: dependencies: @@ -242,6 +245,9 @@ importers: '@ai-sdk/openai': specifier: ^1.3.23 version: 1.3.23(zod@3.25.76) + '@ai-sdk/react': + specifier: ^3.0.0 + version: 3.0.170(react@18.2.0)(zod@3.25.76) '@ariakit/react': specifier: ^0.4.6 version: 0.4.6(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -503,6 +509,9 @@ importers: '@socket.io/redis-adapter': specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.4(bufferutil@4.0.9)) + '@streamdown/code': + specifier: ^1.1.1 + version: 1.1.1(react@18.2.0) '@tabler/icons-react': specifier: ^3.36.1 version: 3.36.1(react@18.2.0) @@ -816,8 +825,8 @@ importers: specifier: ^7.4.0 version: 7.5.0(@aws-sdk/client-sqs@3.454.0) streamdown: - specifier: ^1.4.0 - version: 1.4.0(@types/react@18.2.69)(react@18.2.0) + specifier: ^2.5.0 + version: 2.5.0(patch_hash=36211d09153a59c880b6a2bce2a0a0f011c99c73c20c8ceca78cc77e47623f06)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) superjson: specifier: ^2.2.1 version: 2.2.1 @@ -869,7 +878,7 @@ importers: version: link:../../internal-packages/testcontainers '@remix-run/dev': specifier: 2.17.4 - version: 2.17.4(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/serve@2.17.4(typescript@5.5.4))(@types/node@20.14.14)(bufferutil@4.0.9)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(typescript@5.5.4)(vite@6.4.2(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3))(yaml@2.8.3) + version: 2.17.4(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/serve@2.17.4(typescript@5.5.4))(@types/node@20.14.14)(bufferutil@4.0.9)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(typescript@5.5.4)(vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3))(yaml@2.8.3) '@remix-run/eslint-config': specifier: 2.17.4 version: 2.17.4(eslint@8.31.0)(react@18.2.0)(typescript@5.5.4) @@ -1182,7 +1191,7 @@ importers: version: link:../testcontainers vitest: specifier: 3.1.4 - version: 3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) + version: 3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) internal-packages/otlp-importer: dependencies: @@ -1362,7 +1371,7 @@ importers: version: 5.5.4 vitest: specifier: 3.1.4 - version: 3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) + version: 3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) internal-packages/testcontainers: dependencies: @@ -1510,7 +1519,7 @@ importers: version: 0.0.1-cli.2.80.0 '@modelcontextprotocol/sdk': specifier: ^1.25.2 - version: 1.25.2(hono@4.11.8)(supports-color@10.0.0)(zod@3.25.76) + version: 1.25.2(hono@4.12.15)(supports-color@10.0.0)(zod@3.25.76) '@opentelemetry/api': specifier: 1.9.0 version: 1.9.0 @@ -2044,7 +2053,7 @@ importers: version: 6.0.1 tsup: specifier: ^8.4.0 - version: 8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.10)(tsx@4.17.0)(typescript@5.5.4)(yaml@2.8.3) + version: 8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.6.1)(postcss@8.5.10)(tsx@4.17.0)(typescript@5.5.4)(yaml@2.8.3) tsx: specifier: 4.17.0 version: 4.17.0 @@ -2158,6 +2167,9 @@ importers: evt: specifier: ^2.4.13 version: 2.4.13 + react: + specifier: ^18.0 || ^19.0 + version: 18.3.1 slug: specifier: ^6.0.0 version: 6.1.0 @@ -2171,12 +2183,18 @@ importers: specifier: ^8.11.0 version: 8.12.0(bufferutil@4.0.9) devDependencies: + '@ai-sdk/provider': + specifier: 3.0.8 + version: 3.0.8 '@arethetypeswrong/cli': specifier: ^0.15.4 version: 0.15.4 '@types/debug': specifier: ^4.1.7 version: 4.1.7 + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 '@types/slug': specifier: ^5.0.3 version: 5.0.3 @@ -2184,8 +2202,8 @@ importers: specifier: ^8.5.3 version: 8.5.4 ai: - specifier: ^6.0.0 - version: 6.0.3(zod@3.25.76) + specifier: ^6.0.116 + version: 6.0.116(zod@3.25.76) encoding: specifier: ^0.1.13 version: 0.1.13 @@ -2205,6 +2223,94 @@ importers: specifier: 3.25.76 version: 3.25.76 + references/ai-chat: + dependencies: + '@ai-sdk/anthropic': + specifier: ^3.0.0 + version: 3.0.71(zod@3.25.76) + '@ai-sdk/openai': + specifier: ^3.0.0 + version: 3.0.41(zod@3.25.76) + '@ai-sdk/react': + specifier: ^3.0.0 + version: 3.0.170(react@19.1.0)(zod@3.25.76) + '@e2b/code-interpreter': + specifier: ^2.4.0 + version: 2.4.1 + '@prisma/adapter-pg': + specifier: ^7.4.2 + version: 7.8.0 + '@prisma/client': + specifier: ^7.4.2 + version: 7.8.0(prisma@7.8.0(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@11.10.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4))(typescript@5.5.4) + '@trigger.dev/sdk': + specifier: workspace:* + version: link:../../packages/trigger-sdk + ai: + specifier: ^6.0.0 + version: 6.0.116(zod@3.25.76) + next: + specifier: 15.3.3 + version: 15.3.3(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + pg: + specifier: ^8.16.3 + version: 8.16.3 + react: + specifier: ^19.0.0 + version: 19.1.0 + react-dom: + specifier: ^19.0.0 + version: 19.1.0(react@19.1.0) + serialize-error: + specifier: ^11.0.3 + version: 11.0.3 + streamdown: + specifier: ^2.3.0 + version: 2.5.0(patch_hash=36211d09153a59c880b6a2bce2a0a0f011c99c73c20c8ceca78cc77e47623f06)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + turndown: + specifier: ^7.2.2 + version: 7.2.4 + zod: + specifier: 3.25.76 + version: 3.25.76 + devDependencies: + '@ai-sdk/provider': + specifier: 3.0.8 + version: 3.0.8 + '@tailwindcss/postcss': + specifier: ^4 + version: 4.0.17 + '@trigger.dev/build': + specifier: workspace:* + version: link:../../packages/build + '@types/node': + specifier: 20.14.14 + version: 20.14.14 + '@types/react': + specifier: ^19 + version: 19.2.14 + '@types/react-dom': + specifier: ^19 + version: 19.0.4(@types/react@19.2.14) + '@types/turndown': + specifier: ^5.0.6 + version: 5.0.6 + prisma: + specifier: ^7.4.2 + version: 7.8.0(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@11.10.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4) + tailwindcss: + specifier: ^4 + version: 4.0.17 + trigger.dev: + specifier: workspace:* + version: link:../../packages/cli-v3 + typescript: + specifier: 5.5.4 + version: 5.5.4 + vitest: + specifier: ^3.1.4 + version: 3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) + references/bun-catalog: dependencies: '@trigger.dev/sdk': @@ -2968,6 +3074,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4 + '@ai-sdk/anthropic@3.0.71': + resolution: {integrity: sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@1.0.6': resolution: {integrity: sha512-JuSj1MtTr4vw2VBBth4wlbciQnQIV0o1YV9qGLFA+r85nR5H+cJp3jaYE0nprqfzC9rYG8w9c6XGHB3SDKgcgA==} engines: {node: '>=18'} @@ -2980,6 +3092,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@3.0.104': + resolution: {integrity: sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/gateway@3.0.2': resolution: {integrity: sha512-giJEg9ob45htbu3iautK+2kvplY2JnTj7ir4wZzYSQWvqGatWfBBfDuNCU5wSJt9BCGjymM5ZS9ziD42JGCZBw==} engines: {node: '>=18'} @@ -3082,6 +3200,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider-utils@4.0.23': + resolution: {integrity: sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + '@ai-sdk/provider@0.0.26': resolution: {integrity: sha512-dQkfBDs2lTYpKM8389oopPdQgIU007GQyCbuPPrV+K6MtSII3HBfE0stUIMXUb44L+LK1t6GXPP7wjSzjO6uKg==} engines: {node: '>=18'} @@ -3142,6 +3266,12 @@ packages: zod: optional: true + '@ai-sdk/react@3.0.170': + resolution: {integrity: sha512-YUDn+mK0c8iUz14rCBf1A0zg6SV5b5aSVUz+azF1bdBd1SFXVI19dKYR+PQSpZY+0+z+zs252AAsacUqiO98Kw==} + engines: {node: '>=18'} + peerDependencies: + react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1 + '@ai-sdk/ui-utils@1.0.0': resolution: {integrity: sha512-oXBDIM/0niWeTWyw77RVl505dNxBUDLLple7bTsqo2d3i1UKwGlzBUX8XqZsh7GbY7I6V05nlG0Y8iGlWxv1Aw==} engines: {node: '>=18'} @@ -4036,6 +4166,9 @@ packages: '@bufbuild/protobuf@1.10.0': resolution: {integrity: sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==} + '@bufbuild/protobuf@2.12.0': + resolution: {integrity: sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==} + '@bufbuild/protobuf@2.2.5': resolution: {integrity: sha512-/g5EzJifw5GF8aren8wZ/G5oMuPoGeS6MQD3ca8ddcvdXR5UELUfdTZITCGNhNXynY/AYl3Z4plmxdj/tRl/hQ==} @@ -4100,18 +4233,33 @@ packages: '@chevrotain/cst-dts-gen@11.0.3': resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==} + '@chevrotain/cst-dts-gen@12.0.0': + resolution: {integrity: sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg==} + '@chevrotain/gast@11.0.3': resolution: {integrity: sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q==} + '@chevrotain/gast@12.0.0': + resolution: {integrity: sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ==} + '@chevrotain/regexp-to-ast@11.0.3': resolution: {integrity: sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA==} + '@chevrotain/regexp-to-ast@12.0.0': + resolution: {integrity: sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA==} + '@chevrotain/types@11.0.3': resolution: {integrity: sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ==} + '@chevrotain/types@12.0.0': + resolution: {integrity: sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA==} + '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@chevrotain/utils@12.0.0': + resolution: {integrity: sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA==} + '@clack/core@0.5.0': resolution: {integrity: sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==} @@ -4284,6 +4432,10 @@ packages: resolution: {integrity: sha512-T54U7WS56ou11ytoxlYllBRBM+MYBpOvVZQa1p1qE4KDZBKJd9m1kAA0PqHjy5T6f/tSv4w5wlq4oyExl4QLLA==} engines: {node: '>=18'} + '@e2b/code-interpreter@2.4.1': + resolution: {integrity: sha512-9T+NcQPtB3Utm0KAB3vdhx6vC1X+Y3cV6oydk2GnVuEqn0lUAY+9/8WdHuh/0l4L15aO2JynufP5oQwub7gDhw==} + engines: {node: '>=20'} + '@effect/platform@0.63.2': resolution: {integrity: sha512-b39pVFw0NGo/tXjGShW7Yg0M+kG7bRrFR6+dQ3aIu99ePTkTp6bGb/kDB7n+dXsFFdIqHsQGYESeYcOQngxdFQ==} peerDependencies: @@ -4302,6 +4454,20 @@ packages: '@electric-sql/client@1.0.14': resolution: {integrity: sha512-LtPAfeMxXRiYS0hyDQ5hue2PjljUiK9stvzsVyVb4nwxWQxfOWTSF42bHTs/o5i3x1T4kAQ7mwHpxa4A+f8X7Q==} + '@electric-sql/pglite-socket@0.1.1': + resolution: {integrity: sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1': + resolution: {integrity: sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==} + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': + resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==} + '@electric-sql/react@0.3.5': resolution: {integrity: sha512-qPrlF3BsRg5L8zAn1sLGzc3pkswfEHyQI3lNOu7Xllv1DBx85RvHR1zgGGPAUfC8iwyWupQu9pFPE63GdbeuhA==} peerDependencies: @@ -5375,6 +5541,12 @@ packages: peerDependencies: hono: ^4 + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hono/node-server@1.19.9': resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} engines: {node: '>=18.14.1'} @@ -5705,6 +5877,10 @@ packages: resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + '@isaacs/fs-minipass@4.0.1': resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} @@ -5780,6 +5956,9 @@ packages: '@kubernetes/client-node@1.0.0': resolution: {integrity: sha512-a8NSvFDSHKFZ0sR1hbPSf8IDFNJwctEU5RodSCNiq/moRXWmrdmqhb1RRQzF+l+TSBaDgHw3YsYNxxE92STBzw==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} @@ -5824,9 +6003,15 @@ packages: '@mermaid-js/parser@0.6.3': resolution: {integrity: sha512-lnjOhe7zyHjc+If7yT4zoedx2vo4sHaTmtkl1+or8BRTnCtDmcTpAjpzDSfCZrshM5bCoz0GyidzadJAH1xobA==} + '@mermaid-js/parser@1.1.0': + resolution: {integrity: sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw==} + '@microsoft/fetch-event-source@2.0.1': resolution: {integrity: sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA==} + '@mixmark-io/domino@2.2.0': + resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/sdk@1.25.2': resolution: {integrity: sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww==} engines: {node: '>=18'} @@ -5867,6 +6052,9 @@ packages: '@next/env@15.2.4': resolution: {integrity: sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==} + '@next/env@15.3.3': + resolution: {integrity: sha512-OdiMrzCl2Xi0VTjiQQUK0Xh7bJHnOuET2s+3V+Y40WJBAXrJeGA3f+I8MZJ/YQ3mVGi5XGR1L66oFlgqXhQ4Vw==} + '@next/env@15.4.8': resolution: {integrity: sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ==} @@ -5891,6 +6079,12 @@ packages: cpu: [arm64] os: [darwin] + '@next/swc-darwin-arm64@15.3.3': + resolution: {integrity: sha512-WRJERLuH+O3oYB4yZNVahSVFmtxRNjNF1I1c34tYMoJb0Pve+7/RaLAJJizyYiFhjYNGHRAE1Ri2Fd23zgDqhg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + '@next/swc-darwin-arm64@15.4.8': resolution: {integrity: sha512-Pf6zXp7yyQEn7sqMxur6+kYcywx5up1J849psyET7/8pG2gQTVMjU3NzgIt8SeEP5to3If/SaWmaA6H6ysBr1A==} engines: {node: '>= 10'} @@ -5921,6 +6115,12 @@ packages: cpu: [x64] os: [darwin] + '@next/swc-darwin-x64@15.3.3': + resolution: {integrity: sha512-XHdzH/yBc55lu78k/XwtuFR/ZXUTcflpRXcsu0nKmF45U96jt1tsOZhVrn5YH+paw66zOANpOnFQ9i6/j+UYvw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + '@next/swc-darwin-x64@15.4.8': resolution: {integrity: sha512-xla6AOfz68a6kq3gRQccWEvFC/VRGJmA/QuSLENSO7CZX5WIEkSz7r1FdXUjtGCQ1c2M+ndUAH7opdfLK1PQbw==} engines: {node: '>= 10'} @@ -5954,6 +6154,13 @@ packages: os: [linux] libc: [glibc] + '@next/swc-linux-arm64-gnu@15.3.3': + resolution: {integrity: sha512-VZ3sYL2LXB8znNGcjhocikEkag/8xiLgnvQts41tq6i+wql63SMS1Q6N8RVXHw5pEUjiof+II3HkDd7GFcgkzw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@next/swc-linux-arm64-gnu@15.4.8': resolution: {integrity: sha512-y3fmp+1Px/SJD+5ntve5QLZnGLycsxsVPkTzAc3zUiXYSOlTPqT8ynfmt6tt4fSo1tAhDPmryXpYKEAcoAPDJw==} engines: {node: '>= 10'} @@ -5989,6 +6196,13 @@ packages: os: [linux] libc: [musl] + '@next/swc-linux-arm64-musl@15.3.3': + resolution: {integrity: sha512-h6Y1fLU4RWAp1HPNJWDYBQ+e3G7sLckyBXhmH9ajn8l/RSMnhbuPBV/fXmy3muMcVwoJdHL+UtzRzs0nXOf9SA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + '@next/swc-linux-arm64-musl@15.4.8': resolution: {integrity: sha512-DX/L8VHzrr1CfwaVjBQr3GWCqNNFgyWJbeQ10Lx/phzbQo3JNAxUok1DZ8JHRGcL6PgMRgj6HylnLNndxn4Z6A==} engines: {node: '>= 10'} @@ -6024,6 +6238,13 @@ packages: os: [linux] libc: [glibc] + '@next/swc-linux-x64-gnu@15.3.3': + resolution: {integrity: sha512-jJ8HRiF3N8Zw6hGlytCj5BiHyG/K+fnTKVDEKvUCyiQ/0r5tgwO7OgaRiOjjRoIx2vwLR+Rz8hQoPrnmFbJdfw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + '@next/swc-linux-x64-gnu@15.4.8': resolution: {integrity: sha512-9fLAAXKAL3xEIFdKdzG5rUSvSiZTLLTCc6JKq1z04DR4zY7DbAPcRvNm3K1inVhTiQCs19ZRAgUerHiVKMZZIA==} engines: {node: '>= 10'} @@ -6059,6 +6280,13 @@ packages: os: [linux] libc: [musl] + '@next/swc-linux-x64-musl@15.3.3': + resolution: {integrity: sha512-HrUcTr4N+RgiiGn3jjeT6Oo208UT/7BuTr7K0mdKRBtTbT4v9zJqCDKO97DUqqoBK1qyzP1RwvrWTvU6EPh/Cw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + '@next/swc-linux-x64-musl@15.4.8': resolution: {integrity: sha512-s45V7nfb5g7dbS7JK6XZDcapicVrMMvX2uYgOHP16QuKH/JA285oy6HcxlKqwUNaFY/UC6EvQ8QZUOo19cBKSA==} engines: {node: '>= 10'} @@ -6091,6 +6319,12 @@ packages: cpu: [arm64] os: [win32] + '@next/swc-win32-arm64-msvc@15.3.3': + resolution: {integrity: sha512-SxorONgi6K7ZUysMtRF3mIeHC5aA3IQLmKFQzU0OuhuUYwpOBc1ypaLJLP5Bf3M9k53KUUUj4vTPwzGvl/NwlQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + '@next/swc-win32-arm64-msvc@15.4.8': resolution: {integrity: sha512-KjgeQyOAq7t/HzAJcWPGA8X+4WY03uSCZ2Ekk98S9OgCFsb6lfBE3dbUzUuEQAN2THbwYgFfxX2yFTCMm8Kehw==} engines: {node: '>= 10'} @@ -6133,6 +6367,12 @@ packages: cpu: [x64] os: [win32] + '@next/swc-win32-x64-msvc@15.3.3': + resolution: {integrity: sha512-4QZG6F8enl9/S2+yIiOiju0iCTFd93d8VC1q9LZS4p/Xuk81W2QDjCFeoogmrWWkAD59z8ZxepBQap2dKS5ruw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@next/swc-win32-x64-msvc@15.4.8': resolution: {integrity: sha512-Exsmf/+42fWVnLMaZHzshukTBxZrSwuuLKFvqhGHJ+mC1AokqieLY/XzAl3jc/CqhXLqLY3RRjkKJ9YnLPcRWg==} engines: {node: '>= 10'} @@ -6807,9 +7047,15 @@ packages: '@prisma/adapter-pg@6.20.0-integration-next.8': resolution: {integrity: sha512-5+ZjSPMzyfDYMmWLH1IaQIOQGa8eJrqEz5A9V4vS4+b6LV6qvCOHjqlnbRQ5IKSNCwFP055SJ54RsPES+0jOyA==} + '@prisma/adapter-pg@7.8.0': + resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==} + '@prisma/client-runtime-utils@6.20.0-integration-next.8': resolution: {integrity: sha512-prENLjPislFvRWDHNgXmg9yzixQYsFPVQGtDv5zIMs4pV2KPdNc5pCiZ3n77hAinvqGJVafASa+eU4TfpVphdA==} + '@prisma/client-runtime-utils@7.8.0': + resolution: {integrity: sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==} + '@prisma/client@4.9.0': resolution: {integrity: sha512-bz6QARw54sWcbyR1lLnF2QHvRW5R/Jxnbbmwh3u+969vUKXtBkXgSgjDA85nji31ZBlf7+FrHDy5x+5ydGyQDg==} engines: {node: '>=14.17'} @@ -6867,6 +7113,18 @@ packages: typescript: optional: true + '@prisma/client@7.8.0': + resolution: {integrity: sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + peerDependencies: + prisma: '*' + typescript: 5.5.4 + peerDependenciesMeta: + prisma: + optional: true + typescript: + optional: true + '@prisma/config@6.14.0': resolution: {integrity: sha512-IwC7o5KNNGhmblLs23swnfBjADkacBb7wvyDXUWLwuvUQciKJZqyecU0jw0d7JRkswrj+XTL8fdr0y2/VerKQQ==} @@ -6879,6 +7137,9 @@ packages: '@prisma/config@6.20.0-integration-next.8': resolution: {integrity: sha512-nwf+tczfiGSn0tnuHmBpnK+wmaYzcC20sn9Zt8BSoJVCewJxf8ASHPxZEGgvFLl05zbCfFtq3rMc6ZnAiYjowg==} + '@prisma/config@7.8.0': + resolution: {integrity: sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==} + '@prisma/debug@4.16.2': resolution: {integrity: sha512-7L7WbG0qNNZYgLpsVB8rCHCXEyHFyIycRlRDNwkVfjQmACC2OW6AWCYCbfdjQhkF/t7+S3njj8wAWAocSs+Brw==} @@ -6894,12 +7155,24 @@ packages: '@prisma/debug@6.20.0-integration-next.8': resolution: {integrity: sha512-PqUUFXf8MDoIrsKMzpF4NYqA3gHE8l/CUWVnYa4hNIbynCcEhvk7iT+6ve0u9w1TiGVUFnIVMuqFGEb2aHCuFw==} + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.8.0': + resolution: {integrity: sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==} + + '@prisma/dev@0.24.3': + resolution: {integrity: sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==} + '@prisma/driver-adapter-utils@6.16.0': resolution: {integrity: sha512-dsRHvEnifJ3xqpMKGBy1jRwR8yc+7Ko4TcHrdTQJIfq6NYN2gNoOf0k91hcbzs5AH19wDxjuHXCveklWq5AJdA==} '@prisma/driver-adapter-utils@6.20.0-integration-next.8': resolution: {integrity: sha512-TXpFugr3sCl2bHechoG3p9mvlq2Z3GgA0Cp73lUOEWQyUuoG8NW/4UA56Ax1r5fBUAs9hKbr20Ld6wKCZhnz8Q==} + '@prisma/driver-adapter-utils@7.8.0': + resolution: {integrity: sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==} + '@prisma/engines-version@4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5': resolution: {integrity: sha512-M16aibbxi/FhW7z1sJCX8u+0DriyQYY5AyeTH7plQm9MLnURoiyn3CZBqAyIoQ+Z1pS77usCIibYJWSgleBMBA==} @@ -6915,6 +7188,9 @@ packages: '@prisma/engines-version@6.20.0-11.next-80ee0a44bf5668992b0c909c946a755b86b56c95': resolution: {integrity: sha512-DqrQqRIgeocvWpgN7t9PymiJdV8ISSSrZCuilAtpKEaKIt4JUGIxsAdWNMRSHk188hYA2W1YFG5KvWUYBaCO1A==} + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': + resolution: {integrity: sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==} + '@prisma/engines@6.14.0': resolution: {integrity: sha512-LhJjqsALFEcoAtF07nSaOkVguaxw/ZsgfROIYZ8bAZDobe7y8Wy+PkYQaPOK1iLSsFgV2MhCO/eNrI1gdSOj6w==} @@ -6927,6 +7203,9 @@ packages: '@prisma/engines@6.20.0-integration-next.8': resolution: {integrity: sha512-XdzTxN0PFLIW2DcprG9xlMy39FrsjxW5J2qtHQ58FBtbllHSZGD0pK2nzATw5dRh7nGhmX+uNA02cqHv5oND3A==} + '@prisma/engines@7.8.0': + resolution: {integrity: sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==} + '@prisma/fetch-engine@6.14.0': resolution: {integrity: sha512-MPzYPOKMENYOaY3AcAbaKrfvXVlvTc6iHmTXsp9RiwCX+bPyfDMqMFVUSVXPYrXnrvEzhGHfyiFy0PRLHPysNg==} @@ -6939,6 +7218,9 @@ packages: '@prisma/fetch-engine@6.20.0-integration-next.8': resolution: {integrity: sha512-zVNM5Q1hFclpqD1y7wujDzyc3l01S8ZMuP0Zddzuda4LOA7/F2enjro48VcD2/fxkBgzkkmO/quLOGnbQDKO7g==} + '@prisma/fetch-engine@7.8.0': + resolution: {integrity: sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==} + '@prisma/generator-helper@4.16.2': resolution: {integrity: sha512-bMOH7y73Ui7gpQrioFeavMQA+Tf8ksaVf8Nhs9rQNzuSg8SSV6E9baczob0L5KGZTSgYoqnrRxuo03kVJYrnIg==} @@ -6954,6 +7236,12 @@ packages: '@prisma/get-platform@6.20.0-integration-next.8': resolution: {integrity: sha512-21jEfhFpC8FuvPD7JEf1Qu02engBCBa3+1il3UiyHKcKS3Kbp9IgR+DVqqrqSWIGJg8+1oTfF/3AgbjunaQ1Ag==} + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.8.0': + resolution: {integrity: sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==} + '@prisma/instrumentation@6.11.1': resolution: {integrity: sha512-mrZOev24EDhnefmnZX7WVVT7v+r9LttPRqf54ONvj6re4XMF7wFTpK2tLJi4XHB7fFp/6xhYbgRel8YV7gQiyA==} peerDependencies: @@ -6964,6 +7252,13 @@ packages: peerDependencies: '@opentelemetry/api': ^1.8 + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.2': + resolution: {integrity: sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==} + engines: {bun: '>=1.3.6', node: '>=22.0.0'} + '@prisma/studio-core-licensed@0.6.0': resolution: {integrity: sha512-LNC8ohLosuWz6n9oKNqfR5Ep/JYiPavk4RxrU6inOS4LEvMQts8N+Vtt7NAB9i06BaiIRKnPsg1Hcaao5pRjSw==} peerDependencies: @@ -6971,6 +7266,14 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + '@prisma/studio-core@0.27.3': + resolution: {integrity: sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + '@protobuf-ts/runtime@2.11.1': resolution: {integrity: sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==} @@ -7033,6 +7336,9 @@ packages: '@radix-ui/primitive@1.1.2': resolution: {integrity: sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==} + '@radix-ui/primitive@1.1.3': + resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} + '@radix-ui/react-accordion@1.2.11': resolution: {integrity: sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==} peerDependencies: @@ -7769,6 +8075,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-toggle@1.1.10': + resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-tooltip@1.0.5': resolution: {integrity: sha512-cDKVcfzyO6PpckZekODJZDe5ZxZ2fCZlzKzTmPhe4mX9qTHRfLcKgqb0OKf22xLwDequ2tVleim+ZYx3rabD5w==} peerDependencies: @@ -9116,21 +9435,39 @@ packages: '@shikijs/core@3.13.0': resolution: {integrity: sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA==} + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + '@shikijs/engine-javascript@3.13.0': resolution: {integrity: sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg==} + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + '@shikijs/engine-oniguruma@3.13.0': resolution: {integrity: sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg==} + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + '@shikijs/langs@3.13.0': resolution: {integrity: sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ==} + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + '@shikijs/themes@3.13.0': resolution: {integrity: sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg==} + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + '@shikijs/types@3.13.0': resolution: {integrity: sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw==} + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -9735,6 +10072,11 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@streamdown/code@1.1.1': + resolution: {integrity: sha512-i7HTNuDgZWb+VdrNVOam9gQhIc5MSSDXKWXgbUrn/4vSRaSMM+Rtl10MQj4wLWPNpF7p80waJsAqFP8HZfb0Jg==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + '@stricli/auto-complete@1.2.0': resolution: {integrity: sha512-r9/msiloVmTF95mdhe04Uzqei1B0ZofhYRLeiPqpJ1W1RMCC8p9iW7kqBZEbALl2aRL5ZK9OEW3Q1cIejH7KEQ==} hasBin: true @@ -10391,6 +10733,9 @@ packages: '@types/pg@8.11.6': resolution: {integrity: sha512-/2WmmBXHLsfRqzfHW7BNZ8SbYzE8OSk7i3WjFYvfgRHj7S1xj+16Je5fUKv3lVdVzk/zn9TXOqf+avFCFIE0yQ==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/pg@8.6.1': resolution: {integrity: sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==} @@ -10526,6 +10871,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/turndown@5.0.6': + resolution: {integrity: sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==} + '@types/unist@2.0.6': resolution: {integrity: sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==} @@ -10655,6 +11003,9 @@ packages: '@uploadthing/shared@7.0.3': resolution: {integrity: sha512-PAT5Jl6bfuVp37PBvaw7bwQYhLeDfIBuGr37mbPBPhtiqm8zf8ip8zubkdm5rXEhqRWfdI64SQpl+7Q+dLoM2Q==} + '@upsetjs/venn.js@2.0.0': + resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} + '@upstash/core-analytics@0.0.8': resolution: {integrity: sha512-MCJoF+Y8fkzq4NRLG7kEHjtGyMsZ2DICBdmEdwoK9umoSrfkzgBlYdZiHTIaewyt9PGaMZCHOasz0LAuMpxwxQ==} engines: {node: '>=16.0.0'} @@ -10689,6 +11040,10 @@ packages: resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + '@vercel/otel@1.13.0': resolution: {integrity: sha512-esRkt470Y2jRK1B1g7S1vkt4Csu44gp83Zpu8rIyPoqy2BKgk4z7ik1uSMswzi45UogLHFl6yR5TauDurBQi4Q==} engines: {node: '>=18'} @@ -11043,6 +11398,12 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + ai@6.0.168: + resolution: {integrity: sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ==} + engines: {node: '>=18'} + peerDependencies: + zod: ^3.25.76 || ^4.1.8 + ai@6.0.3: resolution: {integrity: sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ==} engines: {node: '>=18'} @@ -11280,6 +11641,10 @@ packages: aws-sign2@0.7.0: resolution: {integrity: sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + aws4@1.12.0: resolution: {integrity: sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==} @@ -11309,6 +11674,10 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + bare-events@2.8.2: resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} peerDependencies: @@ -11377,6 +11746,9 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} + better-result@2.8.2: + resolution: {integrity: sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==} + better-sqlite3@11.10.0: resolution: {integrity: sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==} @@ -11426,6 +11798,10 @@ packages: brace-expansion@2.0.1: resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + brace-expansion@5.0.5: + resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} + engines: {node: 18 || 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -11532,6 +11908,14 @@ packages: magicast: optional: true + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} + peerDependencies: + magicast: '*' + peerDependenciesMeta: + magicast: + optional: true + cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} @@ -11637,6 +12021,10 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} @@ -11649,9 +12037,18 @@ packages: peerDependencies: chevrotain: ^11.0.0 + chevrotain-allstar@0.4.1: + resolution: {integrity: sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA==} + peerDependencies: + chevrotain: ^12.0.0 + chevrotain@11.0.3: resolution: {integrity: sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw==} + chevrotain@12.0.0: + resolution: {integrity: sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ==} + engines: {node: '>=22.0.0'} + chokidar@3.5.3: resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==} engines: {node: '>= 8.10.0'} @@ -11664,6 +12061,10 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} @@ -11885,6 +12286,9 @@ packages: confbox@0.2.2: resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + config-chain@1.1.13: resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} @@ -12262,6 +12666,9 @@ packages: dagre-d3-es@7.0.11: resolution: {integrity: sha512-tvlJLyQf834SylNKax8Wkzco/1ias1OPw8DcUMDE7oUIoSEW25riQVuiu/0OWEFqT0cxHT3Pa9/D82Jr47IONw==} + dagre-d3-es@7.0.14: + resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -12301,6 +12708,9 @@ packages: dayjs@1.11.18: resolution: {integrity: sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==} + dayjs@1.11.20: + resolution: {integrity: sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==} + debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} @@ -12494,6 +12904,9 @@ packages: destr@2.0.3: resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -12560,6 +12973,9 @@ packages: resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} engines: {node: '>= 8.0'} + dockerfile-ast@0.7.1: + resolution: {integrity: sha512-oX/A4I0EhSkGqrFv0YuvPkBUSYp1XiY8O8zAKc8Djglx8ocz+JfOr8gP0ryRMC2myqvDLagmnZaU9ot1vG2ijw==} + dockerode@4.0.10: resolution: {integrity: sha512-8L/P9JynLBiG7/coiA4FlQXegHltRqS0a+KqI44P1zgQh8QLHTg7FKOwhkBgSJwZTeHsq30WRoVFLuwkfK0YFg==} engines: {node: '>= 8.0'} @@ -12622,6 +13038,10 @@ packages: resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==} engines: {node: '>=12'} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} + engines: {node: '>=12'} + dotenv@8.6.0: resolution: {integrity: sha512-IrPdXQsk2BbzvCBGBOTmmSH5SodmqZNt4ERAZDmW4CT+tL8VtvinqywuANaFu4bOMWki16nqf0e4oC0QIaDr/g==} engines: {node: '>=10'} @@ -12649,6 +13069,10 @@ packages: resolution: {integrity: sha512-ii/Bw55ecxgORqkArKNbuVTwqLgVZ0rH1X3J/NOe4LMZaVETm3qNpPBjoPkpQAsQjw2ew0Ad2sd54epqm9nLCw==} engines: {node: '>=18'} + e2b@2.19.2: + resolution: {integrity: sha512-AJtaQ72XIjdOBGnsvzVuYveYmy4ZDALLzZddN7sFIgd49eCY7u7Nwx7TXp97vZLPTEgfCwEqn1U9mehDrQMp3g==} + engines: {node: '>=20'} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -12675,6 +13099,9 @@ packages: effect@3.18.4: resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} + effect@3.21.2: resolution: {integrity: sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg==} @@ -12745,6 +13172,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -13316,6 +13747,9 @@ packages: exsolve@1.0.7: resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -13531,6 +13965,10 @@ packages: resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} engines: {node: '>=14'} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + forever-agent@0.6.1: resolution: {integrity: sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==} @@ -13654,6 +14092,9 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + generic-names@4.0.0: resolution: {integrity: sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==} @@ -13673,6 +14114,9 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-port@5.1.1: resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} engines: {node: '>=8'} @@ -13734,6 +14178,10 @@ packages: resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} hasBin: true + giget@3.2.0: + resolution: {integrity: sha512-GvHTWcykIR/fP8cj8dMpuMMkvaeJfPvYnhq0oW+chSeIr+ldX21ifU2Ms6KBoyKZQZmVaUAAhQ2EZ68KJF8a7A==} + hasBin: true + git-last-commit@1.0.1: resolution: {integrity: sha512-FDSgeMqa7GnJDxt/q0AbrxbfeTyxp4ImxEw1e4nw6NUHA5FMhFUq33dTXI4Xdgcj1VQ1q5QLWF6WxFrJ8KCBOg==} @@ -13768,6 +14216,12 @@ packages: deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -13822,6 +14276,9 @@ packages: resolution: {integrity: sha512-rEDCuqUQ4tbD78TpzsMtt5OIf0cBCSDWSJtUDaF6JsAh+k0v9r++NzxNEG87oDZx9ZwGhD8DaezR2L/yrw0Jdw==} engines: {node: '>=10'} + grammex@3.1.12: + resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} + grapheme-splitter@1.0.4: resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} @@ -13834,6 +14291,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + graphql@16.6.0: resolution: {integrity: sha512-KPIBPDlW7NxrbT/eh4qPXz5FiFdL5UbaA0XUNz2Rp3Z3hqBSkbj0GVjwFDztsWVauZUWsbKHgMg++sk8UX0bkw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -13925,6 +14385,9 @@ packages: hast-util-raw@9.1.0: resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + hast-util-sanitize@5.0.2: + resolution: {integrity: sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==} + hast-util-to-estree@2.1.0: resolution: {integrity: sha512-Vwch1etMRmm89xGgz+voWXvVHba2iiMdGMKmaMfYt35rbVtFDq8JNwwAIvi8zHMkO6Gvqo9oTMwJTmzVRfXh4g==} @@ -13963,6 +14426,10 @@ packages: resolution: {integrity: sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q==} engines: {node: '>=16.9.0'} + hono@4.12.15: + resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==} + engines: {node: '>=16.9.0'} + hono@4.5.11: resolution: {integrity: sha512-62FcjLPtjAFwISVBUshryl+vbHOjg8rE4uIK/dxyR8GpLztunZpwFmfEvmJCUI7xoGh/Sr3CGCDPCmYxVw7wUQ==} engines: {node: '>=16.0.0'} @@ -14013,6 +14480,9 @@ packages: resolution: {integrity: sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==} engines: {node: '>=0.8', npm: '>=1.3.7'} + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -14341,6 +14811,9 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-reference@3.0.3: resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} @@ -14461,6 +14934,10 @@ packages: resolution: {integrity: sha512-cub8rahkh0Q/bw1+GxP7aeSe29hHHn2V4m29nnDlvCdlgU+3UGxkZp7Z53jLUdpX3jdTO0nJZUDl3xvbWc2Xog==} engines: {node: 20 || >=22} + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + javascript-stringify@2.1.0: resolution: {integrity: sha512-JVAfqNPTvNq3sB/VHQJAFxN/sPgKnsKrCwyRt15zwNCdrMMJDdcEOdubuy+DuJYYdm0ox1J4uzEuYKkN+9yhVg==} @@ -14480,6 +14957,10 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jiti@2.6.1: + resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} + hasBin: true + joi@17.7.0: resolution: {integrity: sha512-1/ugc8djfn93rTE3WRKdCzGGt/EtiYKxITMO4Wiv6q5JL1gl9ePt4kBsl1S499nbosspfctIQTpYIhSmHA3WAg==} @@ -14676,6 +15157,10 @@ packages: resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==} engines: {node: '>=16.0.0'} + langium@4.2.2: + resolution: {integrity: sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ==} + engines: {node: '>=20.10.0', npm: '>=10.2.3'} + langsmith@0.2.15: resolution: {integrity: sha512-homtJU41iitqIZVuuLW7iarCzD4f39KcfP9RTBWav9jifhrsDa1Ez89Ejr+4qi72iuBu8Y5xykchsGVgiEZ93w==} peerDependencies: @@ -15015,6 +15500,10 @@ packages: resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==} engines: {node: '>=12'} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lucide-react@0.229.0: resolution: {integrity: sha512-b0/KSFXhPi++vUbnYEDUgP8Z8Rw9MQpRfBr+dRZNPMT3FD1HrVgMHXhSpkm9ZrrEtuqIfHf/O+tAGmw4WOmIog==} peerDependencies: @@ -15090,6 +15579,11 @@ packages: engines: {node: '>= 20'} hasBin: true + marked@17.0.6: + resolution: {integrity: sha512-gB0gkNafnonOw0obSTEGZTT86IuhILt2Wfx0mWH/1Au83kybTayroZ/V6nS25mN7u8ASy+5fMhgB3XPNrOZdmA==} + engines: {node: '>= 20'} + hasBin: true + marked@4.2.5: resolution: {integrity: sha512-jPueVhumq7idETHkb203WDD4fMA3yV9emQ5vLwop58lu8bTclMghBWcYAavlDqIEMaisADinV1TooIFCfqOsYQ==} engines: {node: '>= 12'} @@ -15245,6 +15739,9 @@ packages: mermaid@11.12.0: resolution: {integrity: sha512-ZudVx73BwrMJfCFmSSJT84y6u5brEoV8DOItdHomNLz32uBjNrelm7mg95X7g+C6UoQH/W6mBLGDEDv73JdxBg==} + mermaid@11.14.0: + resolution: {integrity: sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g==} + methods@1.1.2: resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} engines: {node: '>= 0.6'} @@ -15500,6 +15997,10 @@ packages: resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==} engines: {node: 20 || >=22} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} @@ -15652,9 +16153,17 @@ packages: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nan@2.23.1: resolution: {integrity: sha512-r7bBUGKzlqk8oPBDYxt6Z0aEdF1G1rwlMcLk8LCOMbOzf0mG+JUfUzG4fIMWwHWP0iyaLWEQZJmtB7nOHEm/qw==} @@ -15785,6 +16294,28 @@ packages: sass: optional: true + next@15.3.3: + resolution: {integrity: sha512-JqNj29hHNmCLtNvd090SyRbXJiivQ+58XjCcrC50Crb5g5u2zi7Y2YivbsEfzk6AtVI80akdOQbaMZwWB1Hthw==} + engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} + deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/CVE-2025-66478 for more details. + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.41.2 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + next@15.4.8: resolution: {integrity: sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} @@ -16070,9 +16601,15 @@ packages: oniguruma-parser@0.12.1: resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==} + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + oniguruma-to-es@4.3.3: resolution: {integrity: sha512-rPiZhzC3wXwE59YQMRDodUwwT9FZ9nNBwQQfsd1wfdtlKEyCdRV0avrTcSZ5xlIvGRVPd/cx6ZN45ECmS39xvg==} + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + open@10.0.3: resolution: {integrity: sha512-dtbI5oW7987hwC9qjJTyABldTaa19SuyJse1QboWv3b0qCcrrLNVDqBx1XgELAjh9QTVQaP/C5b1nhQebd1H2A==} engines: {node: '>=18'} @@ -16106,9 +16643,15 @@ packages: zod: optional: true + openapi-fetch@0.14.1: + resolution: {integrity: sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A==} + openapi-fetch@0.9.8: resolution: {integrity: sha512-zM6elH0EZStD/gSiNlcPrzXcVQ/pZo3BDvC6CDwRDUt1dDzxlshpmQnpD6cZaJ39THaSmwVCxxRrPKNM1hHrDg==} + openapi-typescript-helpers@0.0.15: + resolution: {integrity: sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==} + openapi-typescript-helpers@0.0.8: resolution: {integrity: sha512-1eNjQtbfNi5Z/kFhagDIaIRj6qqDzhjNJKz8cmMW0CVdGwT6e1GLbAfgI0d28VTJa1A8jz82jm/4dG8qNoNS8g==} @@ -16397,6 +16940,9 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + performance-now@2.1.0: resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==} @@ -16909,6 +17455,19 @@ packages: typescript: optional: true + prisma@7.8.0: + resolution: {integrity: sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} + hasBin: true + peerDependencies: + better-sqlite3: '>=9.0.0' + typescript: 5.5.4 + peerDependenciesMeta: + better-sqlite3: + optional: true + typescript: + optional: true + prismjs@1.29.0: resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} engines: {node: '>=6'} @@ -17082,6 +17641,9 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -17335,6 +17897,10 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -17387,6 +17953,9 @@ packages: regex@6.0.1: resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==} + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + regexp.prototype.flags@1.4.3: resolution: {integrity: sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA==} engines: {node: '>= 0.4'} @@ -17413,12 +17982,18 @@ packages: rehype-harden@1.1.5: resolution: {integrity: sha512-JrtBj5BVd/5vf3H3/blyJatXJbzQfRT9pJBmjafbTaPouQCAKxHwRyCc7dle9BXQKxv4z1OzZylz/tNamoiG3A==} + rehype-harden@1.1.8: + resolution: {integrity: sha512-Qn7vR1xrf6fZCrkm9TDWi/AB4ylrHy+jqsNm1EHOAmbARYA6gsnVJBq/sdBh6kmT4NEZxH5vgIjrscefJAOXcw==} + rehype-katex@7.0.1: resolution: {integrity: sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==} rehype-raw@7.0.0: resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + rehype-sanitize@6.0.0: + resolution: {integrity: sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==} + remark-frontmatter@4.0.1: resolution: {integrity: sha512-38fJrB0KnmD3E33a5jZC/5+gGAC2WKNiPw1/fdXJvijBlhA7RCsvJklrYJakS0HedninvaCYW8lQGf9C918GfA==} @@ -17447,9 +18022,18 @@ packages: remark-rehype@11.1.1: resolution: {integrity: sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==} + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + + remend@1.3.0: + resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} + remix-auth-email-link@2.0.2: resolution: {integrity: sha512-Lze9c50fsqBpixXQKe37wI2Dm4rlYYkNA6Eskxk8erQ7tbyN8xiFXOgo7Y3Al0SSjzkezw8au3uc2vCLJ8A5mQ==} peerDependencies: @@ -17789,6 +18373,13 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + + serialize-error@11.0.3: + resolution: {integrity: sha512-2G2y++21dhj2R7iHAdd0FIzjGwuKZld+7Pl/bTU6YIkrC2ZMbVUjm+luj6A6V34Rv9XfKJDKpTWu9W4Gse1D9g==} + engines: {node: '>=14.16'} + serialize-javascript@6.0.1: resolution: {integrity: sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==} @@ -17863,6 +18454,9 @@ packages: shiki@3.13.0: resolution: {integrity: sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g==} + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + shimmer@1.2.1: resolution: {integrity: sha512-sQTKC1Re/rM6XyFM6fIAGHRPVGvyXfgzIDvzoq608vM+jeyVD0Tu1E6Np0Kc2zAIFWIj963V2800iF/9LPieQw==} @@ -18061,6 +18655,10 @@ packages: resolution: {integrity: sha512-mkpF+RG402P66VMsnQkWewTRzDBWfu9iLbOfxaW/nAKOS/2A9MheQmcU5cmX0D0At9azrorZwpvcBRNNBozACQ==} hasBin: true + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + sqs-consumer@7.5.0: resolution: {integrity: sha512-aY3akgMjuK1aj4E7ZVAURUUnC8aNgUBES+b4SN+6ccMmJhi37MamWl7g1JbPow8sjIp1fBPz1bXCCDJmtjOTAg==} engines: {node: '>=18.0.0'} @@ -18113,6 +18711,9 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} @@ -18140,6 +18741,12 @@ packages: peerDependencies: react: ^18.0.0 || ^19.0.0 + streamdown@2.5.0: + resolution: {integrity: sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + streamsearch@1.1.0: resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} engines: {node: '>=10.0.0'} @@ -18380,6 +18987,9 @@ packages: tailwind-merge@3.3.1: resolution: {integrity: sha512-gBXpgUm/3rp1lMZZrM/w7D8GKqshif0zAymAhbCyIt8KMe+0v9DQ7cdYLR4FHH/cKpdTXb+A/tKKU3eolfsI+g==} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + tailwind-scrollbar-hide@1.1.7: resolution: {integrity: sha512-X324n9OtpTmOMqEgDUEA/RgLrNfBF/jwJdctaPZDzB3mppxJk7TLIDmOreEDm1Bq4R9LSPu4Epf8VSdovNU+iA==} @@ -18861,6 +19471,10 @@ packages: resolution: {integrity: sha512-U4gKCWcKgLcCjQd4Pl8KJdfEKumpyWbzRu75A6FCj6Ctea1PIm58W6Ltw1QXKqHrl2pF9e1raAskf/h6dlrPCA==} hasBin: true + turndown@7.2.4: + resolution: {integrity: sha512-I8yFsfRzmzK0WV1pNNOA4A7y4RDfFxPRxb3t+e3ui14qSGOxGtiSP6GjeX+Y6CHb7HYaFj7ECUD7VE5kQMZWGQ==} + engines: {node: '>=18', npm: '>=9'} + tw-animate-css@1.2.4: resolution: {integrity: sha512-yt+HkJB41NAvOffe4NweJU6fLqAlVx/mBX6XmHRp15kq0JxTtOKaIw8pVSWM1Z+n2nXtyi7cW6C9f0WG/F/QAQ==} @@ -19203,6 +19817,14 @@ packages: typescript: optional: true + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: 5.5.4 + peerDependenciesMeta: + typescript: + optional: true + valibot@1.3.1: resolution: {integrity: sha512-sfdRir/QFM0JaF22hqTroPc5xy4DimuGQVKFrzF1YfGwaS1nJot3Y8VqMdLO2Lg27fMzat2yD3pY5PbAYO39Gg==} peerDependencies: @@ -19385,6 +20007,9 @@ packages: vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -19687,6 +20312,9 @@ packages: yup@1.7.0: resolution: {integrity: sha512-VJce62dBd+JQvoc+fCVq+KZfPHr+hXaxCcVgotfwWvlR0Ja3ffYKaJBT8rptPOSKOGJDCUnW2C2JWpud7aRP6Q==} + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + zip-stream@6.0.1: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} @@ -19737,6 +20365,12 @@ snapshots: '@ai-sdk/provider-utils': 3.0.3(zod@3.25.76) zod: 3.25.76 + '@ai-sdk/anthropic@3.0.71(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) + zod: 3.25.76 + '@ai-sdk/gateway@1.0.6(zod@3.25.76)': dependencies: '@ai-sdk/provider': 2.0.0 @@ -19750,6 +20384,13 @@ snapshots: '@vercel/oidc': 3.0.3 zod: 3.25.76 + '@ai-sdk/gateway@3.0.104(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) + '@vercel/oidc': 3.2.0 + zod: 3.25.76 + '@ai-sdk/gateway@3.0.2(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.0 @@ -19861,6 +20502,13 @@ snapshots: eventsource-parser: 3.0.6 zod: 3.25.76 + '@ai-sdk/provider-utils@4.0.23(zod@3.25.76)': + dependencies: + '@ai-sdk/provider': 3.0.8 + '@standard-schema/spec': 1.1.0 + eventsource-parser: 3.0.6 + zod: 3.25.76 + '@ai-sdk/provider@0.0.26': dependencies: json-schema: 0.4.0 @@ -19919,6 +20567,26 @@ snapshots: optionalDependencies: zod: 3.25.76 + '@ai-sdk/react@3.0.170(react@18.2.0)(zod@3.25.76)': + dependencies: + '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) + ai: 6.0.168(zod@3.25.76) + react: 18.2.0 + swr: 2.2.5(react@18.2.0) + throttleit: 2.1.0 + transitivePeerDependencies: + - zod + + '@ai-sdk/react@3.0.170(react@19.1.0)(zod@3.25.76)': + dependencies: + '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) + ai: 6.0.168(zod@3.25.76) + react: 19.1.0 + swr: 2.2.5(react@19.1.0) + throttleit: 2.1.0 + transitivePeerDependencies: + - zod + '@ai-sdk/ui-utils@1.0.0(zod@3.25.76)': dependencies: '@ai-sdk/provider': 1.0.0 @@ -22182,6 +22850,8 @@ snapshots: '@bufbuild/protobuf@1.10.0': {} + '@bufbuild/protobuf@2.12.0': {} + '@bufbuild/protobuf@2.2.5': {} '@bugsnag/cuid@3.1.1': {} @@ -22348,17 +23018,32 @@ snapshots: '@chevrotain/types': 11.0.3 lodash-es: 4.18.1 + '@chevrotain/cst-dts-gen@12.0.0': + dependencies: + '@chevrotain/gast': 12.0.0 + '@chevrotain/types': 12.0.0 + '@chevrotain/gast@11.0.3': dependencies: '@chevrotain/types': 11.0.3 lodash-es: 4.18.1 + '@chevrotain/gast@12.0.0': + dependencies: + '@chevrotain/types': 12.0.0 + '@chevrotain/regexp-to-ast@11.0.3': {} + '@chevrotain/regexp-to-ast@12.0.0': {} + '@chevrotain/types@11.0.3': {} + '@chevrotain/types@12.0.0': {} + '@chevrotain/utils@11.0.3': {} + '@chevrotain/utils@12.0.0': {} + '@clack/core@0.5.0': dependencies: picocolors: 1.1.1 @@ -22479,6 +23164,11 @@ snapshots: '@connectrpc/connect': 1.4.0(@bufbuild/protobuf@1.10.0) undici: 5.29.0 + '@connectrpc/connect-web@2.0.0-rc.3(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.12.0))': + dependencies: + '@bufbuild/protobuf': 2.12.0 + '@connectrpc/connect': 2.0.0-rc.3(@bufbuild/protobuf@2.12.0) + '@connectrpc/connect-web@2.0.0-rc.3(@bufbuild/protobuf@2.2.5)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.2.5))': dependencies: '@bufbuild/protobuf': 2.2.5 @@ -22488,6 +23178,10 @@ snapshots: dependencies: '@bufbuild/protobuf': 1.10.0 + '@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.12.0)': + dependencies: + '@bufbuild/protobuf': 2.12.0 + '@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.2.5)': dependencies: '@bufbuild/protobuf': 2.2.5 @@ -22545,6 +23239,10 @@ snapshots: dependencies: e2b: 1.2.1 + '@e2b/code-interpreter@2.4.1': + dependencies: + e2b: 2.19.2 + '@effect/platform@0.63.2(@effect/schema@0.72.2(effect@3.7.2))(effect@3.7.2)': dependencies: '@effect/schema': 0.72.2(effect@3.7.2) @@ -22567,6 +23265,16 @@ snapshots: optionalDependencies: '@rollup/rollup-darwin-arm64': 4.53.2 + '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': {} + '@electric-sql/react@0.3.5(react@18.2.0)': dependencies: '@electric-sql/client': 0.4.0 @@ -22591,8 +23299,8 @@ snapshots: '@epic-web/test-server@0.1.0(bufferutil@4.0.9)': dependencies: - '@hono/node-server': 1.12.2(hono@4.5.11) - '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9) + '@hono/node-server': 1.12.2(hono@4.12.15) + '@hono/node-ws': 1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9) '@open-draft/deferred-promise': 2.2.0 '@types/ws': 8.5.12 hono: 4.5.11 @@ -23270,17 +23978,25 @@ snapshots: dependencies: react: 18.2.0 - '@hono/node-server@1.12.2(hono@4.5.11)': + '@hono/node-server@1.12.2(hono@4.12.15)': dependencies: - hono: 4.5.11 + hono: 4.12.15 + + '@hono/node-server@1.19.11(hono@4.12.15)': + dependencies: + hono: 4.12.15 '@hono/node-server@1.19.9(hono@4.11.8)': dependencies: hono: 4.11.8 - '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.11.8))(bufferutil@4.0.9)': + '@hono/node-server@1.19.9(hono@4.12.15)': + dependencies: + hono: 4.12.15 + + '@hono/node-ws@1.0.4(@hono/node-server@1.12.2(hono@4.5.11))(bufferutil@4.0.9)': dependencies: - '@hono/node-server': 1.12.2(hono@4.5.11) + '@hono/node-server': 1.12.2(hono@4.12.15) ws: 8.18.3(bufferutil@4.0.9) transitivePeerDependencies: - bufferutil @@ -23525,6 +24241,8 @@ snapshots: wrap-ansi: 8.1.0 wrap-ansi-cjs: wrap-ansi@7.0.0 + '@isaacs/cliui@9.0.0': {} + '@isaacs/fs-minipass@4.0.1': dependencies: minipass: 7.1.2 @@ -23644,6 +24362,8 @@ snapshots: - encoding - utf-8-validate + '@kurkle/color@0.3.4': {} + '@kwsites/file-exists@1.1.1': dependencies: debug: 4.4.3(supports-color@10.0.0) @@ -23737,11 +24457,17 @@ snapshots: dependencies: langium: 3.3.1 + '@mermaid-js/parser@1.1.0': + dependencies: + langium: 4.2.2 + '@microsoft/fetch-event-source@2.0.1': {} - '@modelcontextprotocol/sdk@1.25.2(hono@4.11.8)(supports-color@10.0.0)(zod@3.25.76)': + '@mixmark-io/domino@2.2.0': {} + + '@modelcontextprotocol/sdk@1.25.2(hono@4.12.15)(supports-color@10.0.0)(zod@3.25.76)': dependencies: - '@hono/node-server': 1.19.9(hono@4.11.8) + '@hono/node-server': 1.19.9(hono@4.12.15) ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) content-type: 1.0.5 @@ -23802,6 +24528,8 @@ snapshots: '@next/env@15.2.4': {} + '@next/env@15.3.3': {} + '@next/env@15.4.8': {} '@next/env@15.5.6': {} @@ -23815,6 +24543,9 @@ snapshots: '@next/swc-darwin-arm64@15.2.4': optional: true + '@next/swc-darwin-arm64@15.3.3': + optional: true + '@next/swc-darwin-arm64@15.4.8': optional: true @@ -23830,6 +24561,9 @@ snapshots: '@next/swc-darwin-x64@15.2.4': optional: true + '@next/swc-darwin-x64@15.3.3': + optional: true + '@next/swc-darwin-x64@15.4.8': optional: true @@ -23845,6 +24579,9 @@ snapshots: '@next/swc-linux-arm64-gnu@15.2.4': optional: true + '@next/swc-linux-arm64-gnu@15.3.3': + optional: true + '@next/swc-linux-arm64-gnu@15.4.8': optional: true @@ -23860,6 +24597,9 @@ snapshots: '@next/swc-linux-arm64-musl@15.2.4': optional: true + '@next/swc-linux-arm64-musl@15.3.3': + optional: true + '@next/swc-linux-arm64-musl@15.4.8': optional: true @@ -23875,6 +24615,9 @@ snapshots: '@next/swc-linux-x64-gnu@15.2.4': optional: true + '@next/swc-linux-x64-gnu@15.3.3': + optional: true + '@next/swc-linux-x64-gnu@15.4.8': optional: true @@ -23890,6 +24633,9 @@ snapshots: '@next/swc-linux-x64-musl@15.2.4': optional: true + '@next/swc-linux-x64-musl@15.3.3': + optional: true + '@next/swc-linux-x64-musl@15.4.8': optional: true @@ -23905,6 +24651,9 @@ snapshots: '@next/swc-win32-arm64-msvc@15.2.4': optional: true + '@next/swc-win32-arm64-msvc@15.3.3': + optional: true + '@next/swc-win32-arm64-msvc@15.4.8': optional: true @@ -23926,6 +24675,9 @@ snapshots: '@next/swc-win32-x64-msvc@15.2.4': optional: true + '@next/swc-win32-x64-msvc@15.3.3': + optional: true + '@next/swc-win32-x64-msvc@15.4.8': optional: true @@ -24817,8 +25569,19 @@ snapshots: transitivePeerDependencies: - pg-native + '@prisma/adapter-pg@7.8.0': + dependencies: + '@prisma/driver-adapter-utils': 7.8.0 + '@types/pg': 8.20.0 + pg: 8.16.3 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + '@prisma/client-runtime-utils@6.20.0-integration-next.8': {} + '@prisma/client-runtime-utils@7.8.0': {} + '@prisma/client@4.9.0(prisma@6.14.0(magicast@0.3.5)(typescript@5.5.4))': dependencies: '@prisma/engines-version': 4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5 @@ -24847,6 +25610,13 @@ snapshots: prisma: 6.20.0-integration-next.8(@types/react@19.2.14)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4) typescript: 5.5.4 + '@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@11.10.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4))(typescript@5.5.4)': + dependencies: + '@prisma/client-runtime-utils': 7.8.0 + optionalDependencies: + prisma: 7.8.0(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@11.10.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4) + typescript: 5.5.4 + '@prisma/config@6.14.0(magicast@0.3.5)': dependencies: c12: 3.1.0(magicast@0.3.5) @@ -24883,6 +25653,15 @@ snapshots: transitivePeerDependencies: - magicast + '@prisma/config@7.8.0(magicast@0.3.5)': + dependencies: + c12: 3.3.4(magicast@0.3.5) + deepmerge-ts: 7.1.5 + effect: 3.20.0 + empathic: 2.0.0 + transitivePeerDependencies: + - magicast + '@prisma/debug@4.16.2': dependencies: '@types/debug': 4.1.8 @@ -24899,6 +25678,32 @@ snapshots: '@prisma/debug@6.20.0-integration-next.8': {} + '@prisma/debug@7.2.0': {} + + '@prisma/debug@7.8.0': {} + + '@prisma/dev@0.24.3(typescript@5.5.4)': + dependencies: + '@electric-sql/pglite': 0.4.1 + '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) + '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) + '@hono/node-server': 1.19.11(hono@4.12.15) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.2 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + hono: 4.12.15 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.2.0(typescript@5.5.4) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript + '@prisma/driver-adapter-utils@6.16.0': dependencies: '@prisma/debug': 6.16.0 @@ -24907,6 +25712,10 @@ snapshots: dependencies: '@prisma/debug': 6.20.0-integration-next.8 + '@prisma/driver-adapter-utils@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version@4.9.0-42.ceb5c99003b99c9ee2c1d2e618e359c14aef2ea5': {} '@prisma/engines-version@6.14.0-25.717184b7b35ea05dfa71a3236b7af656013e1e49': {} @@ -24917,6 +25726,8 @@ snapshots: '@prisma/engines-version@6.20.0-11.next-80ee0a44bf5668992b0c909c946a755b86b56c95': {} + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': {} + '@prisma/engines@6.14.0': dependencies: '@prisma/debug': 6.14.0 @@ -24945,6 +25756,13 @@ snapshots: '@prisma/fetch-engine': 6.20.0-integration-next.8 '@prisma/get-platform': 6.20.0-integration-next.8 + '@prisma/engines@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a + '@prisma/fetch-engine': 7.8.0 + '@prisma/get-platform': 7.8.0 + '@prisma/fetch-engine@6.14.0': dependencies: '@prisma/debug': 6.14.0 @@ -24969,6 +25787,12 @@ snapshots: '@prisma/engines-version': 6.20.0-11.next-80ee0a44bf5668992b0c909c946a755b86b56c95 '@prisma/get-platform': 6.20.0-integration-next.8 + '@prisma/fetch-engine@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a + '@prisma/get-platform': 7.8.0 + '@prisma/generator-helper@4.16.2': dependencies: '@prisma/debug': 4.16.2 @@ -24994,6 +25818,14 @@ snapshots: dependencies: '@prisma/debug': 6.20.0-integration-next.8 + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/instrumentation@6.11.1(@opentelemetry/api@1.9.0)': dependencies: '@opentelemetry/api': 1.9.0 @@ -25008,12 +25840,31 @@ snapshots: transitivePeerDependencies: - supports-color + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/streams-local@0.1.2': + dependencies: + ajv: 8.18.0 + better-result: 2.8.2 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + '@prisma/studio-core-licensed@0.6.0(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@types/react': 19.2.14 react: 19.1.0 react-dom: 19.1.0(react@19.1.0) + '@prisma/studio-core@0.27.3(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@types/react': 19.2.14 + chart.js: 4.5.1 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + transitivePeerDependencies: + - '@types/react-dom' + '@protobuf-ts/runtime@2.11.1': {} '@protobufjs/aspromise@1.1.2': {} @@ -25077,6 +25928,8 @@ snapshots: '@radix-ui/primitive@1.1.2': {} + '@radix-ui/primitive@1.1.3': {} + '@radix-ui/react-accordion@1.2.11(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -25284,6 +26137,12 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + '@radix-ui/react-compose-refs@1.1.2(@types/react@19.2.14)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-context@1.0.0(react@18.2.0)': dependencies: '@babel/runtime': 7.28.4 @@ -25938,6 +26797,15 @@ snapshots: '@types/react': 18.2.69 '@types/react-dom': 18.2.7 + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.2.14)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.0.4(@types/react@19.2.14) + '@radix-ui/react-progress@1.1.1(@types/react-dom@18.2.7)(@types/react@18.3.1)(react-dom@18.2.0(react@18.3.1))(react@18.3.1)': dependencies: '@radix-ui/react-context': 1.1.1(@types/react@18.3.1)(react@18.3.1) @@ -26163,6 +27031,13 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + '@radix-ui/react-slot@1.2.3(@types/react@19.2.14)(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-switch@1.0.3(@types/react-dom@18.2.7)(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@babel/runtime': 7.20.7 @@ -26235,6 +27110,17 @@ snapshots: '@types/react': 18.2.69 '@types/react-dom': 18.2.7 + '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.0.4(@types/react@19.2.14) + '@radix-ui/react-tooltip@1.0.5(@types/react@18.2.69)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@babel/runtime': 7.20.7 @@ -26363,6 +27249,14 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + '@radix-ui/react-use-controllable-state@1.2.2(@types/react@19.2.14)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-effect-event': 0.0.2(@types/react@19.2.14)(react@19.1.0) + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@18.2.69)(react@18.2.0)': dependencies: '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@18.2.69)(react@18.2.0) @@ -26370,6 +27264,13 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + '@radix-ui/react-use-effect-event@0.0.2(@types/react@19.2.14)(react@19.1.0)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.14)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-escape-keydown@1.0.2(react@18.2.0)': dependencies: '@babel/runtime': 7.28.4 @@ -26449,6 +27350,12 @@ snapshots: optionalDependencies: '@types/react': 18.2.69 + '@radix-ui/react-use-layout-effect@1.1.1(@types/react@19.2.14)(react@19.1.0)': + dependencies: + react: 19.1.0 + optionalDependencies: + '@types/react': 19.2.14 + '@radix-ui/react-use-previous@1.0.0(react@18.2.0)': dependencies: '@babel/runtime': 7.28.4 @@ -27653,7 +28560,7 @@ snapshots: transitivePeerDependencies: - encoding - '@remix-run/dev@2.17.4(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/serve@2.17.4(typescript@5.5.4))(@types/node@20.14.14)(bufferutil@4.0.9)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(typescript@5.5.4)(vite@6.4.2(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3))(yaml@2.8.3)': + '@remix-run/dev@2.17.4(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/serve@2.17.4(typescript@5.5.4))(@types/node@20.14.14)(bufferutil@4.0.9)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(typescript@5.5.4)(vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3))(yaml@2.8.3)': dependencies: '@babel/core': 7.22.17 '@babel/generator': 7.24.7 @@ -27710,12 +28617,12 @@ snapshots: tar-fs: 2.1.4 tsconfig-paths: 4.2.0 valibot: 1.3.1(typescript@5.5.4) - vite-node: 3.1.4(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) + vite-node: 3.1.4(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) ws: 7.5.10(bufferutil@4.0.9) optionalDependencies: '@remix-run/serve': 2.17.4(typescript@5.5.4) typescript: 5.5.4 - vite: 6.4.2(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) + vite: 6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - bluebird @@ -28130,30 +29037,61 @@ snapshots: '@types/hast': 3.0.4 hast-util-to-html: 9.0.5 + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + '@shikijs/engine-javascript@3.13.0': dependencies: '@shikijs/types': 3.13.0 '@shikijs/vscode-textmate': 10.0.2 oniguruma-to-es: 4.3.3 + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + '@shikijs/engine-oniguruma@3.13.0': dependencies: '@shikijs/types': 3.13.0 '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@shikijs/langs@3.13.0': dependencies: '@shikijs/types': 3.13.0 + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/themes@3.13.0': dependencies: '@shikijs/types': 3.13.0 + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/types@3.13.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + '@shikijs/vscode-textmate@10.0.2': {} '@sideway/address@4.1.4': @@ -29117,6 +30055,11 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@streamdown/code@1.1.1(react@18.2.0)': + dependencies: + react: 18.2.0 + shiki: 3.23.0 + '@stricli/auto-complete@1.2.0': dependencies: '@stricli/core': 1.2.0 @@ -29785,6 +30728,12 @@ snapshots: pg-protocol: 1.10.3 pg-types: 4.0.2 + '@types/pg@8.20.0': + dependencies: + '@types/node': 20.14.14 + pg-protocol: 1.10.3 + pg-types: 2.2.0 + '@types/pg@8.6.1': dependencies: '@types/node': 20.14.14 @@ -29824,6 +30773,10 @@ snapshots: dependencies: '@types/react': 19.0.12 + '@types/react-dom@19.0.4(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react@18.2.48': dependencies: '@types/prop-types': 15.7.5 @@ -29947,6 +30900,8 @@ snapshots: '@types/trusted-types@2.0.7': optional: true + '@types/turndown@5.0.6': {} + '@types/unist@2.0.6': {} '@types/unist@3.0.3': {} @@ -30121,6 +31076,11 @@ snapshots: effect: 3.7.2 sqids: 0.3.0 + '@upsetjs/venn.js@2.0.0': + optionalDependencies: + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + '@upstash/core-analytics@0.0.8': dependencies: '@upstash/redis': 1.29.0 @@ -30186,6 +31146,8 @@ snapshots: '@vercel/oidc@3.1.0': {} + '@vercel/oidc@3.2.0': {} + '@vercel/otel@1.13.0(@opentelemetry/api-logs@0.203.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))': dependencies: '@opentelemetry/api': 1.9.0 @@ -30212,7 +31174,7 @@ snapshots: - '@cfworker/json-schema' - supports-color - '@vitest/coverage-v8@3.1.4(vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3))': + '@vitest/coverage-v8@3.1.4(vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3))': dependencies: '@ampproject/remapping': 2.3.0 '@bcoe/v8-coverage': 1.0.2 @@ -30226,7 +31188,7 @@ snapshots: std-env: 3.9.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3) + vitest: 3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3) transitivePeerDependencies: - supports-color @@ -30237,13 +31199,13 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.1.4(vite@6.4.2(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3))': + '@vitest/mocker@3.1.4(vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.1.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 6.4.2(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3) + vite: 6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3) '@vitest/pretty-format@2.1.9': dependencies: @@ -30624,6 +31586,14 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 3.25.76 + ai@6.0.168(zod@3.25.76): + dependencies: + '@ai-sdk/gateway': 3.0.104(zod@3.25.76) + '@ai-sdk/provider': 3.0.8 + '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) + '@opentelemetry/api': 1.9.0 + zod: 3.25.76 + ai@6.0.3(zod@3.25.76): dependencies: '@ai-sdk/gateway': 3.0.2(zod@3.25.76) @@ -30900,6 +31870,8 @@ snapshots: aws-sign2@0.7.0: {} + aws-ssl-profiles@1.1.2: {} + aws4@1.12.0: {} aws4fetch@1.0.18: {} @@ -30928,6 +31900,8 @@ snapshots: balanced-match@1.0.2: {} + balanced-match@4.0.4: {} + bare-events@2.8.2: optional: true @@ -30986,6 +31960,8 @@ snapshots: dependencies: is-windows: 1.0.2 + better-result@2.8.2: {} + better-sqlite3@11.10.0: dependencies: bindings: 1.5.0 @@ -31069,6 +32045,10 @@ snapshots: dependencies: balanced-match: 1.0.2 + brace-expansion@5.0.5: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -31194,6 +32174,23 @@ snapshots: optionalDependencies: magicast: 0.3.5 + c12@3.3.4(magicast@0.3.5): + dependencies: + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.0.8 + giget: 3.2.0 + jiti: 2.6.1 + ohash: 2.0.11 + pathe: 2.0.3 + perfect-debounce: 2.1.0 + pkg-types: 2.3.0 + rc9: 3.0.1 + optionalDependencies: + magicast: 0.3.5 + cac@6.7.14: {} cacache@17.1.4: @@ -31307,6 +32304,10 @@ snapshots: chardet@0.7.0: {} + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + check-error@2.1.1: {} cheminfo-types@1.8.1: {} @@ -31316,6 +32317,11 @@ snapshots: chevrotain: 11.0.3 lodash-es: 4.18.1 + chevrotain-allstar@0.4.1(chevrotain@12.0.0): + dependencies: + chevrotain: 12.0.0 + lodash-es: 4.18.1 + chevrotain@11.0.3: dependencies: '@chevrotain/cst-dts-gen': 11.0.3 @@ -31325,6 +32331,14 @@ snapshots: '@chevrotain/utils': 11.0.3 lodash-es: 4.18.1 + chevrotain@12.0.0: + dependencies: + '@chevrotain/cst-dts-gen': 12.0.0 + '@chevrotain/gast': 12.0.0 + '@chevrotain/regexp-to-ast': 12.0.0 + '@chevrotain/types': 12.0.0 + '@chevrotain/utils': 12.0.0 + chokidar@3.5.3: dependencies: anymatch: 3.1.3 @@ -31353,6 +32367,10 @@ snapshots: dependencies: readdirp: 4.1.2 + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + chownr@1.1.4: {} chownr@2.0.0: {} @@ -31583,6 +32601,8 @@ snapshots: confbox@0.2.2: {} + confbox@0.2.4: {} + config-chain@1.1.13: dependencies: ini: 1.3.8 @@ -31975,6 +32995,11 @@ snapshots: d3: 7.9.0 lodash-es: 4.18.1 + dagre-d3-es@7.0.14: + dependencies: + d3: 7.9.0 + lodash-es: 4.18.1 + damerau-levenshtein@1.0.8: {} dashdash@1.14.1: @@ -32011,6 +33036,8 @@ snapshots: dayjs@1.11.18: {} + dayjs@1.11.20: {} + debounce@1.2.1: {} debounce@2.0.0: {} @@ -32148,6 +33175,8 @@ snapshots: destr@2.0.3: {} + destr@2.0.5: {} + destroy@1.2.0: {} detect-indent@6.1.0: {} @@ -32211,6 +33240,11 @@ snapshots: transitivePeerDependencies: - supports-color + dockerfile-ast@0.7.1: + dependencies: + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + dockerode@4.0.10: dependencies: '@balena/dockerignore': 1.0.2 @@ -32284,6 +33318,8 @@ snapshots: dotenv@17.2.3: {} + dotenv@17.4.2: {} + dotenv@8.6.0: {} dprint-node@1.0.8: @@ -32323,6 +33359,19 @@ snapshots: openapi-fetch: 0.9.8 platform: 1.3.6 + e2b@2.19.2: + dependencies: + '@bufbuild/protobuf': 2.12.0 + '@connectrpc/connect': 2.0.0-rc.3(@bufbuild/protobuf@2.12.0) + '@connectrpc/connect-web': 2.0.0-rc.3(@bufbuild/protobuf@2.12.0)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.12.0)) + chalk: 5.3.0 + compare-versions: 6.1.1 + dockerfile-ast: 0.7.1 + glob: 11.1.0 + openapi-fetch: 0.14.1 + platform: 1.3.6 + tar: 7.5.13 + eastasianwidth@0.2.0: {} ecc-jsbn@0.1.2: @@ -32358,6 +33407,11 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + effect@3.20.0: + dependencies: + '@standard-schema/spec': 1.1.0 + fast-check: 3.23.2 + effect@3.21.2: dependencies: '@standard-schema/spec': 1.1.0 @@ -32435,6 +33489,8 @@ snapshots: env-paths@2.2.1: {} + env-paths@3.0.0: {} + environment@1.1.0: {} err-code@2.0.3: {} @@ -33374,6 +34430,8 @@ snapshots: exsolve@1.0.7: {} + exsolve@1.0.8: {} + extend@3.0.2: {} extendable-error@0.1.7: {} @@ -33615,6 +34673,11 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + forever-agent@0.6.1: {} form-data-encoder@1.7.2: {} @@ -33739,6 +34802,10 @@ snapshots: functions-have-names@1.2.3: {} + generate-function@2.3.1: + dependencies: + is-property: 1.0.2 + generic-names@4.0.0: dependencies: loader-utils: 3.2.1 @@ -33762,6 +34829,8 @@ snapshots: get-nonce@1.0.1: {} + get-port-please@3.2.0: {} + get-port@5.1.1: {} get-port@7.2.0: {} @@ -33840,6 +34909,8 @@ snapshots: nypm: 0.6.1 pathe: 2.0.3 + giget@3.2.0: {} + git-last-commit@1.0.1: {} github-from-package@0.0.0: {} @@ -33880,6 +34951,15 @@ snapshots: package-json-from-dist: 1.0.0 path-scurry: 2.0.0 + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.0 + path-scurry: 2.0.0 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -33954,6 +35034,8 @@ snapshots: chalk: 4.1.2 tinygradient: 1.1.5 + grammex@3.1.12: {} + grapheme-splitter@1.0.4: {} graphile-config@0.0.1-beta.8: @@ -33986,6 +35068,8 @@ snapshots: - supports-color - typescript + graphmatch@1.1.1: {} + graphql@16.6.0: {} gunzip-maybe@1.4.2: @@ -34099,6 +35183,12 @@ snapshots: web-namespaces: 2.0.1 zwitch: 2.0.4 + hast-util-sanitize@5.0.2: + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.3.0 + unist-util-position: 5.0.0 + hast-util-to-estree@2.1.0: dependencies: '@types/estree': 1.0.8 @@ -34194,6 +35284,8 @@ snapshots: hono@4.11.8: {} + hono@4.12.15: {} + hono@4.5.11: {} hosted-git-info@2.8.9: {} @@ -34256,6 +35348,8 @@ snapshots: jsprim: 1.4.2 sshpk: 1.18.0 + http-status-codes@2.3.0: {} + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -34538,6 +35632,8 @@ snapshots: is-promise@4.0.0: {} + is-property@1.0.2: {} + is-reference@3.0.3: dependencies: '@types/estree': 1.0.8 @@ -34656,6 +35752,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + javascript-stringify@2.1.0: {} jest-worker@27.5.1: @@ -34670,6 +35770,8 @@ snapshots: jiti@2.4.2: {} + jiti@2.6.1: {} + joi@17.7.0: dependencies: '@hapi/hoek': 9.3.0 @@ -34858,6 +35960,15 @@ snapshots: vscode-languageserver-textdocument: 1.0.12 vscode-uri: 3.0.8 + langium@4.2.2: + dependencies: + '@chevrotain/regexp-to-ast': 12.0.0 + chevrotain: 12.0.0 + chevrotain-allstar: 0.4.1(chevrotain@12.0.0) + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + langsmith@0.2.15(openai@4.68.4(encoding@0.1.13)(zod@3.25.76)): dependencies: '@types/uuid': 10.0.0 @@ -35130,6 +36241,8 @@ snapshots: lru-cache@7.18.3: {} + lru.min@1.1.4: {} + lucide-react@0.229.0(react@18.2.0): dependencies: react: 18.2.0 @@ -35146,10 +36259,6 @@ snapshots: dependencies: react: 19.0.0 - lucide-react@0.542.0(react@18.2.0): - dependencies: - react: 18.2.0 - lucide-react@0.542.0(react@19.1.0): dependencies: react: 19.1.0 @@ -35202,6 +36311,8 @@ snapshots: marked@16.4.1: {} + marked@17.0.6: {} + marked@4.2.5: {} marked@7.0.4: {} @@ -35553,6 +36664,32 @@ snapshots: transitivePeerDependencies: - supports-color + mermaid@11.14.0: + dependencies: + '@braintree/sanitize-url': 7.1.1 + '@iconify/utils': 3.0.2 + '@mermaid-js/parser': 1.1.0 + '@types/d3': 7.4.3 + '@upsetjs/venn.js': 2.0.0 + cytoscape: 3.33.1 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.33.1) + cytoscape-fcose: 2.2.0(cytoscape@3.33.1) + d3: 7.9.0 + d3-sankey: 0.12.3 + dagre-d3-es: 7.0.14 + dayjs: 1.11.20 + dompurify: 3.4.1 + katex: 0.16.25 + khroma: 2.1.0 + lodash-es: 4.18.1 + marked: 16.4.1 + roughjs: 4.6.6 + stylis: 4.3.6 + ts-dedent: 2.2.0 + uuid: 11.1.0 + transitivePeerDependencies: + - supports-color + methods@1.1.2: {} micromark-core-commonmark@1.0.6: @@ -36014,6 +37151,10 @@ snapshots: dependencies: brace-expansion: 2.0.1 + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.5 + minimatch@3.1.5: dependencies: brace-expansion: 1.1.11 @@ -36171,12 +37312,28 @@ snapshots: mustache@4.2.0: {} + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.2.3 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + mz@2.7.0: dependencies: any-promise: 1.3.0 object-assign: 4.1.1 thenify-all: 1.6.0 + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + nan@2.23.1: optional: true @@ -36184,7 +37341,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 css-tree: 1.1.3 - csstype: 3.2.0 + csstype: 3.2.3 fastest-stable-stringify: 2.0.2 inline-style-prefixer: 7.0.1 react: 18.2.0 @@ -36312,6 +37469,33 @@ snapshots: - '@babel/core' - babel-plugin-macros + next@15.3.3(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + '@next/env': 15.3.3 + '@swc/counter': 0.1.3 + '@swc/helpers': 0.5.15 + busboy: 1.6.0 + caniuse-lite: 1.0.30001754 + postcss: 8.5.10 + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + styled-jsx: 5.1.6(react@19.1.0) + optionalDependencies: + '@next/swc-darwin-arm64': 15.3.3 + '@next/swc-darwin-x64': 15.3.3 + '@next/swc-linux-arm64-gnu': 15.3.3 + '@next/swc-linux-arm64-musl': 15.3.3 + '@next/swc-linux-x64-gnu': 15.3.3 + '@next/swc-linux-x64-musl': 15.3.3 + '@next/swc-win32-arm64-msvc': 15.3.3 + '@next/swc-win32-x64-msvc': 15.3.3 + '@opentelemetry/api': 1.9.0 + '@playwright/test': 1.37.0 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + next@15.4.8(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0): dependencies: '@next/env': 15.4.8 @@ -36610,12 +37794,20 @@ snapshots: oniguruma-parser@0.12.1: {} + oniguruma-parser@0.12.2: {} + oniguruma-to-es@4.3.3: dependencies: oniguruma-parser: 0.12.1 regex: 6.0.1 regex-recursion: 6.0.2 + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + open@10.0.3: dependencies: default-browser: 5.2.1 @@ -36686,10 +37878,16 @@ snapshots: transitivePeerDependencies: - encoding + openapi-fetch@0.14.1: + dependencies: + openapi-typescript-helpers: 0.0.15 + openapi-fetch@0.9.8: dependencies: openapi-typescript-helpers: 0.0.8 + openapi-typescript-helpers@0.0.15: {} + openapi-typescript-helpers@0.0.8: {} opener@1.5.2: {} @@ -36972,6 +38170,8 @@ snapshots: perfect-debounce@1.0.0: {} + perfect-debounce@2.1.0: {} + performance-now@2.1.0: {} periscopic@3.1.0: @@ -37189,11 +38389,11 @@ snapshots: optionalDependencies: postcss: 8.5.10 - postcss-load-config@6.0.1(jiti@2.4.2)(postcss@8.5.10)(tsx@4.17.0)(yaml@2.8.3): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.17.0)(yaml@2.8.3): dependencies: lilconfig: 3.1.3 optionalDependencies: - jiti: 2.4.2 + jiti: 2.6.1 postcss: 8.5.10 tsx: 4.17.0 yaml: 2.8.3 @@ -37445,6 +38645,24 @@ snapshots: - react - react-dom + prisma@7.8.0(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(better-sqlite3@11.10.0)(magicast@0.3.5)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)(typescript@5.5.4): + dependencies: + '@prisma/config': 7.8.0(magicast@0.3.5) + '@prisma/dev': 0.24.3(typescript@5.5.4) + '@prisma/engines': 7.8.0 + '@prisma/studio-core': 0.27.3(@types/react-dom@19.0.4(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + mysql2: 3.15.3 + postgres: 3.4.7 + optionalDependencies: + better-sqlite3: 11.10.0 + typescript: 5.5.4 + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + - magicast + - react + - react-dom + prismjs@1.29.0: {} prismjs@1.30.0: {} @@ -37644,6 +38862,11 @@ snapshots: defu: 6.1.7 destr: 2.0.3 + rc9@3.0.1: + dependencies: + defu: 6.1.7 + destr: 2.0.5 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -38166,6 +39389,8 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.0.0: {} + real-require@0.2.0: {} recharts-scale@0.4.5: @@ -38223,6 +39448,10 @@ snapshots: dependencies: regex-utilities: 2.3.0 + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + regexp.prototype.flags@1.4.3: dependencies: call-bind: 1.0.8 @@ -38250,6 +39479,10 @@ snapshots: rehype-harden@1.1.5: {} + rehype-harden@1.1.8: + dependencies: + unist-util-visit: 5.0.0 + rehype-katex@7.0.1: dependencies: '@types/hast': 3.0.4 @@ -38266,6 +39499,11 @@ snapshots: hast-util-raw: 9.1.0 vfile: 6.0.3 + rehype-sanitize@6.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-sanitize: 5.0.2 + remark-frontmatter@4.0.1: dependencies: '@types/mdast': 3.0.10 @@ -38339,12 +39577,24 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.0 + unified: 11.0.5 + vfile: 6.0.3 + remark-stringify@11.0.0: dependencies: '@types/mdast': 4.0.4 mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remeda@2.33.4: {} + + remend@1.3.0: {} + remix-auth-email-link@2.0.2(@remix-run/server-runtime@2.17.4(typescript@5.5.4))(remix-auth@3.6.0(@remix-run/react@2.17.4(react-dom@18.2.0(react@18.2.0))(react@18.2.0)(typescript@5.5.4))(@remix-run/server-runtime@2.17.4(typescript@5.5.4))): dependencies: '@remix-run/server-runtime': 2.17.4(typescript@5.5.4) @@ -38750,6 +40000,12 @@ snapshots: transitivePeerDependencies: - supports-color + seq-queue@0.0.5: {} + + serialize-error@11.0.3: + dependencies: + type-fest: 2.19.0 + serialize-javascript@6.0.1: dependencies: randombytes: 2.1.0 @@ -38899,6 +40155,17 @@ snapshots: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + shimmer@1.2.1: {} side-channel-list@1.0.0: @@ -39145,6 +40412,8 @@ snapshots: argparse: 2.0.1 nearley: 2.20.1 + sqlstring@2.3.3: {} + sqs-consumer@7.5.0(@aws-sdk/client-sqs@3.454.0): dependencies: '@aws-sdk/client-sqs': 3.454.0 @@ -39210,6 +40479,8 @@ snapshots: statuses@2.0.2: {} + std-env@3.10.0: {} + std-env@3.7.0: {} std-env@3.8.1: {} @@ -39226,15 +40497,15 @@ snapshots: dependencies: mixme: 0.5.4 - streamdown@1.4.0(@types/react@18.2.69)(react@18.2.0): + streamdown@1.4.0(@types/react@19.0.12)(react@19.1.0): dependencies: clsx: 2.1.1 katex: 0.16.25 - lucide-react: 0.542.0(react@18.2.0) + lucide-react: 0.542.0(react@19.1.0) marked: 16.4.1 mermaid: 11.12.0 - react: 18.2.0 - react-markdown: 10.1.0(@types/react@18.2.69)(react@18.2.0) + react: 19.1.0 + react-markdown: 10.1.0(@types/react@19.0.12)(react@19.1.0) rehype-harden: 1.1.5 rehype-katex: 7.0.1 rehype-raw: 7.0.0 @@ -39246,24 +40517,50 @@ snapshots: - '@types/react' - supports-color - streamdown@1.4.0(@types/react@19.0.12)(react@19.1.0): + streamdown@2.5.0(patch_hash=36211d09153a59c880b6a2bce2a0a0f011c99c73c20c8ceca78cc77e47623f06)(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: clsx: 2.1.1 - katex: 0.16.25 - lucide-react: 0.542.0(react@19.1.0) - marked: 16.4.1 - mermaid: 11.12.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + marked: 17.0.6 + mermaid: 11.14.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + rehype-harden: 1.1.8 + rehype-raw: 7.0.0 + rehype-sanitize: 6.0.0 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remend: 1.3.0 + tailwind-merge: 3.5.0 + unified: 11.0.5 + unist-util-visit: 5.0.0 + unist-util-visit-parents: 6.0.1 + transitivePeerDependencies: + - supports-color + + streamdown@2.5.0(patch_hash=36211d09153a59c880b6a2bce2a0a0f011c99c73c20c8ceca78cc77e47623f06)(react-dom@19.1.0(react@19.1.0))(react@19.1.0): + dependencies: + clsx: 2.1.1 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + marked: 17.0.6 + mermaid: 11.14.0 react: 19.1.0 - react-markdown: 10.1.0(@types/react@19.0.12)(react@19.1.0) - rehype-harden: 1.1.5 - rehype-katex: 7.0.1 + react-dom: 19.1.0(react@19.1.0) + rehype-harden: 1.1.8 rehype-raw: 7.0.0 + rehype-sanitize: 6.0.0 remark-gfm: 4.0.1 - remark-math: 6.0.0 - shiki: 3.13.0 - tailwind-merge: 3.3.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remend: 1.3.0 + tailwind-merge: 3.5.0 + unified: 11.0.5 + unist-util-visit: 5.0.0 + unist-util-visit-parents: 6.0.1 transitivePeerDependencies: - - '@types/react' - supports-color streamsearch@1.1.0: {} @@ -39502,6 +40799,12 @@ snapshots: react: 19.0.0 use-sync-external-store: 1.2.2(react@19.0.0) + swr@2.2.5(react@19.1.0): + dependencies: + client-only: 0.0.1 + react: 19.1.0 + use-sync-external-store: 1.2.2(react@19.1.0) + sync-content@2.0.1: dependencies: glob: 11.0.0 @@ -39539,6 +40842,8 @@ snapshots: tailwind-merge@3.3.1: {} + tailwind-merge@3.5.0: {} + tailwind-scrollbar-hide@1.1.7: {} tailwind-scrollbar@3.0.1(tailwindcss@3.4.1): @@ -40009,7 +41314,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.4.2)(postcss@8.5.10)(tsx@4.17.0)(typescript@5.5.4)(yaml@2.8.3): + tsup@8.4.0(@swc/core@1.3.101(@swc/helpers@0.5.15))(jiti@2.6.1)(postcss@8.5.10)(tsx@4.17.0)(typescript@5.5.4)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.25.1) cac: 6.7.14 @@ -40019,7 +41324,7 @@ snapshots: esbuild: 0.25.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.4.2)(postcss@8.5.10)(tsx@4.17.0)(yaml@2.8.3) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.10)(tsx@4.17.0)(yaml@2.8.3) resolve-from: 5.0.0 rollup: 4.60.1 source-map: 0.8.0-beta.0 @@ -40149,6 +41454,10 @@ snapshots: turbo-windows-64: 1.10.3 turbo-windows-arm64: 1.10.3 + turndown@7.2.4: + dependencies: + '@mixmark-io/domino': 2.2.0 + tw-animate-css@1.2.4: {} tweetnacl@0.14.5: {} @@ -40483,6 +41792,10 @@ snapshots: dependencies: react: 19.0.0 + use-sync-external-store@1.2.2(react@19.1.0): + dependencies: + react: 19.1.0 + use-sync-external-store@1.6.0(react@18.2.0): dependencies: react: 18.2.0 @@ -40522,6 +41835,10 @@ snapshots: optionalDependencies: typescript: 5.5.4 + valibot@1.2.0(typescript@5.5.4): + optionalDependencies: + typescript: 5.5.4 + valibot@1.3.1(typescript@5.5.4): optionalDependencies: typescript: 5.5.4 @@ -40620,13 +41937,13 @@ snapshots: - supports-color - terser - vite-node@3.1.4(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3): + vite-node@3.1.4(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@10.0.0) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.2(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3) + vite: 6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -40641,13 +41958,13 @@ snapshots: - tsx - yaml - vite-node@3.1.4(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3): + vite-node@3.1.4(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@10.0.0) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.2(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) + vite: 6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -40682,7 +41999,7 @@ snapshots: lightningcss: 1.29.2 terser: 5.44.1 - vite@6.4.2(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3): + vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3): dependencies: esbuild: 0.25.1 fdir: 6.4.4(picomatch@4.0.4) @@ -40693,13 +42010,13 @@ snapshots: optionalDependencies: '@types/node': 20.14.14 fsevents: 2.3.3 - jiti: 2.4.2 + jiti: 2.6.1 lightningcss: 1.29.2 terser: 5.44.1 tsx: 3.12.2 yaml: 2.8.3 - vite@6.4.2(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3): + vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3): dependencies: esbuild: 0.25.1 fdir: 6.4.4(picomatch@4.0.4) @@ -40710,16 +42027,16 @@ snapshots: optionalDependencies: '@types/node': 20.14.14 fsevents: 2.3.3 - jiti: 2.4.2 + jiti: 2.6.1 lightningcss: 1.29.2 terser: 5.44.1 tsx: 4.20.6 yaml: 2.8.3 - vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3): + vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3): dependencies: '@vitest/expect': 3.1.4 - '@vitest/mocker': 3.1.4(vite@6.4.2(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3)) + '@vitest/mocker': 3.1.4(vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3)) '@vitest/pretty-format': 3.1.4 '@vitest/runner': 3.1.4 '@vitest/snapshot': 3.1.4 @@ -40736,8 +42053,8 @@ snapshots: tinyglobby: 0.2.13 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.4.2(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3) - vite-node: 3.1.4(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3) + vite: 6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3) + vite-node: 3.1.4(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -40756,10 +42073,10 @@ snapshots: - tsx - yaml - vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3): + vitest@3.1.4(@types/debug@4.1.12)(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3): dependencies: '@vitest/expect': 3.1.4 - '@vitest/mocker': 3.1.4(vite@6.4.2(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3)) + '@vitest/mocker': 3.1.4(vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3)) '@vitest/pretty-format': 3.1.4 '@vitest/runner': 3.1.4 '@vitest/snapshot': 3.1.4 @@ -40776,8 +42093,8 @@ snapshots: tinyglobby: 0.2.13 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.4.2(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) - vite-node: 3.1.4(@types/node@20.14.14)(jiti@2.4.2)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) + vite: 6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) + vite-node: 3.1.4(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.12 @@ -40813,6 +42130,8 @@ snapshots: vscode-uri@3.0.8: {} + vscode-uri@3.1.0: {} + w3c-keyname@2.2.8: {} walk-up-path@4.0.0: {} @@ -41136,6 +42455,11 @@ snapshots: toposort: 2.0.2 type-fest: 2.19.0 + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.12 + graphmatch: 1.1.1 + zip-stream@6.0.1: dependencies: archiver-utils: 5.0.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2bfb60d56d5..870222a3176 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -12,6 +12,8 @@ minimumReleaseAgeExclude: - "next" - "@next/*" - "agentcrumbs" + - "secure-exec" + - "@secure-exec/*" preferOffline: true linkWorkspacePackages: false From 5022769f7001d3598d7cf74b337f45dfea4e128e Mon Sep 17 00:00:00 2001 From: Daniel Sutton <45313566+d-cs@users.noreply.github.com> Date: Thu, 14 May 2026 16:23:32 +0100 Subject: [PATCH 016/238] fix(webapp): retry on version collision when initializing a deployment (#3610) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Concurrent `POST /api/v1/deployments` requests for the same environment race on the `WorkerDeployment(environmentId, version)` unique constraint. Both requests read the same latest deployment via `findFirst`, compute the same next version via `calculateNextBuildVersion`, and both attempt `prisma.workerDeployment.create()` — one wins, the other crashes with Prisma `P2002`. The bug is a classic TOCTOU between the version read and the version write; it's been latent since the version-assignment logic was first added but only fires when two deploys land within milliseconds of each other (CI matrices, retried CLI calls, webhook-triggered redeploys). ## Approach Extracts the version assignment + create into a small helper `createDeploymentWithNextVersion` (`apps/webapp/app/v3/services/initializeDeployment/createDeploymentWithNextVersion.server.ts`). The helper retries on `P2002 (environmentId, version)` up to 5 times with randomised 5–50ms jitter so N concurrent racers don't loop in lockstep. Each attempt re-reads the latest version, recomputes via `calculateNextBuildVersion`, and re-runs the caller's `buildData` callback so version-dependent fields (image ref tag, friendlyId) are always consistent with the version actually persisted. A `logger.warn` fires per collision so the retry rate is observable in production logs. When retries are exhausted, the helper throws a dedicated `DeploymentVersionCollisionError` carrying `environmentId`, `attempts`, and `lastAttemptedVersion`, with the original `PrismaClientKnownRequestError` attached as `cause`. Sentry walks the `cause` chain natively, so contention exhaustion shows up as a distinguishable wrapper exception linked to the underlying `P2002` rather than a generic unique-constraint violation that looks identical to every other duplicate-key bug. The behavioural change is limited to "catch P2002 and retry instead of crashing." The image ref computation stays inside the builder callback (same call site as before the refactor), so ECR / non-ECR behaviour, S2 stream creation order, and all downstream side effects are unchanged. ## Non-goals - No new database migrations, no schema changes, no isolation-level / locking changes. A serialisable transaction or advisory lock would also fix this; retry-on-conflict is the smaller change that keeps the existing version-allocation logic intact. - Does not touch the analogous `calculateNextBuildVersion` call in `createBackgroundWorker.server.ts`, which likely has the same race shape against `BackgroundWorker`'s unique constraint — flagged as a follow-up. ## Test plan - [x] `pnpm run typecheck --filter webapp` passes (no new errors in the modified files). - [x] Three real-Postgres tests in `apps/webapp/test/createDeploymentWithNextVersion.test.ts` via `containerTest`: - 5 concurrent calls all produce distinct, persistable versions (`Set(versions).size === concurrency`). The naive read-then-create version of the helper fails this test with the exact same `P2002` seen in production; the retry version passes. - Non-`P2002` errors raised from the `buildData` callback propagate immediately without retry, builder invoked exactly once. - With `maxRetries: 0`, concurrent racers surface the wrapped `DeploymentVersionCollisionError` (not a raw `P2002`); `environmentId`, `attempts`, `lastAttemptedVersion` are populated and `error.cause.code === "P2002"`. - [x] Existing `apps/webapp/test/getDeploymentImageRef.test.ts` still green (the file was untouched in the final diff). ## Follow-ups (not in this PR) - `createBackgroundWorker.server.ts` likely has the same TOCTOU shape against its background-worker version unique constraint — should use the same helper. - Sentry visibility check: confirm `error.cause` chain renders as a linked exception in the Sentry UI when the wrapped error fires (requires a sandboxed triggering of the exhaustion path). Co-authored-by: Claude Opus 4.7 (1M context) --- .../fix-worker-deployment-version-race.md | 6 + .../services/initializeDeployment.server.ts | 147 +++++++++--------- .../createDeploymentWithNextVersion.server.ts | 99 ++++++++++++ .../createDeploymentWithNextVersion.test.ts | 133 ++++++++++++++++ 4 files changed, 311 insertions(+), 74 deletions(-) create mode 100644 .server-changes/fix-worker-deployment-version-race.md create mode 100644 apps/webapp/app/v3/services/initializeDeployment/createDeploymentWithNextVersion.server.ts create mode 100644 apps/webapp/test/createDeploymentWithNextVersion.test.ts diff --git a/.server-changes/fix-worker-deployment-version-race.md b/.server-changes/fix-worker-deployment-version-race.md new file mode 100644 index 00000000000..b0ad7de9e89 --- /dev/null +++ b/.server-changes/fix-worker-deployment-version-race.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Retry on unique-constraint collisions when assigning the next worker deployment version so concurrent deploys to the same environment no longer fail with P2002. diff --git a/apps/webapp/app/v3/services/initializeDeployment.server.ts b/apps/webapp/app/v3/services/initializeDeployment.server.ts index 9ecc25f9941..2f51fe79c73 100644 --- a/apps/webapp/app/v3/services/initializeDeployment.server.ts +++ b/apps/webapp/app/v3/services/initializeDeployment.server.ts @@ -9,13 +9,13 @@ import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; import { generateFriendlyId } from "../friendlyIdentifiers"; import { createRemoteImageBuild, remoteBuildsEnabled } from "../remoteImageBuilder.server"; -import { calculateNextBuildVersion } from "../utils/calculateNextBuildVersion"; import { BaseService, ServiceValidationError } from "./baseService.server"; import { TimeoutDeploymentService } from "./timeoutDeployment.server"; import { getDeploymentImageRef } from "../getDeploymentImageRef.server"; import { tryCatch } from "@trigger.dev/core"; import { getRegistryConfig } from "../registryConfig.server"; import { DeploymentService } from "./deployment.server"; +import { createDeploymentWithNextVersion } from "./initializeDeployment/createDeploymentWithNextVersion.server"; import { errAsync } from "neverthrow"; const nanoid = customAlphabet("1234567890abcdefghijklmnopqrstuvwxyz", 8); @@ -97,18 +97,6 @@ export class InitializeDeploymentService extends BaseService { }); } - const latestDeployment = await this._prisma.workerDeployment.findFirst({ - where: { - environmentId: environment.id, - }, - orderBy: { - createdAt: "desc", - }, - take: 1, - }); - - const nextVersion = calculateNextBuildVersion(latestDeployment?.version); - if (payload.selfHosted && remoteBuildsEnabled()) { throw new ServiceValidationError( "Self-hosted deployments are not supported on this instance" @@ -146,30 +134,6 @@ export class InitializeDeploymentService extends BaseService { const deploymentShortCode = nanoid(8); - const [imageRefError, imageRefResult] = await tryCatch( - getDeploymentImageRef({ - registry: registryConfig, - projectRef: environment.project.externalRef, - nextVersion, - environmentType: environment.type, - deploymentShortCode, - }) - ); - - if (imageRefError) { - logger.error("Failed to get deployment image ref", { - environmentId: environment.id, - projectId: environment.projectId, - version: nextVersion, - triggeredById: triggeredBy?.id, - type: payload.type, - cause: imageRefError.message, - }); - throw new ServiceValidationError("Failed to get deployment image ref"); - } - - const { imageRef, isEcr, repoCreated } = imageRefResult; - // We keep using `BUILDING` as the initial status if not explicitly set // to avoid changing the behavior for deployments not created in the build server. // Native builds always start in the `PENDING` status. @@ -208,20 +172,6 @@ export class InitializeDeploymentService extends BaseService { } : undefined; - logger.debug("Creating deployment", { - environmentId: environment.id, - projectId: environment.projectId, - version: nextVersion, - triggeredById: triggeredBy?.id, - type: payload.type, - imageRef, - isEcr, - repoCreated, - initialStatus, - artifactKey: payload.isNativeBuild ? payload.artifactKey : undefined, - isNativeBuild: payload.isNativeBuild, - }); - const buildServerMetadata: BuildServerMetadata | undefined = payload.isNativeBuild || payload.buildId ? { @@ -238,28 +188,77 @@ export class InitializeDeploymentService extends BaseService { } : undefined; - const deployment = await this._prisma.workerDeployment.create({ - data: { - friendlyId: generateFriendlyId("deployment"), - contentHash: payload.contentHash, - shortCode: deploymentShortCode, - version: nextVersion, - status: initialStatus, - environmentId: environment.id, - projectId: environment.projectId, - externalBuildData, - buildServerMetadata, - triggeredById: triggeredBy?.id, - type: payload.type, - imageReference: imageRef, - imagePlatform: env.DEPLOY_IMAGE_PLATFORM, - git: payload.gitMeta ?? undefined, - commitSHA: payload.gitMeta?.commitSha ?? undefined, - runtime: payload.runtime ?? undefined, - triggeredVia: payload.triggeredVia ?? undefined, - startedAt: initialStatus === "BUILDING" ? new Date() : undefined, - }, - }); + // Concurrent deploys to the same environment race on the + // `(environmentId, version)` unique constraint. The helper retries on + // P2002, recomputing the version (and re-running the image ref call so + // the persisted imageReference always matches the persisted version) + // each attempt. + const deployment = await createDeploymentWithNextVersion( + this._prisma, + environment.id, + async (nextVersion) => { + const [imageRefError, imageRefResult] = await tryCatch( + getDeploymentImageRef({ + registry: registryConfig, + projectRef: environment.project.externalRef, + nextVersion, + environmentType: environment.type, + deploymentShortCode, + }) + ); + + if (imageRefError) { + logger.error("Failed to get deployment image ref", { + environmentId: environment.id, + projectId: environment.projectId, + version: nextVersion, + triggeredById: triggeredBy?.id, + type: payload.type, + cause: imageRefError.message, + }); + throw new ServiceValidationError("Failed to get deployment image ref"); + } + + const { imageRef, isEcr, repoCreated } = imageRefResult; + + logger.debug("Creating deployment", { + environmentId: environment.id, + projectId: environment.projectId, + version: nextVersion, + triggeredById: triggeredBy?.id, + type: payload.type, + imageRef, + isEcr, + repoCreated, + initialStatus, + artifactKey: payload.isNativeBuild ? payload.artifactKey : undefined, + isNativeBuild: payload.isNativeBuild, + }); + + return { + // Regenerated per attempt: each attempt is a fresh `create` that + // must satisfy `WorkerDeployment.friendlyId @unique`, so reusing a + // friendlyId across retries would risk a spurious P2002 on + // friendlyId instead of the version collision we're retrying. + friendlyId: generateFriendlyId("deployment"), + contentHash: payload.contentHash, + shortCode: deploymentShortCode, + status: initialStatus, + projectId: environment.projectId, + externalBuildData, + buildServerMetadata, + triggeredById: triggeredBy?.id, + type: payload.type, + imageReference: imageRef, + imagePlatform: env.DEPLOY_IMAGE_PLATFORM, + git: payload.gitMeta ?? undefined, + commitSHA: payload.gitMeta?.commitSha ?? undefined, + runtime: payload.runtime ?? undefined, + triggeredVia: payload.triggeredVia ?? undefined, + startedAt: initialStatus === "BUILDING" ? new Date() : undefined, + }; + } + ); const timeoutMs = deployment.status === "PENDING" ? env.DEPLOY_QUEUE_TIMEOUT_MS : env.DEPLOY_TIMEOUT_MS; @@ -309,7 +308,7 @@ export class InitializeDeploymentService extends BaseService { return { deployment, - imageRef, + imageRef: deployment.imageReference ?? "", eventStream, }; }); diff --git a/apps/webapp/app/v3/services/initializeDeployment/createDeploymentWithNextVersion.server.ts b/apps/webapp/app/v3/services/initializeDeployment/createDeploymentWithNextVersion.server.ts new file mode 100644 index 00000000000..0a7e54c2947 --- /dev/null +++ b/apps/webapp/app/v3/services/initializeDeployment/createDeploymentWithNextVersion.server.ts @@ -0,0 +1,99 @@ +import { + isUniqueConstraintError, + type Prisma, + type PrismaClientOrTransaction, + type WorkerDeployment, +} from "@trigger.dev/database"; +import { setTimeout as sleep } from "node:timers/promises"; +import { logger } from "~/services/logger.server"; +import { calculateNextBuildVersion } from "../../utils/calculateNextBuildVersion"; + +export type CreateDeploymentData = Omit< + Prisma.WorkerDeploymentUncheckedCreateInput, + "version" | "environmentId" +>; + +export type CreateDeploymentWithNextVersionOptions = { + maxRetries?: number; + jitterMs?: { min: number; max: number }; +}; + +const DEFAULT_MAX_RETRIES = 5; +const DEFAULT_JITTER_MS = { min: 5, max: 50 }; + +export class DeploymentVersionCollisionError extends Error { + readonly name = "DeploymentVersionCollisionError"; + readonly environmentId: string; + readonly attempts: number; + readonly lastAttemptedVersion: string; + + constructor(args: { + environmentId: string; + attempts: number; + lastAttemptedVersion: string; + cause: unknown; + }) { + super( + `Failed to allocate a unique worker deployment version for environment ${args.environmentId} after ${args.attempts} attempt(s); last tried "${args.lastAttemptedVersion}"`, + { cause: args.cause } + ); + this.environmentId = args.environmentId; + this.attempts = args.attempts; + this.lastAttemptedVersion = args.lastAttemptedVersion; + } +} + +export async function createDeploymentWithNextVersion( + prisma: PrismaClientOrTransaction, + environmentId: string, + buildData: (nextVersion: string) => CreateDeploymentData | Promise, + options: CreateDeploymentWithNextVersionOptions = {} +): Promise { + const maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES; + const jitterMs = options.jitterMs ?? DEFAULT_JITTER_MS; + + let lastError: unknown; + let lastVersion = ""; + + for (let attempt = 0; attempt <= maxRetries; attempt++) { + const latest = await prisma.workerDeployment.findFirst({ + where: { environmentId }, + orderBy: { createdAt: "desc" }, + take: 1, + }); + + const version = calculateNextBuildVersion(latest?.version); + lastVersion = version; + const data = await buildData(version); + + try { + return await prisma.workerDeployment.create({ + data: { ...data, environmentId, version }, + }); + } catch (error) { + if (!isUniqueConstraintError(error, ["environmentId", "version"])) { + throw error; + } + + lastError = error; + logger.warn("Worker deployment version collided, retrying", { + environmentId, + attempt: attempt + 1, + maxRetries, + attemptedVersion: version, + }); + + // Randomised backoff so N concurrent racers don't loop in lockstep into the + // same collision again. + const delay = jitterMs.min + Math.random() * (jitterMs.max - jitterMs.min); + await sleep(delay); + } + } + + throw new DeploymentVersionCollisionError({ + environmentId, + attempts: maxRetries + 1, + lastAttemptedVersion: lastVersion, + cause: lastError, + }); +} diff --git a/apps/webapp/test/createDeploymentWithNextVersion.test.ts b/apps/webapp/test/createDeploymentWithNextVersion.test.ts new file mode 100644 index 00000000000..aa135b3a316 --- /dev/null +++ b/apps/webapp/test/createDeploymentWithNextVersion.test.ts @@ -0,0 +1,133 @@ +import { containerTest } from "@internal/testcontainers"; +import { PrismaClient } from "@trigger.dev/database"; +import { describe, expect, vi } from "vitest"; +import { + createDeploymentWithNextVersion, + DeploymentVersionCollisionError, +} from "~/v3/services/initializeDeployment/createDeploymentWithNextVersion.server"; + +vi.setConfig({ testTimeout: 30_000 }); + +async function seedEnvironment(prisma: PrismaClient) { + const slug = `s${Math.random().toString(36).slice(2, 10)}`; + const organization = await prisma.organization.create({ + data: { title: slug, slug }, + }); + + const project = await prisma.project.create({ + data: { + name: slug, + slug, + organizationId: organization.id, + externalRef: slug, + }, + }); + + const environment = await prisma.runtimeEnvironment.create({ + data: { + slug, + type: "PRODUCTION", + projectId: project.id, + organizationId: organization.id, + apiKey: slug, + pkApiKey: slug, + shortcode: slug, + }, + }); + + return { organization, project, environment }; +} + +describe("createDeploymentWithNextVersion", () => { + containerTest( + "assigns unique sequential versions for concurrent calls in the same environment", + async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + + const concurrency = 5; + + const results = await Promise.all( + Array.from({ length: concurrency }, (_, i) => + createDeploymentWithNextVersion(prisma, environment.id, (nextVersion) => ({ + projectId: project.id, + friendlyId: `deployment_${i}_${nextVersion}`, + shortCode: `short_${i}_${nextVersion}`, + contentHash: `hash_${i}`, + })) + ) + ); + + // The property we care about: N concurrent racers all end up with + // distinct, persistable versions. The retry path is exercised whenever + // collisions happen (visible in the `Worker deployment version collided` + // warn logs), and the exhaustion branch is covered deterministically by + // the maxRetries: 0 test below. + const versions = results.map((d) => d.version).sort(); + expect(new Set(versions).size).toBe(concurrency); + } + ); + + containerTest( + "propagates non-P2002 errors immediately without retrying", + async ({ prisma }) => { + const { environment } = await seedEnvironment(prisma); + + let buildDataCalls = 0; + const buildData = () => { + buildDataCalls++; + throw new Error("builder boom"); + }; + + await expect( + createDeploymentWithNextVersion(prisma, environment.id, buildData) + ).rejects.toThrow("builder boom"); + + expect(buildDataCalls).toBe(1); + } + ); + + containerTest( + "wraps exhausted retries in DeploymentVersionCollisionError with the P2002 as cause", + async ({ prisma }) => { + const { project, environment } = await seedEnvironment(prisma); + + const concurrency = 4; + // maxRetries: 0 → no retry path. Concurrent racers all attempt the same + // version; one wins, the rest must surface the wrapped collision error + // (not a raw P2002) so Sentry can distinguish exhaustion from any other + // unique-constraint violation. + const settled = await Promise.allSettled( + Array.from({ length: concurrency }, (_, i) => + createDeploymentWithNextVersion( + prisma, + environment.id, + (nextVersion) => ({ + projectId: project.id, + friendlyId: `deployment_${i}_${nextVersion}`, + shortCode: `short_${i}_${nextVersion}`, + contentHash: `hash_${i}`, + }), + { maxRetries: 0 } + ) + ) + ); + + const fulfilled = settled.filter((s) => s.status === "fulfilled"); + const rejected = settled.filter( + (s): s is PromiseRejectedResult => s.status === "rejected" + ); + + expect(fulfilled.length).toBeGreaterThanOrEqual(1); + expect(rejected.length).toBeGreaterThanOrEqual(1); + + for (const r of rejected) { + expect(r.reason).toBeInstanceOf(DeploymentVersionCollisionError); + const err = r.reason as DeploymentVersionCollisionError; + expect(err.environmentId).toBe(environment.id); + expect(err.attempts).toBe(1); + expect(err.lastAttemptedVersion).toMatch(/^\d{8}\.\d+$/); + expect((err.cause as { code?: string }).code).toBe("P2002"); + } + } + ); +}); From 23ff763359ba8939687224f25ddf91006ee84e02 Mon Sep 17 00:00:00 2001 From: Eric Allam Date: Sun, 10 May 2026 22:28:40 +0100 Subject: [PATCH 017/238] feat(webapp): agent-view dashboard for chat.agent runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dashboard surfaces for inspecting and debugging chat.agent runs. Depends on the Sessions primitive (L1) and chat.agent runtime (L2+L3). Run inspector — chat-aware: - AgentView + AgentMessageView (run inspector tab for chat.agent runs) - AIChatMessages + AISpanDetails + types.ts (per-span chat message rendering, tool-call/tool-output handling) - PromptSpanDetails (gen_ai.* span detail panel) - StreamdownRenderer + shikiTheme (markdown renderer with shiki highlighting and v2 patch) - useAutoScrollToBottom hook Playground UI (interactive chat.agent debugger): - /playground index + /playground/$agentParam routes - /agents route + AgentListPresenter - PlaygroundPresenter (per-org basin variants, clientData wiring) - realtime session routes for playground + run inspector chat - AI-generate-payload + AIPayloadTabContent for the test panel Navigation + theming: - SideMenu links for Agents and Playground - BlankStatePanels copy updates - tailwind config + tailwind.css storybook hooks - streamdown@2 dep in apps/webapp/package.json Includes agent-view-sessions, playground-trigger-config-fields, run-agent-view, and streamdown-v2-upgrade .server-changes. --- .server-changes/agent-view-sessions.md | 12 + .../playground-trigger-config-fields.md | 8 + .server-changes/run-agent-view.md | 6 + .server-changes/streamdown-v2-upgrade.md | 6 + .../app/components/navigation/SideMenu.tsx | 30 + .../runs/v3/agent/AgentMessageView.tsx | 246 ++++ .../components/runs/v3/agent/AgentView.tsx | 717 ++++++++++ .../webapp/app/hooks/useAutoScrollToBottom.ts | 104 ++ .../v3/AgentListPresenter.server.ts | 288 ++++ .../v3/PlaygroundPresenter.server.ts | 147 ++ .../presenters/v3/SessionPresenter.server.ts | 153 ++ .../app/presenters/v3/SpanPresenter.server.ts | 50 + .../route.tsx | 360 +++++ .../route.tsx | 1238 +++++++++++++++++ .../route.tsx | 189 +++ .../route.tsx | 539 +++++++ .../SchemaTabContent.tsx | 26 +- ...tParam.env.$envParam.playground.action.tsx | 307 ++++ ...ealtime.v1.sessions.$session.$io.append.ts | 163 +++ ...round.realtime.v1.sessions.$session.$io.ts | 91 ++ ...ram.realtime.v1.sessions.$sessionId.$io.ts | 118 ++ ...am.realtime.v1.streams.$runId.$streamId.ts | 91 ++ ...ltime.v1.streams.$runId.input.$streamId.ts | 92 ++ .../route.tsx | 49 +- .../route.tsx | 43 +- ....sessions.$sessionParam.realtime.v1.$io.ts | 83 ++ ...env.$envParam.test.ai-generate-payload.tsx | 63 +- .../app/routes/storybook.streamdown/route.tsx | 117 ++ apps/webapp/app/routes/storybook/route.tsx | 4 + apps/webapp/app/tailwind.css | 17 +- apps/webapp/tailwind.config.js | 14 +- 31 files changed, 5345 insertions(+), 26 deletions(-) create mode 100644 .server-changes/agent-view-sessions.md create mode 100644 .server-changes/playground-trigger-config-fields.md create mode 100644 .server-changes/run-agent-view.md create mode 100644 .server-changes/streamdown-v2-upgrade.md create mode 100644 apps/webapp/app/components/runs/v3/agent/AgentMessageView.tsx create mode 100644 apps/webapp/app/components/runs/v3/agent/AgentView.tsx create mode 100644 apps/webapp/app/hooks/useAutoScrollToBottom.ts create mode 100644 apps/webapp/app/presenters/v3/AgentListPresenter.server.ts create mode 100644 apps/webapp/app/presenters/v3/PlaygroundPresenter.server.ts create mode 100644 apps/webapp/app/presenters/v3/SessionPresenter.server.ts create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents/route.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground/route.tsx create mode 100644 apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam/route.tsx create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.action.tsx create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.sessions.$session.$io.append.ts create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.realtime.v1.sessions.$session.$io.ts create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.sessions.$sessionId.$io.ts create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.streams.$runId.$streamId.ts create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.realtime.v1.streams.$runId.input.$streamId.ts create mode 100644 apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.sessions.$sessionParam.realtime.v1.$io.ts create mode 100644 apps/webapp/app/routes/storybook.streamdown/route.tsx diff --git a/.server-changes/agent-view-sessions.md b/.server-changes/agent-view-sessions.md new file mode 100644 index 00000000000..757dcdc2f40 --- /dev/null +++ b/.server-changes/agent-view-sessions.md @@ -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. diff --git a/.server-changes/playground-trigger-config-fields.md b/.server-changes/playground-trigger-config-fields.md new file mode 100644 index 00000000000..8a811c9dd1d --- /dev/null +++ b/.server-changes/playground-trigger-config-fields.md @@ -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. diff --git a/.server-changes/run-agent-view.md b/.server-changes/run-agent-view.md new file mode 100644 index 00000000000..90833d9729f --- /dev/null +++ b/.server-changes/run-agent-view.md @@ -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. diff --git a/.server-changes/streamdown-v2-upgrade.md b/.server-changes/streamdown-v2-upgrade.md new file mode 100644 index 00000000000..8a0b3f17af0 --- /dev/null +++ b/.server-changes/streamdown-v2-upgrade.md @@ -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. diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index bee2ccb3d1e..b8cf5c8e72e 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -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")} > + + + + {messages.map((msg) => ( + + ))} +
+ ); +} + +// 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 ( +
+
+
{text}
+
+
+ ); + } + + if (message.role === "assistant") { + const hasContent = message.parts && message.parts.length > 0; + if (!hasContent) return null; + + return ( +
+ {message.parts?.map((part, i) => renderPart(part, i))} +
+ ); + } + + 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 ? : null; + } + + // Reasoning — amber-bordered italic block + if (type === "reasoning") { + return ( +
+ +
+ {p.text ?? ""} +
+
+
+ ); + } + + // 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 ( + + ); + } + + // Source URL — clickable citation link + if (type === "source-url") { + return ( + + ); + } + + // Source document — citation label + if (type === "source-document") { + return ( +
+ {p.title} + {p.mediaType ? ` (${p.mediaType})` : ""} +
+ ); + } + + // 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 ( + {p.filename + ); + } + return ( + + ); + } + + // Step start — subtle dashed separator with centered label + if (type === "step-start") { + return ( +
+
+ step +
+
+ ); + } + + // Data parts — type: "data-{name}", show as labeled JSON popover + if (type.startsWith("data-")) { + const dataName = type.slice(5); + return ; + } + + return null; +} + +function DataPartPopover({ name, data }: { name: string; data: unknown }) { + const formatted = JSON.stringify(data, null, 2); + + return ( + + + + + +
+ data-{name} +
+
+
{formatted}
+
+
+
+ ); +} diff --git a/apps/webapp/app/components/runs/v3/agent/AgentView.tsx b/apps/webapp/app/components/runs/v3/agent/AgentView.tsx new file mode 100644 index 00000000000..eee7646d03f --- /dev/null +++ b/apps/webapp/app/components/runs/v3/agent/AgentView.tsx @@ -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 ( +
+ {messages.length === 0 ? ( +
+
+ + + Loading conversation… + +
+
+ ) : ( + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// 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; + activeReasoningPartIndexes: Map; +}; + +/** + * `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 | null { + if (raw == null) return null; + if (typeof raw === "string") { + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? (parsed as Record) : null; + } catch { + return null; + } + } + if (typeof raw === "object") return raw as Record; + 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>( + new Map(seedMessages.map((m) => [m.id, m])) + ); + const timestampsRef = useRef>( + 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>(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>( + () => 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(0); + const pendingTimerRef = useRef | 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 }).metadata ?? {}), + ...(chunk.messageMetadata as Record), + }, + } 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 }).metadata ?? {}), + ...(chunk.messageMetadata as Record), + }, + } 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; +} diff --git a/apps/webapp/app/hooks/useAutoScrollToBottom.ts b/apps/webapp/app/hooks/useAutoScrollToBottom.ts new file mode 100644 index 00000000000..b8e59687ed6 --- /dev/null +++ b/apps/webapp/app/hooks/useAutoScrollToBottom.ts @@ -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 ( + *
+ *
+ * {messages.map((m) => )} + *
+ *
+ * ); + * } + * ``` + */ +export function useAutoScrollToBottom(deps: ReadonlyArray) { + const rootRef = useRef(null); + const containerRef = useRef(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; +} diff --git a/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts b/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts new file mode 100644 index 00000000000..d34f7393884 --- /dev/null +++ b/apps/webapp/app/presenters/v3/AgentListPresenter.server.ts @@ -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), + conversationSparklines: Promise.resolve({} as Record), + costSparklines: Promise.resolve({} as Record), + tokenSparklines: Promise.resolve({} as Record), + }; + } + + 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), + conversationSparklines: Promise.resolve({} as Record), + costSparklines: Promise.resolve({} as Record), + tokenSparklines: Promise.resolve({} as Record), + }; + } + + // 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> { + 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 = {}; + 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> { + 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> { + 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> { + 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 { + 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 { + 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(); + for (const row of rows) { + rowMap.set(`${row.task_identifier}|${row.bucket}`, row.val); + } + + const result: Record = {}; + 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); +} diff --git a/apps/webapp/app/presenters/v3/PlaygroundPresenter.server.ts b/apps/webapp/app/presenters/v3/PlaygroundPresenter.server.ts new file mode 100644 index 00000000000..656bc425cdf --- /dev/null +++ b/apps/webapp/app/presenters/v3/PlaygroundPresenter.server.ts @@ -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 { + 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 { + 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 { + 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(); diff --git a/apps/webapp/app/presenters/v3/SessionPresenter.server.ts b/apps/webapp/app/presenters/v3/SessionPresenter.server.ts new file mode 100644 index 00000000000..27807971d5a --- /dev/null +++ b/apps/webapp/app/presenters/v3/SessionPresenter.server.ts @@ -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>>; + +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: [], + }, + }; + } +} diff --git a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts index de41aee4411..ffa4ea71842 100644 --- a/apps/webapp/app/presenters/v3/SpanPresenter.server.ts +++ b/apps/webapp/app/presenters/v3/SpanPresenter.server.ts @@ -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: { diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents/route.tsx new file mode 100644 index 00000000000..deedafd9879 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.agents/route.tsx @@ -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(); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const { filterText, setFilterText, filteredItems } = useFuzzyFilter({ + items: agents, + keys: ["slug", "filePath"], + }); + + if (agents.length === 0) { + return ( + + + + + + +
+ + No agents deployed + + Create a chat agent using chat.agent() from{" "} + @trigger.dev/sdk/ai and deploy it to see it here. + +
+
+
+
+ ); + } + + return ( + + + + + +
+
+
+
+ setFilterText(e.target.value)} + autoFocus + /> +
+ + + + ID + Type + File + Active + Conversations (24h) + Cost (24h) + Tokens (24h) + Go to page + + + + {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 ( + + +
+ + } + content="Agent" + /> + {agent.slug} +
+
+ + {formatAgentType(agentType)} + + + + + + }> + –}> + {(data) => { + const state = data[agent.slug]; + if (!state || (state.running === 0 && state.suspended === 0)) { + return ( + + ); + } + return ( + + {state.running > 0 && ( + + + {state.running} + + )} + {state.running > 0 && state.suspended > 0 && ( + · + )} + {state.suspended > 0 && ( + + + {state.suspended} + + )} + + ); + }} + + + + + }> + –}> + {(data) => ( + + )} + + + + + }> + –}> + {(data) => ( + + )} + + + + + }> + –}> + {(data) => ( + + )} + + + + + + + + } + hiddenButtons={ + + Playground + + } + /> +
+ ); + }) + ) : ( + + + No agents match your filters + + + )} +
+
+
+
+
+
+
+ ); +} + +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
; +} + +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 ; + } + + const total = data.reduce((sum, v) => sum + v, 0); + const max = Math.max(...data); + + return ( +
+
+ {data.map((value, i) => { + const height = max > 0 ? Math.max((value / max) * 100, value > 0 ? 8 : 0) : 0; + return ( +
0 ? barColor : "transparent", + opacity: value > 0 ? 0.8 : 0, + }} + /> + ); + })} +
+ {formatTotal(total)} +
+ ); +} diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx new file mode 100644 index 00000000000..7c37089bf63 --- /dev/null +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground.$agentParam/route.tsx @@ -0,0 +1,1238 @@ +import { + ArrowUpIcon, + BoltIcon, + CpuChipIcon, + StopIcon, + ArrowPathIcon, + TrashIcon, +} from "@heroicons/react/20/solid"; +import { type MetaFunction } from "@remix-run/node"; +import { Link, useFetcher, useNavigate, useRouteLoaderData } from "@remix-run/react"; +import { typedjson, useTypedLoaderData } from "remix-typedjson"; +import { type LoaderFunctionArgs } from "@remix-run/server-runtime"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useChat } from "@ai-sdk/react"; +import { TriggerChatTransport } from "@trigger.dev/sdk/chat"; +import { MainCenteredContainer } from "~/components/layout/AppLayout"; +import { Badge } from "~/components/primitives/Badge"; +import { Button, LinkButton } from "~/components/primitives/Buttons"; +import { CopyButton } from "~/components/primitives/CopyButton"; +import { DurationPicker } from "~/components/primitives/DurationPicker"; +import { Header3 } from "~/components/primitives/Headers"; +import { Hint } from "~/components/primitives/Hint"; +import { Input } from "~/components/primitives/Input"; +import { InputGroup } from "~/components/primitives/InputGroup"; +import { Label } from "~/components/primitives/Label"; +import { Paragraph } from "~/components/primitives/Paragraph"; +import { Spinner } from "~/components/primitives/Spinner"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover"; +import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon"; +import type { PlaygroundConversation } from "~/presenters/v3/PlaygroundPresenter.server"; +import { DateTime } from "~/components/primitives/DateTime"; +import { cn } from "~/utils/cn"; +import { JSONEditor } from "~/components/code/JSONEditor"; +import { ToolUseRow, AssistantResponse, ChatBubble } from "~/components/runs/v3/ai/AIChatMessages"; +import { MessageBubble } from "~/components/runs/v3/agent/AgentMessageView"; +import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "~/components/primitives/Resizable"; +import { + ClientTabs, + ClientTabsContent, + ClientTabsList, + ClientTabsTrigger, +} from "~/components/primitives/ClientTabs"; +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 { requireUserId } from "~/services/session.server"; +import { RunTagInput } from "~/components/runs/v3/RunTagInput"; +import { Select, SelectItem } from "~/components/primitives/Select"; +import { EnvironmentParamSchema, v3PlaygroundAgentPath } from "~/utils/pathBuilder"; +import { env as serverEnv } from "~/env.server"; +import { generateJWT as internal_generateJWT, MachinePresetName } from "@trigger.dev/core/v3"; +import { extractJwtSigningSecretKey } from "~/services/realtime/jwtAuth.server"; +import { SchemaTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/SchemaTabContent"; +import { AIPayloadTabContent } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.test.tasks.$taskParam/AIPayloadTabContent"; +import type { UIMessage } from "@ai-sdk/react"; + +export const meta: MetaFunction = () => { + return [{ title: "Playground | Trigger.dev" }]; +}; + +export const loader = async ({ request, params }: LoaderFunctionArgs) => { + const userId = await requireUserId(request); + const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params); + const agentSlug = params.agentParam; + + if (!agentSlug) { + throw new Response(undefined, { status: 404, statusText: "Agent not specified" }); + } + + 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 agent = await playgroundPresenter.getAgent({ + environmentId: environment.id, + environmentType: environment.type, + agentSlug, + }); + + if (!agent) { + throw new Response(undefined, { status: 404, statusText: "Agent not found" }); + } + + const agentConfig = agent.config as { type?: string } | null; + const apiOrigin = serverEnv.API_ORIGIN || serverEnv.LOGIN_ORIGIN || "http://localhost:3030"; + + const recentConversations = await playgroundPresenter.getRecentConversations({ + environmentId: environment.id, + agentSlug, + userId, + }); + + // Check for ?conversation= param to resume an existing conversation + const url = new URL(request.url); + const conversationId = url.searchParams.get("conversation"); + + let activeConversation: { + chatId: string; + runFriendlyId: string | null; + publicAccessToken: string | null; + clientData: unknown; + messages: unknown; + lastEventId: string | null; + } | null = null; + + if (conversationId) { + const conv = recentConversations.find((c) => c.id === conversationId); + if (conv) { + let jwt: string | null = null; + if (conv.isActive && conv.runFriendlyId) { + jwt = await internal_generateJWT({ + secretKey: extractJwtSigningSecretKey(environment), + payload: { + sub: environment.id, + pub: true, + scopes: [`read:runs:${conv.runFriendlyId}`, `write:inputStreams:${conv.runFriendlyId}`], + }, + expirationTime: "1h", + }); + } + + activeConversation = { + chatId: conv.chatId, + runFriendlyId: conv.runFriendlyId, + publicAccessToken: jwt, + clientData: conv.clientData, + messages: conv.messages, + lastEventId: conv.lastEventId, + }; + } + } + + return typedjson({ + agent: { + slug: agent.slug, + filePath: agent.filePath, + type: agentConfig?.type ?? "unknown", + clientDataSchema: agent.payloadSchema ?? null, + }, + apiOrigin, + recentConversations, + activeConversation, + }); +}; + +export default function PlaygroundAgentPage() { + const { agent, activeConversation } = useTypedLoaderData(); + // Key on agent slug + conversation chatId so React remounts all stateful + // children when switching agents or navigating between conversations. + // Without the agent slug, switching agents keeps key="new" and React + // reuses the component — useState initializers don't re-run. + const conversationKey = `${agent.slug}:${activeConversation?.chatId ?? "new"}`; + return ; +} + +const PARENT_ROUTE_ID = + "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.playground"; + +function PlaygroundChat() { + const { agent, apiOrigin, recentConversations, activeConversation } = + useTypedLoaderData(); + const parentData = useRouteLoaderData(PARENT_ROUTE_ID) as + | { + agents: Array<{ slug: string }>; + versions: string[]; + regions: Array<{ + id: string; + name: string; + description?: string; + isDefault: boolean; + }>; + isDev: boolean; + } + | undefined; + const agents = parentData?.agents ?? []; + const versions = parentData?.versions ?? []; + const regions = parentData?.regions ?? []; + const isDev = parentData?.isDev ?? false; + const defaultRegion = regions.find((r) => r.isDefault); + const navigate = useNavigate(); + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + const [conversationId, setConversationId] = useState(() => + activeConversation + ? recentConversations.find((c) => c.chatId === activeConversation.chatId)?.id ?? null + : null + ); + const [chatId, setChatId] = useState(() => activeConversation?.chatId ?? crypto.randomUUID()); + const [clientDataJson, setClientDataJson] = useState(() => + activeConversation?.clientData ? JSON.stringify(activeConversation.clientData, null, 2) : "{}" + ); + const clientDataJsonRef = useRef(clientDataJson); + clientDataJsonRef.current = clientDataJson; + const [machine, setMachine] = useState(undefined); + const [tags, setTags] = useState([]); + const [maxAttempts, setMaxAttempts] = useState(undefined); + const [maxDuration, setMaxDuration] = useState(undefined); + const [version, setVersion] = useState(undefined); + const [region, setRegion] = useState(() => + isDev ? undefined : defaultRegion?.name + ); + + const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/playground/action`; + + // Server-side `start` via Remix action — atomically creates the + // backing Session for `chatId` and triggers the first run, returns + // the session-scoped PAT. Idempotent: called on initial use AND on + // 401, so the same code path serves both first-run and PAT renewal. + const startSession = useCallback( + async (): Promise => { + const formData = new FormData(); + formData.set("intent", "start"); + formData.set("agentSlug", agent.slug); + formData.set("chatId", chatId); + formData.set("clientData", clientDataJsonRef.current); + if (tags.length > 0) formData.set("tags", tags.join(",")); + if (machine) formData.set("machine", machine); + if (maxAttempts) formData.set("maxAttempts", String(maxAttempts)); + if (maxDuration) formData.set("maxDuration", String(maxDuration)); + if (version) formData.set("version", version); + if (region) formData.set("region", region); + + const response = await fetch(actionPath, { method: "POST", body: formData }); + const data = (await response.json()) as { + runId?: string; + publicAccessToken?: string; + conversationId?: string; + error?: string; + }; + + if (!response.ok || !data.publicAccessToken) { + throw new Error(data.error ?? "Failed to start chat session"); + } + + if (data.conversationId) { + setConversationId(data.conversationId); + } + + return data.publicAccessToken; + }, + [actionPath, agent.slug, chatId, tags, machine, maxAttempts, maxDuration, version, region] + ); + + // Resource route prefix — all realtime traffic goes through session-authed routes + const playgroundBaseURL = `${apiOrigin}/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/playground`; + + // The transport is constructed once (guarded ref below); reading + // `startSession` directly there would freeze its closure to the + // first render's sidebar values, so subsequent edits to tags / + // machine / maxAttempts / maxDuration / version / region would be + // silently ignored on the first send. Mirror the `clientDataJsonRef` + // pattern so the transport always calls the latest `startSession`. + const startSessionRef = useRef(startSession); + startSessionRef.current = startSession; + + // Create TriggerChatTransport directly (not via useTriggerChatTransport hook + // to avoid React version mismatch between SDK and webapp) + const transportRef = useRef(null); + if (transportRef.current === null) { + transportRef.current = new TriggerChatTransport({ + task: agent.slug, + // The Remix action is idempotent on `(env, externalId)` and + // returns a fresh session PAT every time, so it serves both + // first-run create and PAT renewal. `startSession` runs on + // `transport.preload(chatId)` and lazily on the first + // `sendMessage`; `accessToken` runs on a 401/403 from any + // session-PAT-authed request. Wiring the same call to both + // keeps the Preload button working without a separate refresh + // route. + startSession: async () => ({ publicAccessToken: await startSessionRef.current() }), + accessToken: () => startSessionRef.current(), + baseURL: playgroundBaseURL, + // Use safeParseJson so a mid-edit invalid JSON state in the editor + // doesn't throw and crash the component during transport construction. + clientData: safeParseJson(clientDataJson), + ...(activeConversation?.publicAccessToken + ? { + sessions: { + [activeConversation.chatId]: { + publicAccessToken: activeConversation.publicAccessToken, + lastEventId: activeConversation.lastEventId ?? undefined, + }, + }, + } + : {}), + }); + } + const transport = transportRef.current; + + // Keep the transport's `defaultMetadata` in sync with the JSON editor. + // Without this the transport uses the value captured at construction for + // every per-turn metadata merge, even after the user edits the JSON. + // `startSession` reads from `clientDataJsonRef.current` directly so session + // creation is unaffected — this only fixes the per-turn metadata path. + useEffect(() => { + // JSONEditor fires onChange on every keystroke — intermediate values + // like `{"key":` are syntactically invalid. `safeParseJson` returns + // `{}` on parse failure so the next valid keystroke lands the update + // without crashing the component mid-edit. + transport.setClientData(safeParseJson(clientDataJson)); + }, [clientDataJson, transport]); + + // Initial messages from persisted conversation (for resume) + const initialMessages = activeConversation?.messages + ? (activeConversation.messages as UIMessage[]) + : []; + + // Track the initial message count so we only save after genuinely new turns + // (not during resume replay which re-fires onFinish for replayed turns) + const initialMessageCountRef = useRef(initialMessages?.length ?? 0); + + // Save messages after each turn completes + const saveMessages = useCallback( + (allMessages: UIMessage[]) => { + // Skip saves during resume replay — only save when we have more messages than we started with + if (allMessages.length <= initialMessageCountRef.current) return; + + const currentSession = transport.getSession(chatId); + const lastEventId = currentSession?.lastEventId; + + const formData = new FormData(); + formData.set("intent", "save"); + formData.set("agentSlug", agent.slug); + formData.set("chatId", chatId); + formData.set("messages", JSON.stringify(allMessages)); + if (lastEventId) formData.set("lastEventId", lastEventId); + + // Fire and forget + fetch(actionPath, { method: "POST", body: formData }).catch(() => {}); + + // Update the baseline so subsequent saves work correctly + initialMessageCountRef.current = allMessages.length; + }, + [chatId, agent.slug, actionPath, transport] + ); + + // useChat from AI SDK — handles message accumulation, streaming, stop + const { messages, sendMessage, stop, status, error } = useChat({ + id: chatId, + messages: initialMessages, + transport, + onFinish: ({ messages: allMessages }) => { + saveMessages(allMessages); + }, + }); + + const isStreaming = status === "streaming"; + const isSubmitted = status === "submitted"; + + // Sticky-bottom auto-scroll for the messages list. The hook walks up to + // the surrounding `overflow-y-auto` panel and follows the conversation + // as new chunks stream in — pauses if you scroll up to read history, + // resumes when you scroll back into the bottom band. Same behavior as + // the run-inspector Agent tab. + const messagesRootRef = useAutoScrollToBottom([messages, isSubmitted]); + + // Pending messages — steering during streaming + const pending = usePlaygroundPendingMessages({ + transport, + chatId, + status, + messages, + sendMessage, + metadata: safeParseJson(clientDataJson), + }); + + const [input, setInput] = useState(""); + const [preloading, setPreloading] = useState(false); + const [preloaded, setPreloaded] = useState(false); + const inputRef = useRef(null); + + const session = transport.getSession(chatId); + + const handlePreload = useCallback(async () => { + setPreloading(true); + try { + await transport.preload(chatId); + setPreloaded(true); + inputRef.current?.focus(); + } finally { + setPreloading(false); + } + }, [transport, chatId]); + + const handleNewConversation = useCallback(() => { + // Navigate without ?conversation= so the loader returns activeConversation=null + // and the key changes to "new", causing a full remount with fresh state. + navigate(window.location.pathname); + }, [navigate]); + + const handleDeleteConversation = useCallback(async () => { + if (!conversationId) return; + + const formData = new FormData(); + formData.set("intent", "delete"); + formData.set("agentSlug", agent.slug); + formData.set("deleteConversationId", conversationId); + + await fetch(actionPath, { method: "POST", body: formData }); + handleNewConversation(); + }, [conversationId, agent.slug, actionPath, handleNewConversation]); + + const handleSend = useCallback(() => { + const trimmed = input.trim(); + if (!trimmed) return; + + setInput(""); + // steer() handles both cases: sends via input stream during streaming, + // or sends as a normal message when ready + pending.steer(trimmed); + }, [input, pending]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [handleSend] + ); + + return ( + + +
+ {/* Header */} +
+
+ + {formatAgentType(agent.type)} +
+
+ {activeConversation?.runFriendlyId && ( + + View run + + )} + {messages.length > 0 && ( + + Copy raw + + )} + + {conversationId && ( + +
+
+ + {/* Messages */} +
+ {/* Always-mounted scroll-target wrapper so useAutoScrollToBottom + can find its container from `rootRef.current.parentElement` + on mount, even before any messages exist. */} +
+ {messages.length === 0 ? ( + +
+ {preloaded ? ( + <> + + Preloaded + + Agent is warmed up and waiting. Type a message below to start. + + + ) : ( + <> + + Start a conversation + + Type a message below to start testing{" "} + {agent.slug} + + {!session && ( + + )} + + )} +
+
+ ) : ( +
+ {messages.map((msg) => ( + + ))} + {isSubmitted && ( +
+
+ + Thinking... +
+
+ )} +
+ )} +
+
+ + {/* Error */} + {error && ( +
+ {error.message} +
+ )} + + {/* Input */} +
+
+