diff --git a/.changeset/config.json b/.changeset/config.json index ec99f415aa6..f135929f2cd 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,11 +1,23 @@ { "$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json", - "changelog": "@changesets/cli/changelog", + "changelog": [ + "@remix-run/changelog-github", + { + "repo": "triggerdotdev/trigger.dev" + } + ], "commit": false, - "fixed": [], + "fixed": [["@trigger.dev/*", "trigger.dev"]], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["wss", "webapp"] + "ignore": [ + "webapp", + "supervisor", + "@trigger.dev/plugins" + ], + "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { + "onlyUpdatePeerDependentsWhenOutOfRange": true + } } diff --git a/.changeset/experimental-node-runtimes.md b/.changeset/experimental-node-runtimes.md new file mode 100644 index 00000000000..39ec1632129 --- /dev/null +++ b/.changeset/experimental-node-runtimes.md @@ -0,0 +1,6 @@ +--- +"@trigger.dev/core": patch +"trigger.dev": patch +--- + +Add experimental Node.js 24 and 26 task runtimes. Set `runtime` to `experimental-node-24` or `experimental-node-26` in `trigger.config.ts`. diff --git a/.changeset/project-default-region-response.md b/.changeset/project-default-region-response.md new file mode 100644 index 00000000000..86d1856ac50 --- /dev/null +++ b/.changeset/project-default-region-response.md @@ -0,0 +1,5 @@ +--- +"@trigger.dev/core": patch +--- + +Add `defaultRegion` to the project GET and list API responses; null when unset. diff --git a/.claude/REVIEW.md b/.claude/REVIEW.md new file mode 100644 index 00000000000..3bf89ff1931 --- /dev/null +++ b/.claude/REVIEW.md @@ -0,0 +1,82 @@ +# 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 ๐Ÿ”ด. +- **ClickHouse migration ordering + idempotency.** Goose runs in strict mode in the deploy pipeline and refuses to apply a missing version below the current version โ€” slotting a new file in below the latest already-applied version blocks the deploy. New ClickHouse migration files MUST use the next available number (`max(files in internal-packages/clickhouse/schema/) + 1`); if main has added migrations while you've been on a branch, renumber yours. DDL must also be idempotent (`ADD COLUMN IF NOT EXISTS`, `DROP COLUMN IF EXISTS`, `CREATE TABLE IF NOT EXISTS`, `ADD INDEX IF NOT EXISTS`) so a partial / `--allow-missing` apply elsewhere doesn't fail on retry. Either fault is ๐Ÿ”ด โ€” both break test/prod deploys. Rules live in `internal-packages/clickhouse/CLAUDE.md`. +- **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 tables that can reach billions of rows. 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. โ€” ๐Ÿ”ด. + +## Performance (always review) + +Every PR gets a performance pass โ€” not just the ones that look perf-sensitive. For each new query or unit of work, weigh three things: (a) the size of the table it hits, (b) whether it sits on a hot path, (c) whether the data it walks can be deep or wide (run trees, batches). The ๐Ÿ”ด bullets above on indexes, recovery-path queries, aggregations, and Redis `SCAN` are part of this pass โ€” the rest below extends it. + +**Treat these tables as large โ€” no scans, no `COUNT` / `GROUP BY`, no unbounded fetch:** + +- **Postgres โ€” the `TaskRun` family:** `TaskRun`, `TaskRunExecutionSnapshot`, `Waitpoint`, `BatchTaskRun` and their join tables. Assume billions of rows. +- **ClickHouse โ€” `task_events_v1` / `task_events_v2`.** Partitioned by `toDate(inserted_at)`; `ORDER BY (environment_id, toUnixTimestamp(start_time), trace_id)`. Note `span_id` / `parent_span_id` are NOT in the sort key โ€” span-id lookups can't skip granules, only `environment_id` + a `start_time` window can. + +**Hot paths โ€” extra scrutiny on any added query or work:** + +- **Trigger + batch trigger** (`triggerTask.server.ts`, `batchTriggerV3.server.ts`) โ€” see `apps/webapp/CLAUDE.md`; do not add DB queries to these. +- **Dequeue / RunQueue** (`dequeueSystem.ts`, run-queue read/lock paths) โ€” runs on every execution. +- **Execution-snapshot creation in the run engine** โ€” any engine function that writes a `TaskRunExecutionSnapshot` runs per state transition; a new query there multiplies by run volume. +- **OTEL ingestion** (`otel.v1.traces.ts`, `otel.v1.logs.ts`) โ€” write volume scales with customer span counts. +- **Trace + run-list reads** (trace view, run list, span detail) โ€” read paths over the large tables above. + +**Deep / wide shapes โ€” one run can explode into a huge tree or batch; code that walks them is the trap:** + +- Trace span subtrees (deeply nested child runs โ†’ deep span trees). +- Batch + parent/child fan-out (one run triggers thousands of children). +- Waitpoint / run-dependency chains. +- Tag / attribute many-to-many joins against the run/event tables. + +**Anti-patterns (severity):** + +- **Per-level fan-out that re-scans a large table once per tree depth** โ†’ ๐Ÿ”ด. A BFS issuing one query per level (e.g. `parent_span_id IN {thisLevel}`) re-reads the same granules D times for a depth-D tree. Prefer one windowed query + an in-memory tree build. +- **Dropping the partition-pruning predicate** โ€” `inserted_at` for ClickHouse, the `createdAt` window for partitioned Postgres โ€” to "widen" a lookup โ†’ ๐Ÿ”ด. Without it the query scans every partition. Keep a bounded window even for ancestor / backfill lookups. +- **Unbounded `IN (...)` built from a result set** (a BFS frontier, a batch's child ids) โ†’ ๐ŸŸก. It can reach the row cap (`MAXIMUM_TRACE_SUMMARY_VIEW_COUNT` defaults to 25k). Cap or chunk to โ‰ค1โ€“2k ids per query. +- **Sequential per-level round-trips** where one recursive or windowed query would do โ†’ ๐ŸŸก. N levels = N round-trip latencies stacked. +- **Replacing a single bounded query with a multi-query walk for _every_ call** (not just a rare fallback) โ†’ ๐Ÿ”ด on a hot read path, ๐ŸŸก elsewhere. Keep the cheap single-query path; branch into the expensive walk only when the cheap one comes up short. + +## 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 oxfmt / oxlint catches. CI enforces both via the `code-quality` check. +- 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/.claude/review-guides/chat-agent-sessions-row-agnostic.md b/.claude/review-guides/chat-agent-sessions-row-agnostic.md new file mode 100644 index 00000000000..7fb9851f308 --- /dev/null +++ b/.claude/review-guides/chat-agent-sessions-row-agnostic.md @@ -0,0 +1,287 @@ +# Review guide โ€” chat.agent on Sessions, row-agnostic addressing + +Scope: the 12 uncommitted files. **No new behaviour beyond the public surface +already on this branch** โ€” this is plumbing cleanup that: + +1. Eliminates the transport's session-creation step +2. Makes `chatId` the universal addressing string everywhere +3. Makes the server-side stream/append/wait routes row-agnostic + +## The two design moves + +**Move 1 โ€” agent owns session lifecycle.** `chat.agent` and +`chat.customAgent` upsert the backing `Session` row at bind, fire-and-forget, +keyed on `externalId = payload.chatId`. The transport, server-side +`AgentChat`, and `chat.createTriggerAction` no longer create sessions at all. +Browsers cannot mint sessions either (`POST /api/v1/sessions` is now +secret-key-only). One owner, one path. + +**Move 2 โ€” `chatId` is the only address.** The transport, server-side +`AgentChat`, JWT scopes, and S2 stream paths all use `chatId` directly. The +Session's friendlyId is informational. To make this safe, the three stream +routes (`.in/.out` PUT, GET, POST append, plus the run-engine `wait` +endpoint) became "row-optional" and derive a *canonical addressing key* +(`row.externalId ?? row.friendlyId`, fallback to the URL param when the row +hasn't been upserted yet). Same canonical key is used to build the S2 stream +path, the waitpoint cache key, and the JWT resource set โ€” so any caller +addressing by either form converges on the same physical stream. + +Together these remove an entire class of "did the row land yet?" races. The +transport can subscribe to `/sessions/{chatId}/out` before the agent boots, +the agent's `void sessions.create({externalId: chatId})` lands a moment +later, and any earlier reads/writes are already on the right S2 key. + +--- + +## Read in this order + +### 1. `apps/webapp/app/services/realtime/sessions.server.ts` (+34 lines) + +The new primitive. Two helpers: + +- `isSessionFriendlyIdForm(value)` โ€” `value.startsWith("session_")`. Used to + decide whether a missing row is a hard 404 (opaque friendlyId) or a soft + "row will land later" (externalId form). +- `canonicalSessionAddressingKey(row, paramSession)` โ€” `row.externalId ?? + row.friendlyId` if the row exists, else `paramSession`. **This is the load- + bearing function.** Read its docstring. + +**Question to ask:** can two callers addressing the "same" session ever get +different canonical keys? Only if the row exists for one and not the other, +*and* the URL forms differ โ€” but in that case the row-less caller used the +externalId form (friendlyId-form would have 404'd earlier), and the row-ful +caller computes `row.externalId ?? row.friendlyId`. If the row's externalId +matches the URL, they converge. If it doesn't, there's no row to find by +that string anyway. The interesting edge is "row exists with no externalId", +addressed via friendlyId โ€” both sides read `row.friendlyId`. โœ“ + +### 2. `apps/webapp/app/routes/realtime.v1.sessions.$session.$io.ts` (+47/-12) + +PUT initialize + GET subscribe (SSE). Both use the helper. The interesting +part is the loader's `findResource` + `authorization.resource`: + +```ts +findResource: async (params, auth) => { + const row = await resolveSessionByIdOrExternalId(...); + if (!row && isSessionFriendlyIdForm(params.session)) return undefined; // 404 + return { row, addressingKey: canonicalSessionAddressingKey(row, params.session) }; +}, +authorization: { + resource: ({ row, addressingKey }) => { + const ids = new Set([addressingKey]); + if (row) { + ids.add(row.friendlyId); + if (row.externalId) ids.add(row.externalId); + } + return { sessions: [...ids] }; + }, + superScopes: ["read:sessions", "read:all", "admin"], +}, +``` + +**Why three IDs in the resource set?** `checkAuthorization` is "any-match" +across the resource values. We want a JWT scoped to *either* form to +authorize *either* URL form. Smoke test verified the 4-cell matrix passes. + +**The PUT path** (action handler) is simpler โ€” it just resolves the row, +builds an addressing key, and hands it to `initializeSessionStream`. Worth +noting the `closedAt` check is now `maybeSession?.closedAt` โ€” no row means +no closedAt to enforce. + +### 3. `apps/webapp/app/routes/realtime.v1.sessions.$session.$io.append.ts` (+22/-13) + +POST append (browser writes a record to `.in` or server writes to `.out`). +Same row-optional pattern. Both the S2 append and the waitpoint drain use +`addressingKey`. + +**Question to ask:** what fires the waitpoint? An agent's +`session.in.wait()` registers a waitpoint keyed on `(addressingKey, io)` via +the wait endpoint (file 4). The append handler drains by the *same* key โ€” +even if the agent registered with externalId form and the transport +appended via friendlyId form, both compute the same canonical key, so they +converge. โœ“ + +### 4. `apps/webapp/app/routes/api.v1.runs.$runFriendlyId.session-streams.wait.ts` (+18/-13) + +The agent's `.in.wait()` endpoint. Run-engine creates the waitpoint, then +registers it in Redis under `(addressingKey, io)`. The race-check that runs +right after creation reads from S2 by the same key. Three call sites โ€” +`addSessionStreamWaitpoint`, `readSessionStreamRecords`, +`removeSessionStreamWaitpoint` โ€” all consistent. + +### 5. `apps/webapp/app/routes/api.v1.sessions.ts` (+4/-2) + +**Security tightening.** Removed `allowJWT: true` and `corsStrategy: "all"` +from the `POST /api/v1/sessions` action โ€” secret-key only now. + +**Question to ask:** was the JWT path actually used? Until this branch, the +transport called it via `ensureSession` (now deleted). After this branch, +nobody reaches it from the browser. `chat.createTriggerAction` (server +secret key) is the only browser-adjacent path. + +### 6. `packages/trigger-sdk/src/v3/ai.ts` (+62/-39) + +Two near-identical edits โ€” one in `chatAgent`, one in `chatCustomAgent`. +Both bind on `payload.chatId` and fire-and-forget the upsert: + +```ts +locals.set(chatSessionHandleKey, sessions.open(payload.chatId)); +void sessions + .create({ type: "chat.agent", externalId: payload.chatId }) + .catch(() => { /* best effort */ }); +``` + +**Question to ask:** why `void`-and-`catch`? Awaiting the upsert would gate +the agent's bind on a network round-trip that doesn't unblock anything +user-visible โ€” `.in/.out` routes are row-agnostic and the waitpoint cache +is keyed on the addressing string, not the row id. If the upsert genuinely +fails, the next bind retries the same idempotent call (`sessions.create` +upserts on `externalId`, so concurrent triggers on one chatId converge to +one row). The row matters for downstream metadata + listing, not for live +addressing. + +The PAT scope minting in `chatAgent` (two call sites โ€” preload and +sendMessage) now uses `payload.chatId` for the `sessions:` resource. That +matches what the transport/AgentChat use as the JWT resource and what the +JWT's resource set in the loader includes. Cross-form addressing works +either way (smoke-tested), but using `chatId` keeps the chain tight. + +`createChatTriggerAction` is the most visibly trimmed: no pre-create, no +threading `sessionId` into payload, scope mint uses `chatId`. Return type +no longer carries `sessionId` โ€” note `TriggerChatTaskResult.sessionId` was +already declared optional, so this isn't a public-API break. + +**Stale docstring to flag:** `chat.ts:59` and `chat.ts:112` still describe +PAT scopes as `read:sessions:{sessionId}` and +`write:sessions:{sessionId}`. Functionally either ID works (row lookup +canonicalises), but the doc text is now out of date โ€” it should say +`{chatId}`. Worth a tidy-up before merge but not blocking. + +### 7. `packages/trigger-sdk/src/v3/chat.ts` (+63/-117) + +**The biggest mechanical edit.** Net -54 lines from deleting `ensureSession` +and untangling its callers. + +What disappeared: +- `private async ensureSession(chatId)` โ€” gone +- The "lazy upsert from the browser if no triggerTask callback" branch in + `sendMessages` and `preload` โ€” gone +- The "throw if neither path surfaced a sessionId" guard โ€” gone +- All `state.sessionId` URL params replaced with `chatId` +- `subscribeToSessionStream`'s `chatId?` (optional) is now `chatId` (required) + +What stayed: +- `state.sessionId` in `ChatSessionState` โ€” optional, informational +- The `restore from external storage` branch in the constructor still + hydrates `sessionId` if persisted, just doesn't *require* it +- `notifySessionChange` still surfaces `sessionId` if known + +**Question to ask:** does the transport ever still need the friendlyId? The +only place is the `onSessionChange` callback's payload (so consumers +persisting state can save it for later display). The transport itself never +puts it in a URL or a waitpoint key. + +The `sendMessages` path is worth re-reading: when state.runId is set, it +appends to `.in/append` and subscribes to `.out`. If the append fails with +a non-auth error, it falls through to triggering a new run (legacy "run is +dead" detection โ€” unchanged from pre-Sessions, doesn't depend on +addressing). + +### 8. `packages/trigger-sdk/src/v3/chat-client.ts` (+34/-33) + +Server-side `AgentChat`. Mirrors the transport changes โ€” every URL uses +`this.chatId`. `triggerNewRun` no longer pre-creates a session. `ChatSession` +and internal `SessionState` types now have optional `sessionId`. + +The shape of the diff is identical to the transport: delete the upsert, +swap addressing identifiers, optionalise the friendlyId. If you've read +`chat.ts` carefully, this one is mostly mechanical confirmation that both +client surfaces (browser transport + server-side AgentChat) speak the same +addressing protocol. + +### 9. Test infrastructure โ€” `sessions.ts` (+18) + `mock-chat-agent.ts` (+25) + +`__setSessionCreateImplForTests` mirrors the existing +`__setSessionOpenImplForTests`. `mockChatAgent` installs a no-op create stub +returning a synthetic `CreatedSessionResponseBody` so the agent's bind-time +`void sessions.create(...)` doesn't try to hit a real API. Cleanup runs in +the same `.finally` as the open override. + +**Question to ask:** is the synthetic response shape correct? It mirrors +`CreatedSessionResponseBody` โ€” `id`, `externalId`, `type`, `tags`, +`metadata`, `closedAt`, `closedReason`, `expiresAt`, `createdAt`, +`updatedAt`, `isCached`. Tests don't currently assert on this object, so +the bar is "doesn't crash + matches the type". Met. + +### 10. `packages/trigger-sdk/src/v3/chat.test.ts` (+13/-12) + +Three classes of test edits, all consequences: + +- Stream URL assertion: `chat-1` (the chatId) instead of + `session_streamurl` (the friendlyId) +- `renewRunAccessToken` callback: `sessionId: undefined` (was + `DEFAULT_SESSION_ID` because the mocked trigger doesn't surface it) +- Token resolve count: `1` (was `2` โ€” second resolve was for `ensureSession`) +- One `onSessionChange` matchObject loses `sessionId` + +### 11. `apps/webapp/app/routes/_app.../playground/.../route.tsx` (1 line) + +`sessionId: string` โ†’ `sessionId?: string` in the playground sidebar prop +to track the transport type change. + +--- + +## Edge cases I checked, so you don't have to + +- **Cross-form JWT auth (curl matrix).** JWT scoped to externalId can call + externalId URL โœ“ and friendlyId URL โœ“. JWT scoped to friendlyId can call + externalId URL โœ“ and friendlyId URL โœ“. Smoke-tested. +- **Row materialises after subscribe.** Transport opens + `GET /sessions/{chatId}/out` before agent's bind upsert lands โ†’ 200 OK, + `addressingKey = chatId` (paramSession fallback). Once the row lands + with `externalId = chatId`, addressingKey resolves to the same value via + `row.externalId`. Same S2 key throughout. +- **Concurrent triggers on one chatId.** Two browser tabs trigger two runs + โ†’ two binds โ†’ two `sessions.create({externalId: chatId})` calls. Upsert + semantics: both return the same row. +- **Closed session enforcement.** Still enforced when a row exists. + `maybeSession?.closedAt` is null-safe; no row = no close-state to honour. +- **Agent run cancellation.** Frontend doesn't auto-detect โ€” unchanged from + pre-Sessions; messages sit in S2 until the next trigger (the existing + run-PAT auth-error path is the only reaper). Out of scope for this branch. +- **Idle timeout in dev.** Runs stay `EXECUTING_WITH_WAITPOINTS` past the + configured idle because dev runs don't snapshot/restore; the in-process + idle clock advances locally without touching the row. Expected, not a + regression. + +## Things explicitly **not** in this branch + +- Run-state subscription on the transport side (the "run died, re-trigger + silently" UX gap) +- Session auto-close on agent exit (still client-driven by design) +- Any change to `Session` schema, `sessions.create` semantics, or + `chatAccessTokenTTL` +- Docstring updates for `read:sessions:{sessionId}` / `write:sessions:{sessionId}` + in `chat.ts:59` and `chat.ts:112` (functional but textually stale โ€” + follow-up nit) + +--- + +## What I'd be ready to answer cold + +- Why fire-and-forget upsert (vs. `await`) in the agent's bind step +- Why the route's authorization resource set has three IDs (cross-form JWT + auth) +- Why `POST /api/v1/sessions` lost `allowJWT` (security tightening โ€” no + caller needs it after the transport's `ensureSession` is gone) +- What converges two callers using different URL forms onto the same S2 + stream (`canonicalSessionAddressingKey`, identical computation on both + sides for any given row) +- What makes `sessions.create` race-safe under concurrent triggers + (`externalId` upsert) +- Why `state.sessionId` stayed on `ChatSessionState` at all (pure + informational, surfaced via `onSessionChange` for consumer persistence; + zero addressing role) +- Why the chat-client (server-side AgentChat) and chat (transport) edits + look near-identical (they implement the same client protocol against the + same row-agnostic routes) diff --git a/.claude/rules/database-safety.md b/.claude/rules/database-safety.md new file mode 100644 index 00000000000..14a6523595b --- /dev/null +++ b/.claude/rules/database-safety.md @@ -0,0 +1,13 @@ +--- +paths: + - "internal-packages/database/**" +--- + +# Database Migration Safety + +- When adding indexes to **existing tables**, use `CREATE INDEX CONCURRENTLY IF NOT EXISTS` to avoid table locks. These must be in their own separate migration file (one index per file). +- Indexes on **newly created tables** (same migration as `CREATE TABLE`) do not need CONCURRENTLY. +- When indexing a **new column on an existing table**, split into two migrations: first `ADD COLUMN IF NOT EXISTS`, then `CREATE INDEX CONCURRENTLY IF NOT EXISTS` in a separate file. +- After generating a migration with Prisma, remove extraneous lines for: `_BackgroundWorkerToBackgroundWorkerFile`, `_BackgroundWorkerToTaskQueue`, `_TaskRunToTaskRunTag`, `_WaitpointRunConnections`, `_completedWaitpoints`, `SecretStore_key_idx`, and unrelated TaskRun indexes. +- Never drop columns or tables without explicit approval. +- New code should target `RunEngineVersion.V2` only. diff --git a/.claude/rules/docs-writing.md b/.claude/rules/docs-writing.md new file mode 100644 index 00000000000..bbfb471368e --- /dev/null +++ b/.claude/rules/docs-writing.md @@ -0,0 +1,14 @@ +--- +paths: + - "docs/**" +--- + +# Documentation Writing Rules + +- Use Mintlify MDX format. Frontmatter: `title`, `description`, `sidebarTitle` (optional). +- After creating a new page, add it to `docs.json` navigation under the correct group. +- Use Mintlify components: ``, ``, ``, ``, ``, ``, ``/``. +- Code examples should be complete and runnable where possible. +- Always import from `@trigger.dev/sdk`, never `@trigger.dev/sdk/v3`. +- Keep paragraphs short. Use headers to break up content. +- Link to related pages using relative paths (e.g., `[Tasks](/tasks/overview)`). diff --git a/.claude/rules/legacy-v3-code.md b/.claude/rules/legacy-v3-code.md new file mode 100644 index 00000000000..c5e0d465973 --- /dev/null +++ b/.claude/rules/legacy-v3-code.md @@ -0,0 +1,26 @@ +--- +paths: + - "apps/webapp/app/v3/**" +--- + +# v3 (engine V1) has been removed + +The v3 engine (RunEngineVersion `V1`: MarQS queue + Graphile worker) is end-of-life and its execution code has been removed from the webapp. The `app/v3/` directory name is historical: everything under it now serves the current V2 engine (`@internal/run-engine` + `@trigger.dev/redis-worker`). + +There is no `V1` execution path anymore. If you find a `RunEngineVersion` branch, the `V1` arm should only reject or finalize gracefully (for example, mark a historical run cancelled in the DB), never run V1 work. Do not reintroduce MarQS, the graphile worker, or the v3 socket.io namespaces. + +## The deprecation boundary (keep this) + +Requests from clients still on v3 (old SDK/CLI) or historical V1 runs must return a clean 4xx, never a 5xx. The boundary lives in: + +- `engineDeprecation.server.ts` - the `V3_TRIGGER_DEPRECATION_MESSAGE` / `V3_DEV_DEPRECATION_MESSAGE` / `V3_MIGRATION_URL` upgrade messages. +- `engineVersion.server.ts` - `determineEngineVersion()` still detects a V1 project/run so callers can reject it. +- `services/triggerTask.server.ts`, `services/cancelTaskRun.server.ts`, `services/rescheduleTaskRun.server.ts` - the `V1` arm rejects or finalizes gracefully instead of executing. +- `services/initializeDeployment.server.ts` - the `DEPRECATE_V3_CLI_DEPLOYS_ENABLED`-gated v3 CLI deploy rejection. +- `handleWebsockets.server.ts` - the legacy `trigger dev` websocket closes with the upgrade message. + +## V2 modern stack + +- **Run lifecycle**: `@internal/run-engine` (`runEngine.server.ts`, `runEngineHandlers.server.ts`) +- **Background jobs**: `@trigger.dev/redis-worker` (`commonWorker.server.ts`, `alertsWorker.server.ts`, `batchTriggerWorker.server.ts`; `legacyRunEngineWorker.server.ts` still hosts the live batch-completion jobs) +- **Queue operations**: RunQueue inside run-engine (`runQueue.server.ts`), not MarQS diff --git a/.claude/rules/package-installation.md b/.claude/rules/package-installation.md new file mode 100644 index 00000000000..310074823c5 --- /dev/null +++ b/.claude/rules/package-installation.md @@ -0,0 +1,22 @@ +--- +paths: + - "**/package.json" +--- + +# Installing Packages + +When adding a new dependency to any package.json in the monorepo: + +1. **Look up the latest version** on npm before adding: + ```bash + pnpm view version + ``` + If unsure which version to use (e.g. major version compatibility), confirm with the user. + +2. **Edit the package.json directly** โ€” do NOT use `pnpm add` as it can cause issues in the monorepo. Add the dependency with the correct version range (typically `^x.y.z`). + +3. **Run `pnpm i` from the repo root** after editing to install and update the lockfile: + ```bash + pnpm i + ``` + Always run from the repo root, not from the package directory. diff --git a/.claude/rules/sdk-packages.md b/.claude/rules/sdk-packages.md new file mode 100644 index 00000000000..343be2045f8 --- /dev/null +++ b/.claude/rules/sdk-packages.md @@ -0,0 +1,12 @@ +--- +paths: + - "packages/**" +--- + +# Public Package Rules + +- Changes to `packages/` are **customer-facing**. Always add a changeset: `pnpm run changeset:add` +- Default to **patch**. Get maintainer approval for minor. Never select major without explicit approval. +- `@trigger.dev/core`: **Never import the root**. Always use subpath imports (e.g., `@trigger.dev/core/v3`). +- Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked. These are maintained in separate dedicated passes. +- Test changes using the `hello-world` project in the [`triggerdotdev/references`](https://github.com/triggerdotdev/references) repo. diff --git a/.claude/rules/server-apps.md b/.claude/rules/server-apps.md new file mode 100644 index 00000000000..3bc4c0b2b98 --- /dev/null +++ b/.claude/rules/server-apps.md @@ -0,0 +1,25 @@ +--- +paths: + - "apps/**" +--- + +# Server App Changes + +When modifying server apps (webapp, supervisor, etc.) with **no package changes**, add a `.server-changes/` file instead of a changeset: + +```bash +cat > .server-changes/descriptive-name.md << 'EOF' +--- +area: webapp +type: fix +--- + +Fix pages occasionally loading unstyled during deploys. The dashboard now recovers automatically. +EOF +``` + +- **area**: `webapp` | `supervisor` +- **type**: `feature` | `fix` | `improvement` | `breaking` +- If the PR also touches `packages/`, just the changeset is sufficient (no `.server-changes/` needed). + +The body ships **verbatim in user-facing release notes**. Keep it to 1โ€“2 short sentences, non-technical, written for a dashboard user: describe what changed for them, never the implementation (no header names, endpoints, middleware, storage mechanisms, internal tools). See `.server-changes/README.md` for full guidance. diff --git a/.claude/skills/drizzle/SKILL.md b/.claude/skills/drizzle/SKILL.md new file mode 100644 index 00000000000..e647343512f --- /dev/null +++ b/.claude/skills/drizzle/SKILL.md @@ -0,0 +1,191 @@ +--- +name: drizzle +description: Use this skill when writing or modifying Drizzle ORM schemas, queries, or migrations in this repo โ€” specifically the `@internal/dashboard-agent-db` package (the dashboard agent's conversation datastore). Covers pg-core schema definition, the postgres-js driver, drizzle-kit migrations, and this repo's conventions: a dedicated Postgres schema, foreign-key-free cross-database design, pooler-safe connections, and the access-pattern query layer. Drizzle is NOT the main database โ€” that's Prisma. +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +--- + +# Drizzle ORM (this repo) + +Drizzle is used in exactly one place: **`internal-packages/dashboard-agent-db`** (`@internal/dashboard-agent-db`), the in-dashboard agent's conversation store. Everything else in the monorepo is **Prisma** (`@trigger.dev/database`). Keep them separate. + +Pinned versions: **`drizzle-orm` ^0.45**, **`drizzle-kit` ^0.31** (dev), **`postgres` ^3.4** (postgres.js driver). drizzle-orm and drizzle-kit are intentionally on different version lines โ€” 0.31.x is the correct companion for 0.45.x, there is no peer dependency between them. + +## Critical rules + +1. **Drizzle is only the agent's own datastore.** The agent (and its task bundle) must have **no access to the main Prisma database or ClickHouse**. Never import the Prisma client into the agent task or into `@internal/dashboard-agent-db`. Main data is reached via the API, not Drizzle. +2. **Foreign-key-free.** In cloud this DB is a *separate* PlanetScale database, so it can't FK into the main DB. Reference main entities (`organizationId`, `userId`, โ€ฆ) **by id only โ€” never `.references()`**. Joins happen in app code; tenant scoping is enforced in the query layer. +3. **One dedicated Postgres schema.** All tables live under `pgSchema("trigger_dashboard_agent")` so they're schema-qualified and isolated from Prisma's `public` schema (this is what makes the OSS single-database fallback safe). +4. **Pooler-safe connections.** Connections go through a transaction-mode pooler (PlanetScale / PgBouncer-style), so postgres.js must run with **`prepare: false`** โ€” prepared statements don't survive a connection being handed to another client between checkouts. +5. **Node16 module resolution.** Relative imports need explicit **`.js`** extensions (`import { chats } from "./schema.js"`), even though the source is `.ts`. +6. **Scope every user query.** All queries that touch user data go through `src/queries.ts` and are scoped by `organizationId` / `userId`, so callers can't forget the `where`. Don't write ad-hoc cross-tenant queries elsewhere. + +## Package layout + +```text +internal-packages/dashboard-agent-db/ + drizzle.config.ts # drizzle-kit config (schema path, out dir, schemaFilter) + drizzle/ # generated migrations (committed) + src/ + schema.ts # pgSchema + table definitions + client.ts # createDashboardAgentDb() โ€” postgres.js + drizzle + queries.ts # the access-pattern layer (org/user-scoped) + index.ts # barrel: re-exports schema, client, queries +``` + +`package.json` points `main`/`types` at `./src/index.ts` (consumed as source, no build step) โ€” same as other simple internal packages. + +## Schema (pg-core) + +Use `pgSchema(...).table(...)`, not the bare `pgTable`, so tables land in the dedicated schema. ([schemas](https://orm.drizzle.team/docs/schemas), [pg column types](https://orm.drizzle.team/docs/column-types/pg), [indexes](https://orm.drizzle.team/docs/indexes-constraints)) + +```ts +import { sql } from "drizzle-orm"; +import { index, jsonb, pgSchema, text, timestamp } from "drizzle-orm/pg-core"; + +export const dashboardAgentSchema = pgSchema("trigger_dashboard_agent"); + +export const chats = dashboardAgentSchema.table( + "chats", + { + id: text("id").primaryKey(), + organizationId: text("organization_id").notNull(), // FK-free: id only, no .references() + userId: text("user_id").notNull(), + title: text("title").notNull().default("New chat"), + // JSONB with a typed view; .default([]) / .default({}) emit '[]'::jsonb / '{}'::jsonb + messages: jsonb("messages").$type().notNull().default([]), + metadata: jsonb("metadata").$type>().notNull().default({}), + deletedAt: timestamp("deleted_at", { withTimezone: true }), // soft delete + lastMessageAt: timestamp("last_message_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + // Extra config returns an ARRAY in drizzle-orm 0.36+ (not an object). + (t) => [ + // Partial + ordered composite index. `.desc()` on the column, `.where(sql`...`)` for partial. + index("chats_org_user_last_msg_idx") + .on(t.organizationId, t.userId, t.lastMessageAt.desc()) + .where(sql`${t.deletedAt} is null`), + ] +); + +// Inferred row types for the query layer + consumers. +export type Chat = typeof chats.$inferSelect; +export type NewChat = typeof chats.$inferInsert; +``` + +Notes: +- `timestamp(..., { withTimezone: true })` โ†’ `timestamp with time zone`. Use `.defaultNow()` for `DEFAULT now()`. +- For a "newest first, nulls last" sort the partial index uses `.desc()`; the *query* uses raw `sql` for `NULLS LAST` (see below). +- Don't add `.references()` โ€” see critical rule 2. + +## Client (postgres.js + drizzle) + +([connect overview](https://orm.drizzle.team/docs/connect-overview)) One small pool, `prepare: false`. In the agent task create it once in `onBoot` (per-process); in the webapp wrap it in the `singleton(...)` helper. + +```ts +import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import postgres, { type Sql } from "postgres"; +import * as schema from "./schema.js"; + +export type DashboardAgentDb = PostgresJsDatabase; + +export function createDashboardAgentDb(connectionString: string, opts: { max?: number } = {}) { + const sql: Sql = postgres(connectionString, { + max: opts.max ?? 5, // small โ€” the pooler does the real pooling + idle_timeout: 20, // release conns when an agent run suspends + prepare: false, // REQUIRED for transaction-mode poolers + }); + return { db: drizzle(sql, { schema }), sql, close: () => sql.end() }; +} +``` + +## Queries (the access-pattern layer) + +([select](https://orm.drizzle.team/docs/select), [insert](https://orm.drizzle.team/docs/insert), [operators](https://orm.drizzle.team/docs/operators), [transactions](https://orm.drizzle.team/docs/transactions), [joins](https://orm.drizzle.team/docs/joins)) + +```ts +import { and, desc, eq, isNull, sql } from "drizzle-orm"; + +// Select EXPLICIT columns for list views โ€” never select a large blob (messages) +// or a secret (tokens) you don't need. `NULLS LAST` needs raw sql in orderBy. +await db + .select({ id: chats.id, title: chats.title, lastMessageAt: chats.lastMessageAt }) + .from(chats) + .where(and(eq(chats.organizationId, orgId), eq(chats.userId, userId), isNull(chats.deletedAt))) + .orderBy(sql`${chats.pinnedAt} desc nulls last`, desc(chats.lastMessageAt)) + .limit(50); + +// Idempotent create (avoids a duplicate-key race between two writers). +await db.insert(chats).values({ id, organizationId: orgId, userId }).onConflictDoNothing(); + +// Upsert. +await db + .insert(chatSessions) + .values({ chatId, publicAccessToken }) + .onConflictDoUpdate({ target: chatSessions.chatId, set: { publicAccessToken, updatedAt: sql`now()` } }); + +// Owner-scope a join (this DB is FK-free, so enforce ownership in the query). +await db + .select({ /* session cols */ }) + .from(chatSessions) + .innerJoin(chats, eq(chats.id, chatSessions.chatId)) + .where(and(eq(chatSessions.chatId, chatId), eq(chats.userId, userId))); + +// Multi-write that must be consistent on the next read โ†’ one transaction. +await db.transaction(async (tx) => { + await tx.update(chats).set({ messages, updatedAt: sql`now()` }).where(eq(chats.id, chatId)); + await tx.insert(chatSessions).values({ /* ... */ }).onConflictDoUpdate({ /* ... */ }); +}); +``` + +Use `sql\`now()\`` for DB-side timestamps in updates. + +## Migrations (drizzle-kit) + +([kit overview](https://orm.drizzle.team/docs/kit-overview), [generate](https://orm.drizzle.team/docs/drizzle-kit-generate), [migrate](https://orm.drizzle.team/docs/drizzle-kit-migrate)) + +`drizzle.config.ts` must set **`schemaFilter`** so drizzle-kit only ever manages our schema โ€” never Prisma's `public` (critical in the OSS single-DB fallback): + +```ts +import { defineConfig } from "drizzle-kit"; +export default defineConfig({ + schema: "./src/schema.ts", + out: "./drizzle", + dialect: "postgresql", + schemaFilter: ["trigger_dashboard_agent"], + dbCredentials: { url: process.env.DASHBOARD_AGENT_DATABASE_URL ?? process.env.DATABASE_URL ?? "postgres://placeholder" }, +}); +``` + +Workflow: + +```bash +cd internal-packages/dashboard-agent-db +pnpm run db:generate # diff schema.ts โ†’ emit SQL into drizzle/. OFFLINE (no DB needed). +# review the generated drizzle/000N_*.sql before committing +pnpm run db:migrate # apply pending migrations. Needs a real DATABASE URL. +``` + +- `db:generate` is **offline** โ€” it only reads `schema.ts`, so you can verify a schema change compiles to valid DDL with no database. Use it as a fast check. +- drizzle-kit names migration files with a **random suffix** (`0000_magenta_lilandra.sql`). Don't regenerate a committed migration just to "refresh" it โ€” that churns the filename. After the first migration is committed, schema changes produce a **new** `000N_*.sql`; commit that. +- Generated DDL for a new schema is one `CREATE SCHEMA` + schema-qualified `CREATE TABLE`s + indexes, **no foreign keys** (by design here). + +## Common gotchas + +- **`prepare: false`** is not optional with a pooler โ€” without it you'll get prepared-statement errors under load. +- **Missing `.js` extension** on a relative import โ†’ TS2835 under Node16 resolution. +- **Extra-config callback returns an array** `(t) => [ ... ]` in drizzle-orm 0.36+. The old object form `(t) => ({ ... })` is deprecated. +- **`NULLS LAST` / `NULLS FIRST`** aren't on the `desc()` helper โ€” use raw `sql\`col desc nulls last\`` in `orderBy`. +- **Don't `SELECT *` into list views** โ€” explicitly pick columns so you never ship a megabyte `messages` blob or a session token to a list query. +- **Adding a dependency**: edit `package.json`, then `pnpm i` from the repo root (never `pnpm add`). Mind the repo's `minimumReleaseAge` (3 days) โ€” pin with a caret range and let pnpm resolve an old-enough version. + +## Reference (official docs) + +- Schema declaration โ€” https://orm.drizzle.team/docs/sql-schema-declaration +- PostgreSQL column types โ€” https://orm.drizzle.team/docs/column-types/pg +- Schemas (`pgSchema`) โ€” https://orm.drizzle.team/docs/schemas +- Indexes & constraints โ€” https://orm.drizzle.team/docs/indexes-constraints +- Connect (postgres-js) โ€” https://orm.drizzle.team/docs/connect-overview +- Select / Insert / Update / Delete โ€” https://orm.drizzle.team/docs/select ยท /insert ยท /update ยท /delete +- Joins / Operators โ€” https://orm.drizzle.team/docs/joins ยท /operators +- Transactions โ€” https://orm.drizzle.team/docs/transactions +- drizzle-kit (generate / migrate / push) โ€” https://orm.drizzle.team/docs/kit-overview diff --git a/.claude/skills/errors-api-e2e/SKILL.md b/.claude/skills/errors-api-e2e/SKILL.md new file mode 100644 index 00000000000..e74c50bf5ea --- /dev/null +++ b/.claude/skills/errors-api-e2e/SKILL.md @@ -0,0 +1,199 @@ +--- +name: errors-api-e2e +description: End-to-end smoke test for the public Errors HTTP API (error groups). Seeds failed runs into ClickHouse so the error materialized views populate, then drives the real endpoints against the running webapp โ€” list (with filters + pagination), retrieve, resolve/ignore/unresolve, the `filter[error]` runs filter, user attribution via the `trigger.dev mint-token` -> JWT exchange, and the 401/403/404 negatives. Use for "smoke test the errors API", "test the errors API e2e", "prove the errors endpoints work", or to re-verify after changes. +allowed-tools: Read, Bash +--- + +# Errors API โ€” end-to-end smoke test + +Proves the public Errors API against the **running** webapp with real HTTP. No +mocks. The error data plane is ClickHouse (`errors_v1` + `error_occurrences_v1`, +both materialized-view-fed from `task_runs_v2`) plus Postgres `ErrorGroupState` +for lifecycle status; this skill seeds straight into `task_runs_v2` and lets the +MVs do the rest. + +Code under test: +- `apps/webapp/app/routes/api.v1.errors.ts` โ€” `GET /api/v1/errors` (list). +- `apps/webapp/app/routes/api.v1.errors.$errorId.ts` โ€” `GET /api/v1/errors/:errorId` (detail). +- `apps/webapp/app/routes/api.v1.errors.$errorId.{resolve,ignore,unresolve}.ts` โ€” state actions. +- `apps/webapp/app/presenters/v3/ApiErrorListPresenter.server.ts` / `ApiErrorGroupPresenter.server.ts`. +- `apps/webapp/app/presenters/v3/ApiRunListPresenter.server.ts` โ€” the `filter[error]` addition on `GET /api/v1/runs`. +- `apps/webapp/app/v3/services/errorGroupActions.server.ts` โ€” resolve/ignore/unresolve (nullable `userId`). +- Attribution: `api.v1.projects.$projectRef.$env.jwt.ts` stamps `act:{sub}` for PAT **and** UAT exchanges; `@trigger.dev/rbac` surfaces `act.sub` through bearer auth; the action handlers read `authentication.actor?.sub`. + +`errorId` is `error_` (round-trips via `ErrorId` in `@trigger.dev/core/v3/isomorphic`). + +## Prerequisites + +- Webapp running on http://localhost:3030 (`pnpm run dev --filter webapp`). Confirm `curl -s http://localhost:3030/healthcheck`. +- DB seeded (`pnpm run db:seed`), and a local ClickHouse reachable at `CLICKHOUSE_URL` (the `pnpm run docker` stack). +- The CLI built + logged in to localhost:3030 (`pnpm run build --filter trigger.dev`; profile `default` points at localhost:3030). Needed only for the attribution leg. + +> Important wiring facts the seed relies on (verified): +> - The MVs read the error type/message from `error.data.*`, so the seeded +> `error` JSON column **must** be wrapped: `{"data": {"type": ..., "message": ..., "stack": ...}}`. +> - The MVs only fire for failed statuses: `SYSTEM_FAILURE | CRASHED | INTERRUPTED | COMPLETED_WITH_ERRORS | TIMED_OUT`, and require a non-empty `error_fingerprint`. +> - `GET /api/v1/runs` lists run **ids** from ClickHouse but **hydrates from Postgres** `TaskRun`. So the error-list/detail/action legs work from a ClickHouse-only seed, but the `filter[error]` leg needs a **paired** Postgres `TaskRun` row whose `id` equals the ClickHouse `run_id`. + +Run everything from the repo root in one shell. Invoke the built CLI via a +function (a `CLI="node โ€ฆ"` variable won't word-split under zsh): +```bash +cli() { node packages/cli-v3/dist/esm/index.js "$@"; } +PROFILE=default +``` + +## Setup โ€” resolve a dev environment + connection strings + +```bash +cd apps/webapp +CHURL=$(grep -E "^CLICKHOUSE_URL=" .env | head -1 | cut -d= -f2- | tr -d '"') +DBURL=$(grep -E "^DATABASE_URL=" .env | head -1 | cut -d= -f2- | tr -d '"' | tr -d "'" | sed 's/?.*//') + +# Pick the seeded hello-world dev env (proj_rrkpdguyagvsoktglnod). Adjust the +# WHERE if you want a different project. +read ENV ORG PROJ REF < <(psql "$DBURL" -t -A -F' ' -c " + SELECT re.id, re.\"organizationId\", re.\"projectId\", p.\"externalRef\" + FROM \"RuntimeEnvironment\" re + JOIN \"Project\" p ON p.id = re.\"projectId\" + WHERE re.slug='dev' AND p.\"externalRef\"='proj_rrkpdguyagvsoktglnod' LIMIT 1;") +APIKEY=$(psql "$DBURL" -t -A -c "SELECT \"apiKey\" FROM \"RuntimeEnvironment\" WHERE id='$ENV';") +cd .. +H="Authorization: Bearer $APIKEY" +B="http://localhost:3030" +``` + +## Steps + +### 1. Seed two error groups (ClickHouse, MV-fed) + +```bash +RUN=$(node -e 'console.log(Date.now().toString(36))') +TASK="errors-api-e2e-$RUN"; FP_A="fpA${RUN}"; FP_B="fpB${RUN}" +ERRID_A="error_$FP_A"; ERRID_B="error_$FP_B" +NOW_CH=$(node -e 'console.log(new Date().toISOString().replace("T"," ").replace("Z","").slice(0,23))') +NOW_MS=$(node -e 'console.log(Date.now())') +Q=$(python3 -c "import urllib.parse;print(urllib.parse.quote('INSERT INTO trigger_dev.task_runs_v2 FORMAT JSONEachRow'))") + +mkrow() { # status fingerprint errorType message runId + echo "{\"environment_id\":\"$ENV\",\"organization_id\":\"$ORG\",\"project_id\":\"$PROJ\",\"run_id\":\"$5\",\"friendly_id\":\"run_$5\",\"status\":\"$1\",\"environment_type\":\"DEVELOPMENT\",\"engine\":\"V2\",\"task_identifier\":\"$TASK\",\"created_at\":\"$NOW_CH\",\"updated_at\":\"$NOW_CH\",\"error\":{\"data\":{\"type\":\"$3\",\"message\":\"$4\",\"stack\":\"at x (a.ts:1:1)\"}},\"error_fingerprint\":\"$2\",\"task_version\":\"20240101.1\",\"_version\":\"$NOW_MS\",\"_is_deleted\":0}" +} +ROWS="$(mkrow COMPLETED_WITH_ERRORS $FP_A AlphaBoom 'alpha boom happened' r_a1_$RUN) +$(mkrow COMPLETED_WITH_ERRORS $FP_A AlphaBoom 'alpha boom happened' r_a2_$RUN) +$(mkrow CRASHED $FP_B BetaCrash 'beta crash happened' r_b1_$RUN)" +printf '%s' "$ROWS" | curl -s "$CHURL/?query=$Q" --data-binary @- + +# Poll until both fingerprints appear in errors_v1 (the MV is near-instant locally). +for i in $(seq 1 10); do + N=$(curl -s "$CHURL" --data-binary "SELECT count() FROM (SELECT 1 FROM trigger_dev.errors_v1 WHERE environment_id='$ENV' AND error_fingerprint IN ('$FP_A','$FP_B') GROUP BY error_fingerprint)") + [ "$N" = "2" ] && break; sleep 1 +done +echo "seeded fingerprints in errors_v1: $N (want 2)" +``` +PASS: `N = 2`. Alpha has 2 occurrences, beta 1. + +### 2. List + filters + pagination + +```bash +curl -s "$B/api/v1/errors?filter%5BtaskIdentifier%5D=$TASK&filter%5Bperiod%5D=1d" -H "$H" \ + | python3 -c "import sys,json;d=json.load(sys.stdin);print('count',len(d['data']),[(e['id'],e['status'],e['count']) for e in d['data']])" +``` +PASS: 2 groups, both `status=unresolved`, alpha `count=2`, beta `count=1`, ids `error_`. + +Assert each filter narrows correctly (each should return the noted shape): +```bash +curl -s "$B/api/v1/errors?filter%5BtaskIdentifier%5D=$TASK&filter%5Bstatus%5D=unresolved&filter%5Bperiod%5D=1d" -H "$H" | python3 -c "import sys,json;print('unresolved:',len(json.load(sys.stdin)['data']))" # 2 +curl -s "$B/api/v1/errors?filter%5BtaskIdentifier%5D=$TASK&filter%5Bsearch%5D=AlphaBoom&filter%5Bperiod%5D=1d" -H "$H" | python3 -c "import sys,json;print('search:',[e['errorType'] for e in json.load(sys.stdin)['data']])" # ['AlphaBoom'] +curl -s "$B/api/v1/errors?filter%5BtaskIdentifier%5D=$TASK&filter%5Bperiod%5D=1d&page%5Bsize%5D=1" -H "$H" | python3 -c "import sys,json;d=json.load(sys.stdin);print('page size 1:',len(d['data']),'next?',bool(d['pagination'].get('next')))" # 1 / True +``` +PASS: `unresolved: 2`, `search: ['AlphaBoom']`, `page size 1: 1 / next? True`. + +### 3. Retrieve detail + +```bash +curl -s "$B/api/v1/errors/$ERRID_A" -H "$H" \ + | python3 -c "import sys,json;d=json.load(sys.stdin);print(d['id'],d['errorType'],d['status'],d['count'],d['affectedVersions'],d['resolvedBy'])" +``` +PASS: `error_ AlphaBoom unresolved 2 ['20240101.1'] None`. + +### 4. Resolve / ignore / unresolve (env API key โ€” `resolvedBy` null) + +```bash +st(){ python3 -c "import sys,json;d=json.load(sys.stdin);print('status',d['status'],'| resolvedInVersion',d['resolvedInVersion'],'| resolvedBy',d['resolvedBy'],'| ignoredUntil',bool(d['ignoredUntil']),'| reason',d['ignoredReason'])"; } + +curl -s -X POST "$B/api/v1/errors/$ERRID_A/resolve" -H "$H" -H 'Content-Type: application/json' -d '{"resolvedInVersion":"20240101.1"}' >/dev/null +curl -s "$B/api/v1/errors/$ERRID_A" -H "$H" | st # status resolved | resolvedInVersion 20240101.1 | resolvedBy None + +curl -s -X POST "$B/api/v1/errors/$ERRID_B/ignore" -H "$H" -H 'Content-Type: application/json' -d '{"duration":3600000,"reason":"known flake"}' >/dev/null +curl -s "$B/api/v1/errors/$ERRID_B" -H "$H" | st # status ignored | ignoredUntil True | reason known flake + +curl -s -X POST "$B/api/v1/errors/$ERRID_A/unresolve" -H "$H" >/dev/null +curl -s "$B/api/v1/errors/$ERRID_A" -H "$H" | st # status unresolved +``` +PASS: each transition reflected; `filter[status]=ignored` returns only beta: +```bash +curl -s "$B/api/v1/errors?filter%5BtaskIdentifier%5D=$TASK&filter%5Bstatus%5D=ignored&filter%5Bperiod%5D=1d" -H "$H" | python3 -c "import sys,json;print([e['id'] for e in json.load(sys.stdin)['data']])" # [error_] +``` + +### 5. `filter[error]` on the runs list (paired PG + CH seed) + +The runs list hydrates from Postgres, so seed a matching `TaskRun` row + a CH row +that share `run_id`/`id` and carry a fingerprint: +```bash +RID="re2e${RUN}"; FRID="run_${RID}"; FP_R="fpR${RUN}" +psql "$DBURL" -v ON_ERROR_STOP=1 -c " + INSERT INTO \"TaskRun\" (id, \"friendlyId\", \"taskIdentifier\", payload, \"traceId\", \"spanId\", \"runtimeEnvironmentId\", \"projectId\", queue, status, \"createdAt\", \"updatedAt\") + VALUES ('$RID','$FRID','$TASK','{}','trace_$RID','span_$RID','$ENV','$PROJ','task/$TASK','COMPLETED_WITH_ERRORS', now(), now()) + ON CONFLICT (id) DO NOTHING;" >/dev/null +ROW="{\"environment_id\":\"$ENV\",\"organization_id\":\"$ORG\",\"project_id\":\"$PROJ\",\"run_id\":\"$RID\",\"friendly_id\":\"$FRID\",\"status\":\"COMPLETED_WITH_ERRORS\",\"environment_type\":\"DEVELOPMENT\",\"engine\":\"V2\",\"task_identifier\":\"$TASK\",\"created_at\":\"$NOW_CH\",\"updated_at\":\"$NOW_CH\",\"error\":{\"data\":{\"type\":\"RunsFilterErr\",\"message\":\"for runs filter\",\"stack\":\"at x\"}},\"error_fingerprint\":\"$FP_R\",\"task_version\":\"20240101.1\",\"_version\":\"$NOW_MS\",\"_is_deleted\":0}" +printf '%s' "$ROW" | curl -s "$CHURL/?query=$Q" --data-binary @- +sleep 1 +curl -s "$B/api/v1/runs?filter%5Berror%5D=error_$FP_R" -H "$H" | python3 -c "import sys,json;d=json.load(sys.stdin);print('runs:',[r['id'] for r in d['data']])" +``` +PASS: one run, `run_` (status maps to `FAILED`). Proves `filter[error]` -> fingerprint -> CH -> PG hydration. + +### 6. Attribution โ€” `mint-token` -> JWT exchange records the acting user + +```bash +TOKEN=$(cli mint-token --profile $PROFILE --client errors-api-e2e 2>/dev/null) # UAT +ENVJWT=$(curl -sS -X POST "$B/api/v1/projects/$REF/dev/jwt" -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"claims":{"scopes":["read:errors","write:errors"]}}' \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") +# Decoded env JWT carries act.sub = the user id. +node -e 'const p=JSON.parse(Buffer.from(process.argv[1].split(".")[1],"base64url").toString());console.log("act:",JSON.stringify(p.act))' "$ENVJWT" + +curl -s -X POST "$B/api/v1/errors/$ERRID_A/resolve" -H "Authorization: Bearer $ENVJWT" \ + -H 'Content-Type: application/json' -d '{"resolvedInVersion":"20240101.2"}' >/dev/null +curl -s "$B/api/v1/errors/$ERRID_A" -H "$H" | python3 -c "import sys,json;d=json.load(sys.stdin);print('resolvedBy:',d['resolvedBy'])" +``` +PASS: `act.sub` is the user id (matches `cli whoami`), and `detail.resolvedBy` equals that user id (not null). A plain env key leaves it null (step 4). A **PAT** exchanged the same way also stamps `act` โ€” repeat with the stored PAT to confirm `ignoredByUserId` attribution. + +### 7. Negatives + +```bash +curl -s -o /dev/null -w 'unknown id: %{http_code} (404)\n' "$B/api/v1/errors/error_doesnotexist0000" -H "$H" +curl -s -o /dev/null -w 'no auth list: %{http_code} (401)\n' "$B/api/v1/errors" +curl -s -o /dev/null -w 'no auth resolve: %{http_code} (401)\n' -X POST "$B/api/v1/errors/$ERRID_B/resolve" -H 'Content-Type: application/json' -d '{}' + +# read-only JWT must be denied on write, allowed on read +READJWT=$(curl -sS -X POST "$B/api/v1/projects/$REF/dev/jwt" -H "Authorization: Bearer $TOKEN" \ + -H 'Content-Type: application/json' -d '{"claims":{"scopes":["read:errors"]}}' | python3 -c "import sys,json;print(json.load(sys.stdin)['token'])") +curl -s -o /dev/null -w 'read JWT write: %{http_code} (403)\n' -X POST "$B/api/v1/errors/$ERRID_B/resolve" -H "Authorization: Bearer $READJWT" -H 'Content-Type: application/json' -d '{}' +curl -s -o /dev/null -w 'read JWT read: %{http_code} (200)\n' "$B/api/v1/errors?filter%5BtaskIdentifier%5D=$TASK" -H "Authorization: Bearer $READJWT" +``` +PASS: `404`, `401`, `401`, `403`, `200` respectively. + +## Result + +Report PASS only if: step 1 lands 2 groups in `errors_v1`; step 2's filters and +pagination narrow correctly; step 3 returns the detail; step 4's resolve/ignore/ +unresolve flip status (and `filter[status]` follows); step 5's `filter[error]` +returns the paired run; step 6 records `resolvedBy` = the acting user via the +JWT exchange (null with a plain env key); and step 7 returns 404/401/401/403/200. +A red leg is a bug or a missing prereq โ€” report the exact status + body and file +a Linear issue, don't tune around it. + +## Notes / gotchas + +- Run files use a unique `$RUN` suffix per invocation, so reruns don't collide and seeded rows stay isolated by their unique task identifier. They are local-dev test rows (90-day ClickHouse TTL); no cleanup required. +- After **adding** the route files, the classic Remix dev compiler may not register them until a dev-server restart (a stale manifest returns Remix's HTML 404 on the new paths). If `POST โ€ฆ/resolve` returns a 404 HTML page rather than 401/200, restart `pnpm run dev --filter webapp`. +- The rbac `act` extraction lives in `@trigger.dev/rbac` (a built dep). After editing it, `pnpm run build --filter @trigger.dev/rbac` and restart the webapp so the attribution leg (step 6) reflects the change. diff --git a/.claude/skills/span-timeline-events/SKILL.md b/.claude/skills/span-timeline-events/SKILL.md new file mode 100644 index 00000000000..122f49912d7 --- /dev/null +++ b/.claude/skills/span-timeline-events/SKILL.md @@ -0,0 +1,78 @@ +--- +name: span-timeline-events +description: Use when adding, modifying, or debugging OTel span timeline events in the trace view. Covers event structure, ClickHouse storage constraints, rendering in SpanTimeline component, admin visibility, and the step-by-step process for adding new events. +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +--- + +# Span Timeline Events + +The trace view's right panel shows a timeline of events for the selected span. These are OTel span events rendered by `app/utils/timelineSpanEvents.ts` and the `SpanTimeline` component. + +## How They Work + +1. **Span events** in OTel are attached to a parent span. In ClickHouse, they're stored as separate rows with `kind: "SPAN_EVENT"` sharing the parent span's `span_id`. The `#mergeRecordsIntoSpanDetail` method reassembles them into the span's `events` array at query time. +2. The timeline only renders events whose `name` starts with `trigger.dev/` - all others are silently filtered out. +3. The **display name** comes from `properties.event` (not the span event name), mapped through `getFriendlyNameForEvent()`. +4. Events are shown on the **span they belong to** - events on one span don't appear in another span's timeline. + +## ClickHouse Storage Constraint + +When events are written to ClickHouse, `spanEventsToTaskEventV1Input()` filters out events whose `start_time` is not greater than the parent span's `startTime`. Events at or before the span start are silently dropped. This means span events must have timestamps strictly after the span's own `startTimeUnixNano`. + +## Timeline Rendering (SpanTimeline component) + +The `SpanTimeline` component in `app/components/run/RunTimeline.tsx` renders: + +1. **Events** (thin 1px line with hollow dots) - all events from `createTimelineSpanEventsFromSpanEvents()` +2. **"Started"** marker (thick cap) - at the span's `startTime` +3. **Duration bar** (thick 7px line) - from "Started" to "Finished" +4. **"Finished"** marker (thick cap) - at `startTime + duration` + +The thin line before "Started" only appears when there are events with timestamps between the span start and the first child span. For the Attempt span this works well (Dequeued -> Pod scheduled -> Launched -> etc. all happen before execution starts). Events all get `lineVariant: "light"` (thin) while the execution bar gets `variant: "normal"` (thick). + +## Trace View Sort Order + +Sibling spans (same parent) are sorted by `start_time ASC` from the ClickHouse query. The `createTreeFromFlatItems` function preserves this order. Event timestamps don't affect sort order - only the span's own `start_time`. + +## Event Structure + +```typescript +// OTel span event format +{ + name: "trigger.dev/run", // Must start with "trigger.dev/" to render + timeUnixNano: "1711200000000000000", + attributes: [ + { key: "event", value: { stringValue: "dequeue" } }, // The actual event type + { key: "duration", value: { intValue: 150 } }, // Optional: duration in ms + ] +} +``` + +## Admin-Only Events + +`getAdminOnlyForEvent()` controls visibility. Events default to **admin-only** (`true`). + +| Event | Admin-only | Friendly name | +|-------|-----------|---------------| +| `dequeue` | No | Dequeued | +| `fork` | No | Launched | +| `import` | No (if no fork event) | Importing task file | +| `create_attempt` | Yes | Attempt created | +| `lazy_payload` | Yes | Lazy attempt initialized | +| `pod_scheduled` | Yes | Pod scheduled | +| (default) | Yes | (raw event name) | + +## Adding New Timeline Events + +1. Add OTLP span event with `name: "trigger.dev/"` and `properties.event: ""` +2. Event timestamp must be strictly after the parent span's `startTimeUnixNano` (ClickHouse drops earlier events) +3. Add friendly name in `getFriendlyNameForEvent()` in `app/utils/timelineSpanEvents.ts` +4. Set admin visibility in `getAdminOnlyForEvent()` +5. Optionally add help text in `getHelpTextForEvent()` + +## Key Files + +- `app/utils/timelineSpanEvents.ts` - filtering, naming, admin logic +- `app/components/run/RunTimeline.tsx` - `SpanTimeline` component (thin line + thick bar rendering) +- `app/presenters/v3/SpanPresenter.server.ts` - loads span data including events +- `app/v3/eventRepository/clickhouseEventRepository.server.ts` - `spanEventsToTaskEventV1Input()` (storage filter), `#mergeRecordsIntoSpanDetail` (reassembly) diff --git a/.claude/skills/trigger-dev-tasks/SKILL.md b/.claude/skills/trigger-dev-tasks/SKILL.md new file mode 100644 index 00000000000..791c22c27ed --- /dev/null +++ b/.claude/skills/trigger-dev-tasks/SKILL.md @@ -0,0 +1,200 @@ +--- +name: trigger-dev-tasks +description: Use this skill when writing, designing, or optimizing Trigger.dev background tasks and workflows. This includes creating reliable async tasks, implementing AI workflows, setting up scheduled jobs, structuring complex task hierarchies with subtasks, configuring build extensions for tools like ffmpeg or Puppeteer/Playwright, and handling task schemas with Zod validation. +allowed-tools: Read, Write, Edit, Glob, Grep, Bash +--- + +# Trigger.dev Task Expert + +You are an expert Trigger.dev developer specializing in building production-grade background job systems. Tasks deployed to Trigger.dev run in Node.js 21+ and use the `@trigger.dev/sdk` package. + +## Critical Rules + +1. **Always use `@trigger.dev/sdk`** - Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob` pattern +2. **Never use `node-fetch`** - Use the built-in `fetch` function +3. **Export all tasks** - Every task must be exported, including subtasks +4. **Never wrap wait/trigger calls in Promise.all** - `triggerAndWait`, `batchTriggerAndWait`, and `wait.*` calls cannot be wrapped in `Promise.all` or `Promise.allSettled` + +## Basic Task Pattern + +```ts +import { task } from "@trigger.dev/sdk"; + +export const processData = task({ + id: "process-data", + retry: { + maxAttempts: 10, + factor: 1.8, + minTimeoutInMs: 500, + maxTimeoutInMs: 30_000, + }, + run: async (payload: { userId: string; data: any[] }) => { + console.log(`Processing ${payload.data.length} items`); + return { processed: payload.data.length }; + }, +}); +``` + +## Schema Task (with validation) + +```ts +import { schemaTask } from "@trigger.dev/sdk"; +import { z } from "zod"; + +export const validatedTask = schemaTask({ + id: "validated-task", + schema: z.object({ + name: z.string(), + email: z.string().email(), + }), + run: async (payload) => { + // Payload is automatically validated and typed + return { message: `Hello ${payload.name}` }; + }, +}); +``` + +## Triggering Tasks + +### From Backend Code (type-only import to prevent dependency leakage) + +```ts +import { tasks } from "@trigger.dev/sdk"; +import type { processData } from "./trigger/tasks"; + +const handle = await tasks.trigger("process-data", { + userId: "123", + data: [{ id: 1 }], +}); +``` + +### From Inside Tasks + +```ts +export const parentTask = task({ + id: "parent-task", + run: async (payload) => { + // Trigger and wait - returns Result object, NOT direct output + const result = await childTask.triggerAndWait({ data: "value" }); + if (result.ok) { + console.log("Output:", result.output); + } else { + console.error("Failed:", result.error); + } + + // Or unwrap directly (throws on error) + const output = await childTask.triggerAndWait({ data: "value" }).unwrap(); + }, +}); +``` + +## Idempotency (Critical for Retries) + +Always use idempotency keys when triggering tasks from inside other tasks: + +```ts +import { idempotencyKeys } from "@trigger.dev/sdk"; + +export const paymentTask = task({ + id: "process-payment", + run: async (payload: { orderId: string }) => { + // Scoped to current run - survives retries + const key = await idempotencyKeys.create(`payment-${payload.orderId}`); + + await chargeCustomer.trigger(payload, { + idempotencyKey: key, + idempotencyKeyTTL: "24h", + }); + }, +}); +``` + +## Trigger Options + +```ts +await myTask.trigger(payload, { + delay: "1h", // Delay execution + ttl: "10m", // Cancel if not started within TTL + idempotencyKey: key, + queue: "my-queue", + machine: "large-1x", // micro, small-1x, small-2x, medium-1x, medium-2x, large-1x, large-2x + maxAttempts: 3, + tags: ["user_123"], // Max 10 tags + debounce: { // Consolidate rapid triggers + key: "unique-key", + delay: "5s", + mode: "trailing", // "leading" (default) or "trailing" + }, +}); +``` + +## Debouncing + +Consolidate multiple triggers into a single execution: + +```ts +// Rapid triggers with same key = single execution +await myTask.trigger({ userId: "123" }, { + debounce: { + key: "user-123-update", + delay: "5s", + }, +}); + +// Trailing mode: use payload from LAST trigger +await myTask.trigger({ data: "latest" }, { + debounce: { + key: "my-key", + delay: "10s", + mode: "trailing", + }, +}); +``` + +Use cases: user activity updates, webhook deduplication, search indexing, notification batching. + +## Batch Triggering + +Up to 1,000 items per batch, 3MB per payload: + +```ts +const results = await myTask.batchTriggerAndWait([ + { payload: { userId: "1" } }, + { payload: { userId: "2" } }, +]); + +for (const result of results) { + if (result.ok) console.log(result.output); +} +``` + +## Machine Presets + +| Preset | vCPU | Memory | +|-------------|------|--------| +| micro | 0.25 | 0.25GB | +| small-1x | 0.5 | 0.5GB | +| small-2x | 1 | 1GB | +| medium-1x | 1 | 2GB | +| medium-2x | 2 | 4GB | +| large-1x | 4 | 8GB | +| large-2x | 8 | 16GB | + +## Design Principles + +1. **Break complex workflows into subtasks** that can be independently retried and made idempotent +2. **Don't over-complicate** - Sometimes `Promise.allSettled` inside a single task is better than many subtasks (each task has dedicated process and is charged by millisecond) +3. **Always configure retries** - Set appropriate `maxAttempts` based on the operation +4. **Use idempotency keys** - Especially for payment/critical operations +5. **Group related subtasks** - Keep subtasks only used by one parent in the same file, don't export them +6. **Use logger** - Log at key execution points with `logger.info()`, `logger.error()`, etc. + +## Reference Documentation + +For detailed documentation on specific topics, read these files: + +- `basic-tasks.md` - Task basics, triggering, waits +- `advanced-tasks.md` - Tags, queues, concurrency, metadata, error handling +- `scheduled-tasks.md` - Cron schedules, declarative and imperative +- `realtime.md` - Real-time subscriptions, streams, React hooks +- `config.md` - trigger.config.ts, build extensions (Prisma, Playwright, FFmpeg, etc.) diff --git a/.claude/skills/trigger-dev-tasks/advanced-tasks.md b/.claude/skills/trigger-dev-tasks/advanced-tasks.md new file mode 100644 index 00000000000..32a00337f89 --- /dev/null +++ b/.claude/skills/trigger-dev-tasks/advanced-tasks.md @@ -0,0 +1,485 @@ +# Trigger.dev Advanced Tasks (v4) + +**Advanced patterns and features for writing tasks** + +## Tags & Organization + +```ts +import { task, tags } from "@trigger.dev/sdk"; + +export const processUser = task({ + id: "process-user", + run: async (payload: { userId: string; orgId: string }, { ctx }) => { + // Add tags during execution + await tags.add(`user_${payload.userId}`); + await tags.add(`org_${payload.orgId}`); + + return { processed: true }; + }, +}); + +// Trigger with tags +await processUser.trigger( + { userId: "123", orgId: "abc" }, + { tags: ["priority", "user_123", "org_abc"] } // Max 10 tags per run +); + +// Subscribe to tagged runs +for await (const run of runs.subscribeToRunsWithTag("user_123")) { + console.log(`User task ${run.id}: ${run.status}`); +} +``` + +**Tag Best Practices:** + +- Use prefixes: `user_123`, `org_abc`, `video:456` +- Max 10 tags per run, 1-64 characters each +- Tags don't propagate to child tasks automatically + +## Batch Triggering v2 + +Enhanced batch triggering with larger payloads and streaming ingestion. + +### Limits + +- **Maximum batch size**: 1,000 items (increased from 500) +- **Payload per item**: 3MB each (increased from 1MB combined) +- Payloads > 512KB automatically offload to object storage + +### Rate Limiting (per environment) + +| Tier | Bucket Size | Refill Rate | +|------|-------------|-------------| +| Free | 1,200 runs | 100 runs/10 sec | +| Hobby | 5,000 runs | 500 runs/5 sec | +| Pro | 5,000 runs | 500 runs/5 sec | + +### Concurrent Batch Processing + +| Tier | Concurrent Batches | +|------|-------------------| +| Free | 1 | +| Hobby | 10 | +| Pro | 10 | + +### Usage + +```ts +import { myTask } from "./trigger/myTask"; + +// Basic batch trigger (up to 1,000 items) +const runs = await myTask.batchTrigger([ + { payload: { userId: "user-1" } }, + { payload: { userId: "user-2" } }, + { payload: { userId: "user-3" } }, +]); + +// Batch trigger with wait +const results = await myTask.batchTriggerAndWait([ + { payload: { userId: "user-1" } }, + { payload: { userId: "user-2" } }, +]); + +for (const result of results) { + if (result.ok) { + console.log("Result:", result.output); + } +} + +// With per-item options +const batchHandle = await myTask.batchTrigger([ + { + payload: { userId: "123" }, + options: { + idempotencyKey: "user-123-batch", + tags: ["priority"], + }, + }, + { + payload: { userId: "456" }, + options: { + idempotencyKey: "user-456-batch", + }, + }, +]); +``` + +## Debouncing + +Consolidate multiple triggers into a single execution by debouncing task runs with a unique key and delay window. + +### Use Cases + +- **User activity updates**: Batch rapid user actions into a single run +- **Webhook deduplication**: Handle webhook bursts without redundant processing +- **Search indexing**: Combine document updates instead of processing individually +- **Notification batching**: Group notifications to prevent user spam + +### Basic Usage + +```ts +await myTask.trigger( + { userId: "123" }, + { + debounce: { + key: "user-123-update", // Unique identifier for debounce group + delay: "5s", // Wait duration ("5s", "1m", or milliseconds) + }, + } +); +``` + +### Execution Modes + +**Leading Mode** (default): Uses payload/options from the first trigger; subsequent triggers only reschedule execution time. + +```ts +// First trigger sets the payload +await myTask.trigger({ action: "first" }, { + debounce: { key: "my-key", delay: "10s" } +}); + +// Second trigger only reschedules - payload remains "first" +await myTask.trigger({ action: "second" }, { + debounce: { key: "my-key", delay: "10s" } +}); +// Task executes with { action: "first" } +``` + +**Trailing Mode**: Uses payload/options from the most recent trigger. + +```ts +await myTask.trigger( + { data: "latest-value" }, + { + debounce: { + key: "trailing-example", + delay: "10s", + mode: "trailing", + }, + } +); +``` + +In trailing mode, these options update with each trigger: +- `payload` โ€” task input data +- `metadata` โ€” run metadata +- `tags` โ€” run tags (replaces existing) +- `maxAttempts` โ€” retry attempts +- `maxDuration` โ€” maximum compute time +- `machine` โ€” machine preset + +### Important Notes + +- Idempotency keys take precedence over debounce settings +- Compatible with `triggerAndWait()` โ€” parent runs block correctly on debounced execution +- Debounce key is scoped to the task + +## Concurrency & Queues + +```ts +import { task, queue } from "@trigger.dev/sdk"; + +// Shared queue for related tasks +const emailQueue = queue({ + name: "email-processing", + concurrencyLimit: 5, // Max 5 emails processing simultaneously +}); + +// Task-level concurrency +export const oneAtATime = task({ + id: "sequential-task", + queue: { concurrencyLimit: 1 }, // Process one at a time + run: async (payload) => { + // Critical section - only one instance runs + }, +}); + +// Per-user concurrency +export const processUserData = task({ + id: "process-user-data", + run: async (payload: { userId: string }) => { + // Override queue with user-specific concurrency + await childTask.trigger(payload, { + queue: { + name: `user-${payload.userId}`, + concurrencyLimit: 2, + }, + }); + }, +}); + +export const emailTask = task({ + id: "send-email", + queue: emailQueue, // Use shared queue + run: async (payload: { to: string }) => { + // Send email logic + }, +}); +``` + +## Error Handling & Retries + +```ts +import { task, retry, AbortTaskRunError } from "@trigger.dev/sdk"; + +export const resilientTask = task({ + id: "resilient-task", + retry: { + maxAttempts: 10, + factor: 1.8, // Exponential backoff multiplier + minTimeoutInMs: 500, + maxTimeoutInMs: 30_000, + randomize: false, + }, + catchError: async ({ error, ctx }) => { + // Custom error handling + if (error.code === "FATAL_ERROR") { + throw new AbortTaskRunError("Cannot retry this error"); + } + + // Log error details + console.error(`Task ${ctx.task.id} failed:`, error); + + // Allow retry by returning nothing + return { retryAt: new Date(Date.now() + 60000) }; // Retry in 1 minute + }, + run: async (payload) => { + // Retry specific operations + const result = await retry.onThrow( + async () => { + return await unstableApiCall(payload); + }, + { maxAttempts: 3 } + ); + + // Conditional HTTP retries + const response = await retry.fetch("https://api.example.com", { + retry: { + maxAttempts: 5, + condition: (response, error) => { + return response?.status === 429 || response?.status >= 500; + }, + }, + }); + + return result; + }, +}); +``` + +## Machines & Performance + +```ts +export const heavyTask = task({ + id: "heavy-computation", + machine: { preset: "large-2x" }, // 8 vCPU, 16 GB RAM + maxDuration: 1800, // 30 minutes timeout + run: async (payload, { ctx }) => { + // Resource-intensive computation + if (ctx.machine.preset === "large-2x") { + // Use all available cores + return await parallelProcessing(payload); + } + + return await standardProcessing(payload); + }, +}); + +// Override machine when triggering +await heavyTask.trigger(payload, { + machine: { preset: "medium-1x" }, // Override for this run +}); +``` + +**Machine Presets:** + +- `micro`: 0.25 vCPU, 0.25 GB RAM +- `small-1x`: 0.5 vCPU, 0.5 GB RAM (default) +- `small-2x`: 1 vCPU, 1 GB RAM +- `medium-1x`: 1 vCPU, 2 GB RAM +- `medium-2x`: 2 vCPU, 4 GB RAM +- `large-1x`: 4 vCPU, 8 GB RAM +- `large-2x`: 8 vCPU, 16 GB RAM + +## Idempotency + +```ts +import { task, idempotencyKeys } from "@trigger.dev/sdk"; + +export const paymentTask = task({ + id: "process-payment", + retry: { + maxAttempts: 3, + }, + run: async (payload: { orderId: string; amount: number }) => { + // Automatically scoped to this task run, so if the task is retried, the idempotency key will be the same + const idempotencyKey = await idempotencyKeys.create(`payment-${payload.orderId}`); + + // Ensure payment is processed only once + await chargeCustomer.trigger(payload, { + idempotencyKey, + idempotencyKeyTTL: "24h", // Key expires in 24 hours + }); + }, +}); + +// Payload-based idempotency +import { createHash } from "node:crypto"; + +function createPayloadHash(payload: any): string { + const hash = createHash("sha256"); + hash.update(JSON.stringify(payload)); + return hash.digest("hex"); +} + +export const deduplicatedTask = task({ + id: "deduplicated-task", + run: async (payload) => { + const payloadHash = createPayloadHash(payload); + const idempotencyKey = await idempotencyKeys.create(payloadHash); + + await processData.trigger(payload, { idempotencyKey }); + }, +}); +``` + +## Metadata & Progress Tracking + +```ts +import { task, metadata } from "@trigger.dev/sdk"; + +export const batchProcessor = task({ + id: "batch-processor", + run: async (payload: { items: any[] }, { ctx }) => { + const totalItems = payload.items.length; + + // Initialize progress metadata + metadata + .set("progress", 0) + .set("totalItems", totalItems) + .set("processedItems", 0) + .set("status", "starting"); + + const results = []; + + for (let i = 0; i < payload.items.length; i++) { + const item = payload.items[i]; + + // Process item + const result = await processItem(item); + results.push(result); + + // Update progress + const progress = ((i + 1) / totalItems) * 100; + metadata + .set("progress", progress) + .increment("processedItems", 1) + .append("logs", `Processed item ${i + 1}/${totalItems}`) + .set("currentItem", item.id); + } + + // Final status + metadata.set("status", "completed"); + + return { results, totalProcessed: results.length }; + }, +}); + +// Update parent metadata from child task +export const childTask = task({ + id: "child-task", + run: async (payload, { ctx }) => { + // Update parent task metadata + metadata.parent.set("childStatus", "processing"); + metadata.root.increment("childrenCompleted", 1); + + return { processed: true }; + }, +}); +``` + +## Logging & Tracing + +```ts +import { task, logger } from "@trigger.dev/sdk"; + +export const tracedTask = task({ + id: "traced-task", + run: async (payload, { ctx }) => { + logger.info("Task started", { userId: payload.userId }); + + // Custom trace with attributes + const user = await logger.trace( + "fetch-user", + async (span) => { + span.setAttribute("user.id", payload.userId); + span.setAttribute("operation", "database-fetch"); + + const userData = await database.findUser(payload.userId); + span.setAttribute("user.found", !!userData); + + return userData; + }, + { userId: payload.userId } + ); + + logger.debug("User fetched", { user: user.id }); + + try { + const result = await processUser(user); + logger.info("Processing completed", { result }); + return result; + } catch (error) { + logger.error("Processing failed", { + error: error.message, + userId: payload.userId, + }); + throw error; + } + }, +}); +``` + +## Hidden Tasks + +```ts +// Hidden task - not exported, only used internally +const internalProcessor = task({ + id: "internal-processor", + run: async (payload: { data: string }) => { + return { processed: payload.data.toUpperCase() }; + }, +}); + +// Public task that uses hidden task +export const publicWorkflow = task({ + id: "public-workflow", + run: async (payload: { input: string }) => { + // Use hidden task internally + const result = await internalProcessor.triggerAndWait({ + data: payload.input, + }); + + if (result.ok) { + return { output: result.output.processed }; + } + + throw new Error("Internal processing failed"); + }, +}); +``` + +## Best Practices + +- **Concurrency**: Use queues to prevent overwhelming external services +- **Retries**: Configure exponential backoff for transient failures +- **Idempotency**: Always use for payment/critical operations +- **Metadata**: Track progress for long-running tasks +- **Machines**: Match machine size to computational requirements +- **Tags**: Use consistent naming patterns for filtering +- **Debouncing**: Use for user activity, webhooks, and notification batching +- **Batch triggering**: Use for bulk operations up to 1,000 items +- **Error Handling**: Distinguish between retryable and fatal errors + +Design tasks to be stateless, idempotent, and resilient to failures. Use metadata for state tracking and queues for resource management. diff --git a/.claude/skills/trigger-dev-tasks/basic-tasks.md b/.claude/skills/trigger-dev-tasks/basic-tasks.md new file mode 100644 index 00000000000..56bff340761 --- /dev/null +++ b/.claude/skills/trigger-dev-tasks/basic-tasks.md @@ -0,0 +1,199 @@ +# Trigger.dev Basic Tasks (v4) + +**MUST use `@trigger.dev/sdk`, NEVER `client.defineJob`** + +## Basic Task + +```ts +import { task } from "@trigger.dev/sdk"; + +export const processData = task({ + id: "process-data", + retry: { + maxAttempts: 10, + factor: 1.8, + minTimeoutInMs: 500, + maxTimeoutInMs: 30_000, + randomize: false, + }, + run: async (payload: { userId: string; data: any[] }) => { + // Task logic - runs for long time, no timeouts + console.log(`Processing ${payload.data.length} items for user ${payload.userId}`); + return { processed: payload.data.length }; + }, +}); +``` + +## Schema Task (with validation) + +```ts +import { schemaTask } from "@trigger.dev/sdk"; +import { z } from "zod"; + +export const validatedTask = schemaTask({ + id: "validated-task", + schema: z.object({ + name: z.string(), + age: z.number(), + email: z.string().email(), + }), + run: async (payload) => { + // Payload is automatically validated and typed + return { message: `Hello ${payload.name}, age ${payload.age}` }; + }, +}); +``` + +## Triggering Tasks + +### From Backend Code + +```ts +import { tasks } from "@trigger.dev/sdk"; +import type { processData } from "./trigger/tasks"; + +// Single trigger +const handle = await tasks.trigger("process-data", { + userId: "123", + data: [{ id: 1 }, { id: 2 }], +}); + +// Batch trigger (up to 1,000 items, 3MB per payload) +const batchHandle = await tasks.batchTrigger("process-data", [ + { payload: { userId: "123", data: [{ id: 1 }] } }, + { payload: { userId: "456", data: [{ id: 2 }] } }, +]); +``` + +### Debounced Triggering + +Consolidate multiple triggers into a single execution: + +```ts +// Multiple rapid triggers with same key = single execution +await myTask.trigger( + { userId: "123" }, + { + debounce: { + key: "user-123-update", // Unique key for debounce group + delay: "5s", // Wait before executing + }, + } +); + +// Trailing mode: use payload from LAST trigger +await myTask.trigger( + { data: "latest-value" }, + { + debounce: { + key: "trailing-example", + delay: "10s", + mode: "trailing", // Default is "leading" (first payload) + }, + } +); +``` + +**Debounce modes:** +- `leading` (default): Uses payload from first trigger, subsequent triggers only reschedule +- `trailing`: Uses payload from most recent trigger + +### From Inside Tasks (with Result handling) + +```ts +export const parentTask = task({ + id: "parent-task", + run: async (payload) => { + // Trigger and continue + const handle = await childTask.trigger({ data: "value" }); + + // Trigger and wait - returns Result object, NOT task output + const result = await childTask.triggerAndWait({ data: "value" }); + if (result.ok) { + console.log("Task output:", result.output); // Actual task return value + } else { + console.error("Task failed:", result.error); + } + + // Quick unwrap (throws on error) + const output = await childTask.triggerAndWait({ data: "value" }).unwrap(); + + // Batch trigger and wait + const results = await childTask.batchTriggerAndWait([ + { payload: { data: "item1" } }, + { payload: { data: "item2" } }, + ]); + + for (const run of results) { + if (run.ok) { + console.log("Success:", run.output); + } else { + console.log("Failed:", run.error); + } + } + }, +}); + +export const childTask = task({ + id: "child-task", + run: async (payload: { data: string }) => { + return { processed: payload.data }; + }, +}); +``` + +> Never wrap triggerAndWait or batchTriggerAndWait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks. + +## Waits + +```ts +import { task, wait } from "@trigger.dev/sdk"; + +export const taskWithWaits = task({ + id: "task-with-waits", + run: async (payload) => { + console.log("Starting task"); + + // Wait for specific duration + await wait.for({ seconds: 30 }); + await wait.for({ minutes: 5 }); + await wait.for({ hours: 1 }); + await wait.for({ days: 1 }); + + // Wait until specific date + await wait.until({ date: new Date("2024-12-25") }); + + // Wait for token (from external system) + await wait.forToken({ + token: "user-approval-token", + timeoutInSeconds: 3600, // 1 hour timeout + }); + + console.log("All waits completed"); + return { status: "completed" }; + }, +}); +``` + +> Never wrap wait calls in a Promise.all or Promise.allSettled as this is not supported in Trigger.dev tasks. + +## Key Points + +- **Result vs Output**: `triggerAndWait()` returns a `Result` object with `ok`, `output`, `error` properties - NOT the direct task output +- **Type safety**: Use `import type` for task references when triggering from backend +- **Waits > 5 seconds**: Automatically checkpointed, don't count toward compute usage +- **Debounce + idempotency**: Idempotency keys take precedence over debounce settings + +## NEVER Use (v2 deprecated) + +```ts +// BREAKS APPLICATION +client.defineJob({ + id: "job-id", + run: async (payload, io) => { + /* ... */ + }, +}); +``` + +Use SDK (`@trigger.dev/sdk`), check `result.ok` before accessing `result.output` diff --git a/.claude/skills/trigger-dev-tasks/config.md b/.claude/skills/trigger-dev-tasks/config.md new file mode 100644 index 00000000000..f6a4db1c4b8 --- /dev/null +++ b/.claude/skills/trigger-dev-tasks/config.md @@ -0,0 +1,346 @@ +# Trigger.dev Configuration + +**Complete guide to configuring `trigger.config.ts` with build extensions** + +## Basic Configuration + +```ts +import { defineConfig } from "@trigger.dev/sdk"; + +export default defineConfig({ + project: "", // Required: Your project reference + dirs: ["./trigger"], // Task directories + runtime: "node", // "node", "node-22", or "bun" + logLevel: "info", // "debug", "info", "warn", "error" + + // Default retry settings + retries: { + enabledInDev: false, + default: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + factor: 2, + randomize: true, + }, + }, + + // Build configuration + build: { + autoDetectExternal: true, + keepNames: true, + minify: false, + extensions: [], // Build extensions go here + }, + + // Global lifecycle hooks + onStartAttempt: async ({ payload, ctx }) => { + console.log("Global task start"); + }, + onSuccess: async ({ payload, output, ctx }) => { + console.log("Global task success"); + }, + onFailure: async ({ payload, error, ctx }) => { + console.log("Global task failure"); + }, +}); +``` + +## Build Extensions + +### Database & ORM + +#### Prisma + +```ts +import { prismaExtension } from "@trigger.dev/build/extensions/prisma"; + +extensions: [ + prismaExtension({ + schema: "prisma/schema.prisma", + version: "5.19.0", // Optional: specify version + migrate: true, // Run migrations during build + directUrlEnvVarName: "DIRECT_DATABASE_URL", + typedSql: true, // Enable TypedSQL support + }), +]; +``` + +#### TypeScript Decorators (for TypeORM) + +```ts +import { emitDecoratorMetadata } from "@trigger.dev/build/extensions/typescript"; + +extensions: [ + emitDecoratorMetadata(), // Enables decorator metadata +]; +``` + +### Scripting Languages + +#### Python + +```ts +import { pythonExtension } from "@trigger.dev/build/extensions/python"; + +extensions: [ + pythonExtension({ + scripts: ["./python/**/*.py"], // Copy Python files + requirementsFile: "./requirements.txt", // Install packages + devPythonBinaryPath: ".venv/bin/python", // Dev mode binary + }), +]; + +// Usage in tasks +const result = await python.runInline(`print("Hello, world!")`); +const output = await python.runScript("./python/script.py", ["arg1"]); +``` + +### Browser Automation + +#### Playwright + +```ts +import { playwright } from "@trigger.dev/build/extensions/playwright"; + +extensions: [ + playwright({ + browsers: ["chromium", "firefox", "webkit"], // Default: ["chromium"] + headless: true, // Default: true + }), +]; +``` + +#### Puppeteer + +```ts +import { puppeteer } from "@trigger.dev/build/extensions/puppeteer"; + +extensions: [puppeteer()]; + +// Environment variable needed: +// PUPPETEER_EXECUTABLE_PATH: "/usr/bin/google-chrome-stable" +``` + +#### Lightpanda + +```ts +import { lightpanda } from "@trigger.dev/build/extensions/lightpanda"; + +extensions: [ + lightpanda({ + version: "latest", // or "nightly" + disableTelemetry: false, + }), +]; +``` + +### Media Processing + +#### FFmpeg + +```ts +import { ffmpeg } from "@trigger.dev/build/extensions/core"; + +extensions: [ + ffmpeg({ version: "7" }), // Static build, or omit for Debian version +]; + +// Automatically sets FFMPEG_PATH and FFPROBE_PATH +// Add fluent-ffmpeg to external packages if using +``` + +#### Audio Waveform + +```ts +import { audioWaveform } from "@trigger.dev/build/extensions/audioWaveform"; + +extensions: [ + audioWaveform(), // Installs Audio Waveform 1.1.0 +]; +``` + +### System & Package Management + +#### System Packages (apt-get) + +```ts +import { aptGet } from "@trigger.dev/build/extensions/core"; + +extensions: [ + aptGet({ + packages: ["ffmpeg", "imagemagick", "curl=7.68.0-1"], // Can specify versions + }), +]; +``` + +#### Additional NPM Packages + +Only use this for installing CLI tools, NOT packages you import in your code. + +```ts +import { additionalPackages } from "@trigger.dev/build/extensions/core"; + +extensions: [ + additionalPackages({ + packages: ["wrangler"], // CLI tools and specific versions + }), +]; +``` + +#### Additional Files + +```ts +import { additionalFiles } from "@trigger.dev/build/extensions/core"; + +extensions: [ + additionalFiles({ + files: ["wrangler.toml", "./assets/**", "./fonts/**"], // Glob patterns supported + }), +]; +``` + +### Environment & Build Tools + +#### Environment Variable Sync + +```ts +import { syncEnvVars } from "@trigger.dev/build/extensions/core"; + +extensions: [ + syncEnvVars(async (ctx) => { + // ctx contains: environment, projectRef, env + return [ + { name: "SECRET_KEY", value: await getSecret(ctx.environment) }, + { name: "API_URL", value: ctx.environment === "prod" ? "api.prod.com" : "api.dev.com" }, + ]; + }), +]; +``` + +#### ESBuild Plugins + +```ts +import { esbuildPlugin } from "@trigger.dev/build/extensions"; +import { sentryEsbuildPlugin } from "@sentry/esbuild-plugin"; + +extensions: [ + esbuildPlugin( + sentryEsbuildPlugin({ + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }), + { placement: "last", target: "deploy" } // Optional config + ), +]; +``` + +## Custom Build Extensions + +```ts +import { defineConfig } from "@trigger.dev/sdk"; + +const customExtension = { + name: "my-custom-extension", + + externalsForTarget: (target) => { + return ["some-native-module"]; // Add external dependencies + }, + + onBuildStart: async (context) => { + console.log(`Build starting for ${context.target}`); + // Register esbuild plugins, modify build context + }, + + onBuildComplete: async (context, manifest) => { + console.log("Build complete, adding layers"); + // Add build layers, modify deployment + context.addLayer({ + id: "my-layer", + files: [{ source: "./custom-file", destination: "/app/custom" }], + commands: ["chmod +x /app/custom"], + }); + }, +}; + +export default defineConfig({ + project: "my-project", + build: { + extensions: [customExtension], + }, +}); +``` + +## Advanced Configuration + +### Telemetry + +```ts +import { PrismaInstrumentation } from "@prisma/instrumentation"; +import { OpenAIInstrumentation } from "@langfuse/openai"; + +export default defineConfig({ + // ... other config + telemetry: { + instrumentations: [new PrismaInstrumentation(), new OpenAIInstrumentation()], + exporters: [customExporter], // Optional custom exporters + }, +}); +``` + +### Machine & Performance + +```ts +export default defineConfig({ + // ... other config + defaultMachine: "large-1x", // Default machine for all tasks + maxDuration: 300, // Default max duration (seconds) + enableConsoleLogging: true, // Console logging in development +}); +``` + +## Common Extension Combinations + +### Full-Stack Web App + +```ts +extensions: [ + prismaExtension({ schema: "prisma/schema.prisma", migrate: true }), + additionalFiles({ files: ["./public/**", "./assets/**"] }), + syncEnvVars(async (ctx) => [...envVars]), +]; +``` + +### AI/ML Processing + +```ts +extensions: [ + pythonExtension({ + scripts: ["./ai/**/*.py"], + requirementsFile: "./requirements.txt", + }), + ffmpeg({ version: "7" }), + additionalPackages({ packages: ["wrangler"] }), +]; +``` + +### Web Scraping + +```ts +extensions: [ + playwright({ browsers: ["chromium"] }), + puppeteer(), + additionalFiles({ files: ["./selectors.json", "./proxies.txt"] }), +]; +``` + +## Best Practices + +- **Use specific versions**: Pin extension versions for reproducible builds +- **External packages**: Add modules with native addons to the `build.external` array +- **Environment sync**: Use `syncEnvVars` for dynamic secrets +- **File paths**: Use glob patterns for flexible file inclusion +- **Debug builds**: Use `--log-level debug --dry-run` for troubleshooting + +Extensions only affect deployment, not local development. Use `external` array for packages that shouldn't be bundled. diff --git a/.claude/skills/trigger-dev-tasks/realtime.md b/.claude/skills/trigger-dev-tasks/realtime.md new file mode 100644 index 00000000000..c1c4c5821a9 --- /dev/null +++ b/.claude/skills/trigger-dev-tasks/realtime.md @@ -0,0 +1,244 @@ +# Trigger.dev Realtime + +**Real-time monitoring and updates for runs** + +## Core Concepts + +Realtime allows you to: + +- Subscribe to run status changes, metadata updates, and streams +- Build real-time dashboards and UI updates +- Monitor task progress from frontend and backend + +## Authentication + +### Public Access Tokens + +```ts +import { auth } from "@trigger.dev/sdk"; + +// Read-only token for specific runs +const publicToken = await auth.createPublicToken({ + scopes: { + read: { + runs: ["run_123", "run_456"], + tasks: ["my-task-1", "my-task-2"], + }, + }, + expirationTime: "1h", // Default: 15 minutes +}); +``` + +### Trigger Tokens (Frontend only) + +```ts +// Single-use token for triggering tasks +const triggerToken = await auth.createTriggerPublicToken("my-task", { + expirationTime: "30m", +}); +``` + +## Backend Usage + +### Subscribe to Runs + +```ts +import { runs, tasks } from "@trigger.dev/sdk"; + +// Trigger and subscribe +const handle = await tasks.trigger("my-task", { data: "value" }); + +// Subscribe to specific run +for await (const run of runs.subscribeToRun(handle.id)) { + console.log(`Status: ${run.status}, Progress: ${run.metadata?.progress}`); + if (run.status === "COMPLETED") break; +} + +// Subscribe to runs with tag +for await (const run of runs.subscribeToRunsWithTag("user-123")) { + console.log(`Tagged run ${run.id}: ${run.status}`); +} + +// Subscribe to batch +for await (const run of runs.subscribeToBatch(batchId)) { + console.log(`Batch run ${run.id}: ${run.status}`); +} +``` + +### Realtime Streams v2 + +```ts +import { streams, InferStreamType } from "@trigger.dev/sdk"; + +// 1. Define streams (shared location) +export const aiStream = streams.define({ + id: "ai-output", +}); + +export type AIStreamPart = InferStreamType; + +// 2. Pipe from task +export const streamingTask = task({ + id: "streaming-task", + run: async (payload) => { + const completion = await openai.chat.completions.create({ + model: "gpt-4", + messages: [{ role: "user", content: payload.prompt }], + stream: true, + }); + + const { waitUntilComplete } = aiStream.pipe(completion); + await waitUntilComplete(); + }, +}); + +// 3. Read from backend +const stream = await aiStream.read(runId, { + timeoutInSeconds: 300, + startIndex: 0, // Resume from specific chunk +}); + +for await (const chunk of stream) { + console.log("Chunk:", chunk); // Fully typed +} +``` + +## React Frontend Usage + +### Installation + +```bash +npm add @trigger.dev/react-hooks +``` + +### Triggering Tasks + +```tsx +"use client"; +import { useTaskTrigger, useRealtimeTaskTrigger } from "@trigger.dev/react-hooks"; +import type { myTask } from "../trigger/tasks"; + +function TriggerComponent({ accessToken }: { accessToken: string }) { + // Basic trigger + const { submit, handle, isLoading } = useTaskTrigger("my-task", { + accessToken, + }); + + // Trigger with realtime updates + const { + submit: realtimeSubmit, + run, + isLoading: isRealtimeLoading, + } = useRealtimeTaskTrigger("my-task", { accessToken }); + + return ( +
+ + + + + {run &&
Status: {run.status}
} +
+ ); +} +``` + +### Subscribing to Runs + +```tsx +"use client"; +import { useRealtimeRun, useRealtimeRunsWithTag } from "@trigger.dev/react-hooks"; +import type { myTask } from "../trigger/tasks"; + +function SubscribeComponent({ runId, accessToken }: { runId: string; accessToken: string }) { + // Subscribe to specific run + const { run, error } = useRealtimeRun(runId, { + accessToken, + onComplete: (run) => { + console.log("Task completed:", run.output); + }, + }); + + // Subscribe to tagged runs + const { runs } = useRealtimeRunsWithTag("user-123", { accessToken }); + + if (error) return
Error: {error.message}
; + if (!run) return
Loading...
; + + return ( +
+
Status: {run.status}
+
Progress: {run.metadata?.progress || 0}%
+ {run.output &&
Result: {JSON.stringify(run.output)}
} + +

Tagged Runs:

+ {runs.map((r) => ( +
+ {r.id}: {r.status} +
+ ))} +
+ ); +} +``` + +### Realtime Streams with React + +```tsx +"use client"; +import { useRealtimeStream } from "@trigger.dev/react-hooks"; +import { aiStream } from "../trigger/streams"; + +function StreamComponent({ runId, accessToken }: { runId: string; accessToken: string }) { + // Pass defined stream directly for type safety + const { parts, error } = useRealtimeStream(aiStream, runId, { + accessToken, + timeoutInSeconds: 300, + throttleInMs: 50, // Control re-render frequency + }); + + if (error) return
Error: {error.message}
; + if (!parts) return
Loading...
; + + const text = parts.join(""); // parts is typed as AIStreamPart[] + + return
Streamed Text: {text}
; +} +``` + +### Wait Tokens + +```tsx +"use client"; +import { useWaitToken } from "@trigger.dev/react-hooks"; + +function WaitTokenComponent({ tokenId, accessToken }: { tokenId: string; accessToken: string }) { + const { complete } = useWaitToken(tokenId, { accessToken }); + + return ; +} +``` + +## Run Object Properties + +Key properties available in run subscriptions: + +- `id`: Unique run identifier +- `status`: `QUEUED`, `EXECUTING`, `COMPLETED`, `FAILED`, `CANCELED`, etc. +- `payload`: Task input data (typed) +- `output`: Task result (typed, when completed) +- `metadata`: Real-time updatable data +- `createdAt`, `updatedAt`: Timestamps +- `costInCents`: Execution cost + +## Best Practices + +- **Use Realtime over SWR**: Recommended for most use cases due to rate limits +- **Scope tokens properly**: Only grant necessary read/trigger permissions +- **Handle errors**: Always check for errors in hooks and subscriptions +- **Type safety**: Use task types for proper payload/output typing +- **Cleanup subscriptions**: Backend subscriptions auto-complete, frontend hooks auto-cleanup diff --git a/.claude/skills/trigger-dev-tasks/scheduled-tasks.md b/.claude/skills/trigger-dev-tasks/scheduled-tasks.md new file mode 100644 index 00000000000..b314753497f --- /dev/null +++ b/.claude/skills/trigger-dev-tasks/scheduled-tasks.md @@ -0,0 +1,113 @@ +# Scheduled Tasks (Cron) + +Recurring tasks using cron. For one-off future runs, use the **delay** option. + +## Define a Scheduled Task + +```ts +import { schedules } from "@trigger.dev/sdk"; + +export const task = schedules.task({ + id: "first-scheduled-task", + run: async (payload) => { + payload.timestamp; // Date (scheduled time, UTC) + payload.lastTimestamp; // Date | undefined + payload.timezone; // IANA, e.g. "America/New_York" (default "UTC") + payload.scheduleId; // string + payload.externalId; // string | undefined + payload.upcoming; // Date[] + + payload.timestamp.toLocaleString("en-US", { timeZone: payload.timezone }); + }, +}); +``` + +> Scheduled tasks need at least one schedule attached to run. + +## Attach Schedules + +**Declarative (sync on dev/deploy):** + +```ts +schedules.task({ + id: "every-2h", + cron: "0 */2 * * *", // UTC + run: async () => {}, +}); + +schedules.task({ + id: "tokyo-5am", + cron: { pattern: "0 5 * * *", timezone: "Asia/Tokyo", environments: ["PRODUCTION", "STAGING"] }, + run: async () => {}, +}); +``` + +**Imperative (SDK or dashboard):** + +```ts +await schedules.create({ + task: task.id, + cron: "0 0 * * *", + timezone: "America/New_York", // DST-aware + externalId: "user_123", + deduplicationKey: "user_123-daily", // updates if reused +}); +``` + +### Dynamic / Multi-tenant Example + +```ts +// /trigger/reminder.ts +export const reminderTask = schedules.task({ + id: "todo-reminder", + run: async (p) => { + if (!p.externalId) throw new Error("externalId is required"); + const user = await db.getUser(p.externalId); + await sendReminderEmail(user); + }, +}); +``` + +```ts +// app/reminders/route.ts +export async function POST(req: Request) { + const data = await req.json(); + return Response.json( + await schedules.create({ + task: reminderTask.id, + cron: "0 8 * * *", + timezone: data.timezone, + externalId: data.userId, + deduplicationKey: `${data.userId}-reminder`, + }) + ); +} +``` + +## Cron Syntax (no seconds) + +``` +* * * * * +| | | | โ”” day of week (0โ€“7 or 1Lโ€“7L; 0/7=Sun; L=last) +| | | โ””โ”€โ”€ month (1โ€“12) +| | โ””โ”€โ”€โ”€โ”€ day of month (1โ€“31 or L) +| โ””โ”€โ”€โ”€โ”€โ”€โ”€ hour (0โ€“23) +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ minute (0โ€“59) +``` + +## When Schedules Won't Trigger + +- **Dev:** only when the dev CLI is running. +- **Staging/Production:** only for tasks in the **latest deployment**. + +## SDK Management + +```ts +await schedules.retrieve(id); +await schedules.list(); +await schedules.update(id, { cron: "0 0 1 * *", externalId: "ext", deduplicationKey: "key" }); +await schedules.deactivate(id); +await schedules.activate(id); +await schedules.del(id); +await schedules.timezones(); // list of IANA timezones +``` diff --git a/.configs/tsconfig.base.json b/.configs/tsconfig.base.json new file mode 100644 index 00000000000..2d560d22d0f --- /dev/null +++ b/.configs/tsconfig.base.json @@ -0,0 +1,36 @@ +{ + "compilerOptions": { + "target": "es2022", + "lib": ["ES2022", "DOM", "DOM.Iterable", "DOM.AsyncIterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "moduleDetection": "force", + "verbatimModuleSyntax": false, + "jsx": "react", + + "strict": true, + "alwaysStrict": true, + "strictPropertyInitialization": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitAny": true, + "noImplicitReturns": true, + "noImplicitThis": true, + + "noFallthroughCasesInSwitch": true, + "resolveJsonModule": true, + + "removeComments": false, + "esModuleInterop": true, + "emitDecoratorMetadata": false, + "experimentalDecorators": false, + "downlevelIteration": true, + "isolatedModules": true, + "noUncheckedIndexedAccess": true, + + "pretty": true, + "customConditions": ["@triggerdotdev/source"] + } +} diff --git a/.cursor/commands/deslop.md b/.cursor/commands/deslop.md new file mode 100644 index 00000000000..d82835663f7 --- /dev/null +++ b/.cursor/commands/deslop.md @@ -0,0 +1,11 @@ +# Remove AI code slop + +Check the diff against main, and remove all AI generated slop introduced in this branch. + +This includes: +- Extra comments that a human wouldn't add or is inconsistent with the rest of the file +- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths) +- Casts to any to get around type issues +- Any other style that is inconsistent with the file + +Report at the end with only a 1-3 sentence summary of what you changed \ No newline at end of file diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 00000000000..c4b06a67630 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,7 @@ +{ + "mcpServers": { + "linear": { + "url": "https://mcp.linear.app/mcp" + } + } +} diff --git a/.cursor/rules/executing-commands.mdc b/.cursor/rules/executing-commands.mdc new file mode 100644 index 00000000000..0d36b449491 --- /dev/null +++ b/.cursor/rules/executing-commands.mdc @@ -0,0 +1,24 @@ +--- +description: how to run commands in the monorepo +globs: +alwaysApply: true +--- +Almost all commands in the monorepo should be executed when `pnpm run ...` from the root of the monorepo. For example, running tests for the `@internal/run-engine` internal package: + +``` +pnpm run dev --filter webapp +``` + +But often, when running tests, it's better to `cd` into the directory and then run tests: + +``` +cd apps/webapp +pnpm run test --run +``` + +This way you can run for a single file easily: + +``` +cd internal-packages/run-engine +pnpm run test ./src/engine/tests/ttl.test.ts --run +``` diff --git a/.cursor/rules/migrations.mdc b/.cursor/rules/migrations.mdc new file mode 100644 index 00000000000..370c87c051d --- /dev/null +++ b/.cursor/rules/migrations.mdc @@ -0,0 +1,6 @@ +--- +description: how to create and apply database migrations +alwaysApply: false +--- + +Follow our [migrations.md](mdc:ai/references/migrations.md) guide for how to create and apply database migrations. diff --git a/.cursor/rules/otel-metrics.mdc b/.cursor/rules/otel-metrics.mdc new file mode 100644 index 00000000000..218f07c41e2 --- /dev/null +++ b/.cursor/rules/otel-metrics.mdc @@ -0,0 +1,66 @@ +--- +description: Guidelines for creating OpenTelemetry metrics to avoid cardinality issues +globs: + - "**/*.ts" +--- + +# OpenTelemetry Metrics Guidelines + +When creating or editing OTEL metrics (counters, histograms, gauges), always ensure metric attributes have **low cardinality**. + +## What is Cardinality? + +Cardinality refers to the number of unique values an attribute can have. Each unique combination of attribute values creates a new time series, which consumes memory and storage in your metrics backend. + +## Rules + +### DO use low-cardinality attributes: +- **Enums**: `environment_type` (PRODUCTION, STAGING, DEVELOPMENT, PREVIEW) +- **Booleans**: `hasFailures`, `streaming`, `success` +- **Bounded error codes**: A finite, controlled set of error types +- **Shard IDs**: When sharding is bounded (e.g., 0-15) + +### DO NOT use high-cardinality attributes: +- **UUIDs/IDs**: `envId`, `userId`, `runId`, `projectId`, `organizationId` +- **Unbounded integers**: `itemCount`, `batchSize`, `retryCount` +- **Timestamps**: `createdAt`, `startTime` +- **Free-form strings**: `errorMessage`, `taskName`, `queueName` + +## Example + +```typescript +// BAD - High cardinality +this.counter.add(1, { + envId: options.environmentId, // UUID - unbounded + itemCount: options.runCount, // Integer - unbounded +}); + +// GOOD - Low cardinality +this.counter.add(1, { + environment_type: options.environmentType, // Enum - 4 values + streaming: true, // Boolean - 2 values +}); +``` + +## Prometheus Metric Naming + +When metrics are exported via OTLP to Prometheus, the exporter automatically adds unit suffixes to metric names: + +| OTel Metric Name | Unit | Prometheus Name | +|------------------|------|-----------------| +| `my_duration_ms` | `ms` | `my_duration_ms_milliseconds` | +| `my_counter` | counter | `my_counter_total` | +| `items_inserted` | counter | `items_inserted_inserts_total` | +| `batch_size` | histogram | `batch_size_items_bucket` | + +Keep this in mind when writing Grafana dashboards or Prometheus queriesโ€”the metric names in Prometheus will differ from the names defined in code. + +## Reference + +See the schedule engine (`internal-packages/schedule-engine/src/engine/index.ts`) for a good example of low-cardinality metric attributes. + +High cardinality metrics can cause: +- Memory bloat in metrics backends (Axiom, Prometheus, etc.) +- Slow queries and dashboard timeouts +- Increased costs (many backends charge per time series) +- Potential data loss or crashes at scale diff --git a/.cursor/rules/repo.mdc b/.cursor/rules/repo.mdc new file mode 100644 index 00000000000..460c3893656 --- /dev/null +++ b/.cursor/rules/repo.mdc @@ -0,0 +1,6 @@ +--- +description: understanding the structure of the monorepo +globs: +alwaysApply: true +--- +We've documented the structure of our monorepo here: [repo.md](mdc:ai/references/repo.md) \ No newline at end of file diff --git a/.cursor/rules/webapp.mdc b/.cursor/rules/webapp.mdc new file mode 100644 index 00000000000..f1333febdc0 --- /dev/null +++ b/.cursor/rules/webapp.mdc @@ -0,0 +1,40 @@ +--- +description: Making updates to the main trigger.dev remix webapp +globs: apps/webapp/**/*.tsx,apps/webapp/**/*.ts +alwaysApply: false +--- + +The main trigger.dev webapp, which powers it's API and dashboard and makes up the docker image that is produced as an OSS image, is a Remix 2.17.4 app that uses an express server, written in TypeScript. The following subsystems are either included in the webapp or are used by the webapp in another part of the monorepo: + +- `@trigger.dev/database` exports a Prisma 6.14.0 client that is used extensively in the webapp to access a PostgreSQL instance. The schema file is [schema.prisma](mdc:internal-packages/database/prisma/schema.prisma) +- `@trigger.dev/core` is a published package and is used to share code between the `@trigger.dev/sdk` and the webapp. It includes functionality but also a load of Zod schemas for data validation. When importing from `@trigger.dev/core` in the webapp, we never import the root `@trigger.dev/core` path, instead we favor one of the subpath exports that you can find in [package.json](mdc:packages/core/package.json) +- `@internal/run-engine` has all the code needed to trigger a run and take it through it's lifecycle to completion. +- `@trigger.dev/redis-worker` is a custom redis based background job/worker system that's used in the webapp and also used inside the run engine. + +## Environment variables and testing + +In the webapp, all environment variables are accessed through the `env` export of [env.server.ts](mdc:apps/webapp/app/env.server.ts), instead of directly accessing `process.env`. + +Ideally, the `env.server.ts` file would never be imported into a test file, either directly or indirectly. Tests should only imported classes and functions from a file matching `app/**/*.ts` of the webapp, and that file should not use environment variables, everything should be passed through as options instead. This "service/configuration" separation is important, and can be seen in a few places in the code for examples: + +- [realtimeClient.server.ts](mdc:apps/webapp/app/services/realtimeClient.server.ts) is the testable service, and [realtimeClientGlobal.server.ts](mdc:apps/webapp/app/services/realtimeClientGlobal.server.ts) is the configuration + +Also for writing tests in the webapp, checkout our [tests.md](mdc:ai/references/tests.md) guide + +## Legacy run engine vs Run Engine 2.0 + +We originally the Trigger.dev "Run Engine" not as a single system, but just spread out all over the codebase, with no real separate or encapsulation. And we didn't even call it a "Run Engine". With Run Engine 2.0, we've completely rewritten big parts of the way the system works, and moved it over to an internal package called `@internal/run-engine`. So we've retroactively named the previous run engine "Legacy run engine". We're focused almost exclusively now on moving to Run Engine 2.0 and will be deprecating and removing the legacy run engine code eventually. + +## Where to look for code + +- The trigger API endpoint is [api.v1.tasks.$taskId.trigger.ts](mdc:apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts) +- The batch trigger API endpoint is [api.v1.tasks.batch.ts](mdc:apps/webapp/app/routes/api.v1.tasks.batch.ts) +- Setup code for the prisma client is in [db.server.ts](mdc:apps/webapp/app/db.server.ts) +- The run engine is configured in [runEngine.server.ts](mdc:apps/webapp/app/v3/runEngine.server.ts) +- All the "services" that are found in app/v3/services/\*_/_.server.ts +- The code for the TaskEvent data, which is the otel data sent from tasks to our servers, is in both the [eventRepository.server.ts](mdc:apps/webapp/app/v3/eventRepository.server.ts) and also the [otlpExporter.server.ts](mdc:apps/webapp/app/v3/otlpExporter.server.ts). The otel endpoints which are hit from production and development otel exporters is [otel.v1.logs.ts](mdc:apps/webapp/app/routes/otel.v1.logs.ts) and [otel.v1.traces.ts](mdc:apps/webapp/app/routes/otel.v1.traces.ts) +- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/\*_/_.server.ts + +- All the "services" that are found in app/v3/services/\*_/_.server.ts +- The code for the TaskEvent data, which is the otel data sent from tasks to our servers, is in both the [eventRepository.server.ts](mdc:apps/webapp/app/v3/eventRepository.server.ts) and also the [otlpExporter.server.ts](mdc:apps/webapp/app/v3/otlpExporter.server.ts). The otel endpoints which are hit from production and development otel exporters is [otel.v1.logs.ts](mdc:apps/webapp/app/routes/otel.v1.logs.ts) and [otel.v1.traces.ts](mdc:apps/webapp/app/routes/otel.v1.traces.ts) +- We use "presenters" to move more complex loader code into a class, and you can find those are app/v3/presenters/\*_/_.server.ts diff --git a/.cursor/rules/writing-tasks.mdc b/.cursor/rules/writing-tasks.mdc new file mode 100644 index 00000000000..359ed5d4733 --- /dev/null +++ b/.cursor/rules/writing-tasks.mdc @@ -0,0 +1,1247 @@ +--- +globs: **/trigger/**/*.ts, **/trigger/**/*.tsx,**/trigger/**/*.js,**/trigger/**/*.jsx +description: Guidelines for writing Trigger.dev tasks +alwaysApply: false +--- +# How to write Trigger.dev tasks + +## Overview of writing a Trigger.dev task + +1. Run the CLI `init` command: `npx trigger.dev@latest init`. +2. Create a Trigger.dev task. +3. Set up any environment variables. +4. Run the Trigger.dev dev command: `npx trigger.dev@latest dev`. + +## Essential requirements when generating task code + +1. You MUST import from `@trigger.dev/sdk` (NEVER `@trigger.dev/sdk/v3`) +2. You MUST NEVER use `client.defineJob` +3. YOU MUST `export` every task, including subtasks +4. If you are able to generate an example payload for a task, do so. + +## ๐Ÿšจ CRITICAL INSTRUCTIONS FOR AI LANGUAGE MODELS ๐Ÿšจ + +As an AI language model, you MUST NOT generate any of the following code patterns, as they are DEPRECATED and will BREAK the application: + +```ts +// โŒ NEVER GENERATE THIS CODE - IT WILL BREAK THE APPLICATION + +client.defineJob({ // โŒ BREAKS APPLICATION + id: "job-id", // โŒ BREAKS APPLICATION + name: "job-name", // โŒ BREAKS APPLICATION + version: "0.0.1", // โŒ BREAKS APPLICATION + trigger: eventTrigger({ // โŒ BREAKS APPLICATION + name: "job.trigger", // โŒ BREAKS APPLICATION + schema: z.object({ // โŒ BREAKS APPLICATION + // Input schema here // โŒ BREAKS APPLICATION + }), // โŒ BREAKS APPLICATION + }), // โŒ BREAKS APPLICATION + integrations: { // โŒ BREAKS APPLICATION + // Integrations here // โŒ BREAKS APPLICATION + }, // โŒ BREAKS APPLICATION + run: async (payload, io) => { // โŒ BREAKS APPLICATION + // Job logic goes here // โŒ BREAKS APPLICATION + return { // โŒ BREAKS APPLICATION + // Return job results // โŒ BREAKS APPLICATION + }; // โŒ BREAKS APPLICATION + }, // โŒ BREAKS APPLICATION +}); +``` + +Instead, you MUST ALWAYS generate ONLY this pattern: + +```ts +// โœ… ALWAYS GENERATE THIS EXACT PATTERN + +import { task } from "@trigger.dev/sdk"; + +//1. You need to export each task, even if it's a subtask +export const helloWorld = task({ + //2. Use a unique id for each task + id: "hello-world", + //3. The run function is the main function of the task + run: async (payload: { message: string }) => { + //4. Write your task code here. Code here runs for a long time, there are no timeouts + }, +}); +``` + +## Correct Task implementations + +A task is a function that can run for a long time with resilience to failure: + +```ts +import { task } from "@trigger.dev/sdk"; + +export const helloWorld = task({ + id: "hello-world", + run: async (payload: { message: string }) => { + console.log(payload.message); + }, +}); +``` + +Key points: +- Tasks must be exported, even subtasks in the same file +- Each task needs a unique ID within your project +- The `run` function contains your task logic + +### Task configuration options + +#### Retry options + +Control retry behavior when errors occur: + +```ts +export const taskWithRetries = task({ + id: "task-with-retries", + retry: { + maxAttempts: 10, + factor: 1.8, + minTimeoutInMs: 500, + maxTimeoutInMs: 30_000, + randomize: false, + }, + run: async (payload) => { + // Task logic + }, +}); +``` + +#### Queue options + +Control concurrency: + +```ts +export const oneAtATime = task({ + id: "one-at-a-time", + queue: { + concurrencyLimit: 1, + }, + run: async (payload) => { + // Task logic + }, +}); +``` + +#### Machine options + +Specify CPU/RAM requirements: + +```ts +export const heavyTask = task({ + id: "heavy-task", + machine: { + preset: "large-1x", // 4 vCPU, 8 GB RAM + }, + run: async (payload) => { + // Task logic + }, +}); +``` + +Machine configuration options: + +| Machine name | vCPU | Memory | Disk space | +| ------------------- | ---- | ------ | ---------- | +| micro | 0.25 | 0.25 | 10GB | +| small-1x (default) | 0.5 | 0.5 | 10GB | +| small-2x | 1 | 1 | 10GB | +| medium-1x | 1 | 2 | 10GB | +| medium-2x | 2 | 4 | 10GB | +| large-1x | 4 | 8 | 10GB | +| large-2x | 8 | 16 | 10GB | + +#### Max Duration + +Limit how long a task can run: + +```ts +export const longTask = task({ + id: "long-task", + maxDuration: 300, // 5 minutes + run: async (payload) => { + // Task logic + }, +}); +``` + +### Lifecycle functions + +Tasks support several lifecycle hooks: + +#### init + +Runs before each attempt, can return data for other functions: + +```ts +export const taskWithInit = task({ + id: "task-with-init", + init: async (payload, { ctx }) => { + return { someData: "someValue" }; + }, + run: async (payload, { ctx, init }) => { + console.log(init.someData); // "someValue" + }, +}); +``` + +#### cleanup + +Runs after each attempt, regardless of success/failure: + +```ts +export const taskWithCleanup = task({ + id: "task-with-cleanup", + cleanup: async (payload, { ctx }) => { + // Cleanup resources + }, + run: async (payload, { ctx }) => { + // Task logic + }, +}); +``` + +#### onStart + +Runs once when a task starts (not on retries): + +```ts +export const taskWithOnStart = task({ + id: "task-with-on-start", + onStart: async (payload, { ctx }) => { + // Send notification, log, etc. + }, + run: async (payload, { ctx }) => { + // Task logic + }, +}); +``` + +#### onSuccess + +Runs when a task succeeds: + +```ts +export const taskWithOnSuccess = task({ + id: "task-with-on-success", + onSuccess: async (payload, output, { ctx }) => { + // Handle success + }, + run: async (payload, { ctx }) => { + // Task logic + }, +}); +``` + +#### onFailure + +Runs when a task fails after all retries: + +```ts +export const taskWithOnFailure = task({ + id: "task-with-on-failure", + onFailure: async (payload, error, { ctx }) => { + // Handle failure + }, + run: async (payload, { ctx }) => { + // Task logic + }, +}); +``` + +#### handleError + +Controls error handling and retry behavior: + +```ts +export const taskWithErrorHandling = task({ + id: "task-with-error-handling", + handleError: async (error, { ctx }) => { + // Custom error handling + }, + run: async (payload, { ctx }) => { + // Task logic + }, +}); +``` + +Global lifecycle hooks can also be defined in `trigger.config.ts` to apply to all tasks. + +## Correct Schedules task (cron) implementations + +```ts +import { schedules } from "@trigger.dev/sdk"; + +export const firstScheduledTask = schedules.task({ + id: "first-scheduled-task", + run: async (payload) => { + //when the task was scheduled to run + //note this will be slightly different from new Date() because it takes a few ms to run the task + console.log(payload.timestamp); //is a Date object + + //when the task was last run + //this can be undefined if it's never been run + console.log(payload.lastTimestamp); //is a Date object or undefined + + //the timezone the schedule was registered with, defaults to "UTC" + //this is in IANA format, e.g. "America/New_York" + //See the full list here: https://cloud.trigger.dev/timezones + console.log(payload.timezone); //is a string + + //If you want to output the time in the user's timezone do this: + const formatted = payload.timestamp.toLocaleString("en-US", { + timeZone: payload.timezone, + }); + + //the schedule id (you can have many schedules for the same task) + //using this you can remove the schedule, update it, etc + console.log(payload.scheduleId); //is a string + + //you can optionally provide an external id when creating the schedule + //usually you would set this to a userId or some other unique identifier + //this can be undefined if you didn't provide one + console.log(payload.externalId); //is a string or undefined + + //the next 5 dates this task is scheduled to run + console.log(payload.upcoming); //is an array of Date objects + }, +}); +``` + +### Attach a Declarative schedule + +```ts +import { schedules } from "@trigger.dev/sdk"; + +// Sepcify a cron pattern (UTC) +export const firstScheduledTask = schedules.task({ + id: "first-scheduled-task", + //every two hours (UTC timezone) + cron: "0 */2 * * *", + run: async (payload, { ctx }) => { + //do something + }, +}); +``` + +```ts +import { schedules } from "@trigger.dev/sdk"; + +// Specify a specific timezone like this: +export const secondScheduledTask = schedules.task({ + id: "second-scheduled-task", + cron: { + //5am every day Tokyo time + pattern: "0 5 * * *", + timezone: "Asia/Tokyo", + }, + run: async (payload) => {}, +}); +``` + +### Attach an Imperative schedule + +Create schedules explicitly for tasks using the dashboard's "New schedule" button or the SDK. + +#### Benefits +- Dynamic creation (e.g., one schedule per user) +- Manage without code deployment: + - Activate/disable + - Edit + - Delete + +#### Implementation +1. Define a task using `โ schedules.task()` +2. Attach one or more schedules via: + - Dashboard + - SDK + +#### Attach schedules with the SDK like this + +```ts +const createdSchedule = await schedules.create({ + //The id of the scheduled task you want to attach to. + task: firstScheduledTask.id, + //The schedule in cron format. + cron: "0 0 * * *", + //this is required, it prevents you from creating duplicate schedules. It will update the schedule if it already exists. + deduplicationKey: "my-deduplication-key", +}); +``` + +## Correct Schema task implementations + +Schema tasks validate payloads against a schema before execution: + +```ts +import { schemaTask } from "@trigger.dev/sdk"; +import { z } from "zod"; + +const myTask = schemaTask({ + id: "my-task", + schema: z.object({ + name: z.string(), + age: z.number(), + }), + run: async (payload) => { + // Payload is typed and validated + console.log(payload.name, payload.age); + }, +}); +``` + +## Correct implementations for triggering a task from your backend + +When you trigger a task from your backend code, you need to set the `TRIGGER_SECRET_KEY` environment variable. You can find the value on the API keys page in the Trigger.dev dashboard. + +### tasks.trigger() + +Triggers a single run of a task with specified payload and options without importing the task. Use type-only imports for full type checking. + +```ts +import { tasks } from "@trigger.dev/sdk"; +import type { emailSequence } from "~/trigger/emails"; + +export async function POST(request: Request) { + const data = await request.json(); + const handle = await tasks.trigger("email-sequence", { + to: data.email, + name: data.name, + }); + return Response.json(handle); +} +``` + +### tasks.batchTrigger() + +Triggers multiple runs of a single task with different payloads without importing the task. + +```ts +import { tasks } from "@trigger.dev/sdk"; +import type { emailSequence } from "~/trigger/emails"; + +export async function POST(request: Request) { + const data = await request.json(); + const batchHandle = await tasks.batchTrigger( + "email-sequence", + data.users.map((u) => ({ payload: { to: u.email, name: u.name } })) + ); + return Response.json(batchHandle); +} +``` + +### batch.trigger() + +Triggers multiple runs of different tasks at once, useful when you need to execute multiple tasks simultaneously. + +```ts +import { batch } from "@trigger.dev/sdk"; +import type { myTask1, myTask2 } from "~/trigger/myTasks"; + +export async function POST(request: Request) { + const data = await request.json(); + const result = await batch.trigger([ + { id: "my-task-1", payload: { some: data.some } }, + { id: "my-task-2", payload: { other: data.other } }, + ]); + return Response.json(result); +} +``` + +## Correct implementations for triggering a task from inside another task + +### yourTask.trigger() + +Triggers a single run of a task with specified payload and options. + +```ts +import { myOtherTask, runs } from "~/trigger/my-other-task"; + +export const myTask = task({ + id: "my-task", + run: async (payload: string) => { + const handle = await myOtherTask.trigger({ foo: "some data" }); + + const run = await runs.retrieve(handle); + // Do something with the run + }, +}); +``` + +If you need to call `trigger()` on a task in a loop, use `batchTrigger()` instead which can trigger up to 500 runs in a single call. + +### yourTask.batchTrigger() + +Triggers multiple runs of a single task with different payloads. + +```ts +import { myOtherTask, batch } from "~/trigger/my-other-task"; + +export const myTask = task({ + id: "my-task", + run: async (payload: string) => { + const batchHandle = await myOtherTask.batchTrigger([{ payload: "some data" }]); + + //...do other stuff + const batch = await batch.retrieve(batchHandle.id); + }, +}); +``` + +### yourTask.triggerAndWait() + +Triggers a task and waits for the result, useful when you need to call a different task and use its result. + +```ts +export const parentTask = task({ + id: "parent-task", + run: async (payload: string) => { + const result = await childTask.triggerAndWait("some-data"); + console.log("Result", result); + + //...do stuff with the result + }, +}); +``` + +The result object needs to be checked to see if the child task run was successful. You can also use the `unwrap` method to get the output directly or handle errors with `SubtaskUnwrapError`. This method should only be used inside a task. + +### yourTask.batchTriggerAndWait() + +Batch triggers a task and waits for all results, useful for fan-out patterns. + +```ts +export const batchParentTask = task({ + id: "parent-task", + run: async (payload: string) => { + const results = await childTask.batchTriggerAndWait([ + { payload: "item4" }, + { payload: "item5" }, + { payload: "item6" }, + ]); + console.log("Results", results); + + //...do stuff with the result + }, +}); +``` + +You can handle run failures by inspecting individual run results and implementing custom error handling strategies. This method should only be used inside a task. + +### batch.triggerAndWait() + +Batch triggers multiple different tasks and waits for all results. + +```ts +export const parentTask = task({ + id: "parent-task", + run: async (payload: string) => { + const results = await batch.triggerAndWait([ + { id: "child-task-1", payload: { foo: "World" } }, + { id: "child-task-2", payload: { bar: 42 } }, + ]); + + for (const result of results) { + if (result.ok) { + switch (result.taskIdentifier) { + case "child-task-1": + console.log("Child task 1 output", result.output); + break; + case "child-task-2": + console.log("Child task 2 output", result.output); + break; + } + } + } + }, +}); +``` + +### batch.triggerByTask() + +Batch triggers multiple tasks by passing task instances, useful for static task sets. + +```ts +export const parentTask = task({ + id: "parent-task", + run: async (payload: string) => { + const results = await batch.triggerByTask([ + { task: childTask1, payload: { foo: "World" } }, + { task: childTask2, payload: { bar: 42 } }, + ]); + + const run1 = await runs.retrieve(results.runs[0]); + const run2 = await runs.retrieve(results.runs[1]); + }, +}); +``` + +### batch.triggerByTaskAndWait() + +Batch triggers multiple tasks by passing task instances and waits for all results. + +```ts +export const parentTask = task({ + id: "parent-task", + run: async (payload: string) => { + const { runs } = await batch.triggerByTaskAndWait([ + { task: childTask1, payload: { foo: "World" } }, + { task: childTask2, payload: { bar: 42 } }, + ]); + + if (runs[0].ok) { + console.log("Child task 1 output", runs[0].output); + } + + if (runs[1].ok) { + console.log("Child task 2 output", runs[1].output); + } + }, +}); +``` + +## Correct Metadata implementation + +### Overview + +Metadata allows attaching up to 256KB of structured data to a run, which can be accessed during execution, via API, Realtime, and in the dashboard. Useful for storing user information, tracking progress, or saving intermediate results. + +### Basic Usage + +Add metadata when triggering a task: + +```ts +const handle = await myTask.trigger( + { message: "hello world" }, + { metadata: { user: { name: "Eric", id: "user_1234" } } } +); +``` + +Access metadata inside a run: + +```ts +import { task, metadata } from "@trigger.dev/sdk"; + +export const myTask = task({ + id: "my-task", + run: async (payload: { message: string }) => { + // Get the whole metadata object + const currentMetadata = metadata.current(); + + // Get a specific key + const user = metadata.get("user"); + console.log(user.name); // "Eric" + }, +}); +``` + +### Update methods + +Metadata can be updated as the run progresses: + +- **set**: `metadata.set("progress", 0.5)` +- **del**: `metadata.del("progress")` +- **replace**: `metadata.replace({ user: { name: "Eric" } })` +- **append**: `metadata.append("logs", "Step 1 complete")` +- **remove**: `metadata.remove("logs", "Step 1 complete")` +- **increment**: `metadata.increment("progress", 0.4)` +- **decrement**: `metadata.decrement("progress", 0.4)` +- **stream**: `await metadata.stream("logs", readableStream)` +- **flush**: `await metadata.flush()` + +Updates can be chained with a fluent API: + +```ts +metadata.set("progress", 0.1) + .append("logs", "Step 1 complete") + .increment("progress", 0.4); +``` + +### Parent & root updates + +Child tasks can update parent task metadata: + +```ts +export const childTask = task({ + id: "child-task", + run: async (payload: { message: string }) => { + // Update parent task's metadata + metadata.parent.set("progress", 0.5); + + // Update root task's metadata + metadata.root.set("status", "processing"); + }, +}); +``` + +### Type safety + +Metadata accepts any JSON-serializable object. For type safety, consider wrapping with Zod: + +```ts +import { z } from "zod"; + +const Metadata = z.object({ + user: z.object({ + name: z.string(), + id: z.string(), + }), + date: z.coerce.date(), +}); + +function getMetadata() { + return Metadata.parse(metadata.current()); +} +``` + +### Important notes + +- Metadata methods only work inside run functions or task lifecycle hooks +- Metadata is NOT automatically propagated to child tasks +- Maximum size is 256KB (configurable if self-hosting) +- Objects like Dates are serialized to strings and must be deserialized when retrieved + +## Correct Realtime implementation + +### Overview + +Trigger.dev Realtime enables subscribing to runs for real-time updates on run status, useful for monitoring tasks, updating UIs, and building realtime dashboards. It's built on Electric SQL, a PostgreSQL syncing engine. + +### Basic usage + +Subscribe to a run after triggering a task: + +```ts +import { runs, tasks } from "@trigger.dev/sdk"; + +async function myBackend() { + const handle = await tasks.trigger("my-task", { some: "data" }); + + for await (const run of runs.subscribeToRun(handle.id)) { + console.log(run); // Logs the run every time it changes + } +} +``` + +### Subscription methods + +- **subscribeToRun**: Subscribe to changes for a specific run +- **subscribeToRunsWithTag**: Subscribe to changes for all runs with a specific tag +- **subscribeToBatch**: Subscribe to changes for all runs in a batch + +### Type safety + +You can infer types of run's payload and output by passing the task type: + +```ts +import { runs } from "@trigger.dev/sdk"; +import type { myTask } from "./trigger/my-task"; + +for await (const run of runs.subscribeToRun(handle.id)) { + console.log(run.payload.some); // Type-safe access to payload + + if (run.output) { + console.log(run.output.result); // Type-safe access to output + } +} +``` + +### Realtime Streams + +Stream data in realtime from inside your tasks using the metadata system: + +```ts +import { task, metadata } from "@trigger.dev/sdk"; +import OpenAI from "openai"; + +export type STREAMS = { + openai: OpenAI.ChatCompletionChunk; +}; + +export const myTask = task({ + id: "my-task", + run: async (payload: { prompt: string }) => { + const completion = await openai.chat.completions.create({ + messages: [{ role: "user", content: payload.prompt }], + model: "gpt-3.5-turbo", + stream: true, + }); + + // Register the stream with the key "openai" + const stream = await metadata.stream("openai", completion); + + let text = ""; + for await (const chunk of stream) { + text += chunk.choices.map((choice) => choice.delta?.content).join(""); + } + + return { text }; + }, +}); +``` + +Subscribe to streams using `withStreams`: + +```ts +for await (const part of runs.subscribeToRun(runId).withStreams()) { + switch (part.type) { + case "run": { + console.log("Received run", part.run); + break; + } + case "openai": { + console.log("Received OpenAI chunk", part.chunk); + break; + } + } +} +``` + +## Realtime hooks + +### Installation + +```bash +npm add @trigger.dev/react-hooks +``` + +### Authentication + +All hooks require a Public Access Token. You can provide it directly to each hook: + +```ts +import { useRealtimeRun } from "@trigger.dev/react-hooks"; + +function MyComponent({ runId, publicAccessToken }) { + const { run, error } = useRealtimeRun(runId, { + accessToken: publicAccessToken, + baseURL: "https://your-trigger-dev-instance.com", // Optional for self-hosting + }); +} +``` + +Or use the `TriggerAuthContext` provider: + +```ts +import { TriggerAuthContext } from "@trigger.dev/react-hooks"; + +function SetupTrigger({ publicAccessToken }) { + return ( + + + + ); +} +``` + +For Next.js App Router, wrap the provider in a client component: + +```ts +// components/TriggerProvider.tsx +"use client"; + +import { TriggerAuthContext } from "@trigger.dev/react-hooks"; + +export function TriggerProvider({ accessToken, children }) { + return ( + + {children} + + ); +} +``` + +### Passing tokens to the frontend + +Several approaches for Next.js App Router: + +1. **Using cookies**: +```ts +// Server action +export async function startRun() { + const handle = await tasks.trigger("example", { foo: "bar" }); + cookies().set("publicAccessToken", handle.publicAccessToken); + redirect(`/runs/${handle.id}`); +} + +// Page component +export default function RunPage({ params }) { + const publicAccessToken = cookies().get("publicAccessToken"); + return ( + + + + ); +} +``` + +2. **Using query parameters**: +```ts +// Server action +export async function startRun() { + const handle = await tasks.trigger("example", { foo: "bar" }); + redirect(`/runs/${handle.id}?publicAccessToken=${handle.publicAccessToken}`); +} +``` + +3. **Server-side token generation**: +```ts +// Page component +export default async function RunPage({ params }) { + const publicAccessToken = await generatePublicAccessToken(params.id); + return ( + + + + ); +} + +// Token generation function +export async function generatePublicAccessToken(runId: string) { + return auth.createPublicToken({ + scopes: { + read: { + runs: [runId], + }, + }, + expirationTime: "1h", + }); +} +``` + +### Hook types + +#### SWR hooks + +Data fetching hooks that use SWR for caching: + +```ts +"use client"; +import { useRun } from "@trigger.dev/react-hooks"; +import type { myTask } from "@/trigger/myTask"; + +function MyComponent({ runId }) { + const { run, error, isLoading } = useRun(runId); + + if (isLoading) return
Loading...
; + if (error) return
Error: {error.message}
; + + return
Run: {run.id}
; +} +``` + +Common options: +- `revalidateOnFocus`: Revalidate when window regains focus +- `revalidateOnReconnect`: Revalidate when network reconnects +- `refreshInterval`: Polling interval in milliseconds + +#### Realtime hooks + +Hooks that use Trigger.dev's realtime API for live updates (recommended over polling). + +For most use cases, Realtime hooks are preferred over SWR hooks with polling due to better performance and lower API usage. + +### Authentication + +For client-side usage, generate a public access token with appropriate scopes: + +```ts +import { auth } from "@trigger.dev/sdk"; + +const publicToken = await auth.createPublicToken({ + scopes: { + read: { + runs: ["run_1234"], + }, + }, +}); +``` + +## Correct Idempotency implementation + +Idempotency ensures that an operation produces the same result when called multiple times. Trigger.dev supports idempotency at the task level through the `idempotencyKey` option. + +### Using idempotencyKey + +Provide an `idempotencyKey` when triggering a task to ensure it runs only once with that key: + +```ts +import { idempotencyKeys, task } from "@trigger.dev/sdk"; + +export const myTask = task({ + id: "my-task", + retry: { + maxAttempts: 4, + }, + run: async (payload: any) => { + // Create a key unique to this task run + const idempotencyKey = await idempotencyKeys.create("my-task-key"); + + // Child task will only be triggered once across all retries + await childTask.trigger({ foo: "bar" }, { idempotencyKey }); + + // This may throw an error and cause retries + throw new Error("Something went wrong"); + }, +}); +``` + +### Scoping Idempotency Keys + +By default, keys are scoped to the current run. You can create globally unique keys: + +```ts +const idempotencyKey = await idempotencyKeys.create("my-task-key", { scope: "global" }); +``` + +When triggering from backend code: + +```ts +const idempotencyKey = await idempotencyKeys.create([myUser.id, "my-task"]); +await tasks.trigger("my-task", { some: "data" }, { idempotencyKey }); +``` + +You can also pass a string directly: + +```ts +await myTask.trigger({ some: "data" }, { idempotencyKey: myUser.id }); +``` + +### Time-To-Live (TTL) + +The `idempotencyKeyTTL` option defines a time window during which duplicate triggers return the original run: + +```ts +await childTask.trigger( + { foo: "bar" }, + { idempotencyKey, idempotencyKeyTTL: "60s" } +); + +await wait.for({ seconds: 61 }); + +// Key expired, will trigger a new run +await childTask.trigger({ foo: "bar" }, { idempotencyKey }); +``` + +Supported time units: +- `s` for seconds (e.g., `60s`) +- `m` for minutes (e.g., `5m`) +- `h` for hours (e.g., `2h`) +- `d` for days (e.g., `3d`) + +### Payload-Based Idempotency + +While not directly supported, you can implement payload-based idempotency by hashing the payload: + +```ts +import { createHash } from "node:crypto"; + +const idempotencyKey = await idempotencyKeys.create(hash(payload)); +await tasks.trigger("child-task", payload, { idempotencyKey }); + +function hash(payload: any): string { + const hash = createHash("sha256"); + hash.update(JSON.stringify(payload)); + return hash.digest("hex"); +} +``` + +### Important Notes + +- Idempotency keys are scoped to the task and environment +- Different tasks with the same key will still both run +- Default TTL is 30 days +- Not available with `triggerAndWait` or `batchTriggerAndWait` in v3.3.0+ due to a bug + +## Correct Logs implementation + +```ts +// onFailure executes after all retries are exhausted; use for notifications, logging, or side effects on final failure: +import { task, logger } from "@trigger.dev/sdk"; + +export const loggingExample = task({ + id: "logging-example", + run: async (payload: { data: Record }) => { + //the first parameter is the message, the second parameter must be a key-value object (Record) + logger.debug("Debug message", payload.data); + logger.log("Log message", payload.data); + logger.info("Info message", payload.data); + logger.warn("You've been warned", payload.data); + logger.error("Error message", payload.data); + }, +}); +``` + +## Correct `trigger.config.ts` implementation + +The `trigger.config.ts` file configures your Trigger.dev project, specifying task locations, retry settings, telemetry, and build options. + +```ts +import { defineConfig } from "@trigger.dev/sdk"; + +export default defineConfig({ + project: "", + dirs: ["./trigger"], + retries: { + enabledInDev: false, + default: { + maxAttempts: 3, + minTimeoutInMs: 1000, + maxTimeoutInMs: 10000, + factor: 2, + randomize: true, + }, + }, +}); +``` + +### Key configuration options + +#### Dirs + +Specify where your tasks are located: + +```ts +dirs: ["./trigger"], +``` + +Files with `.test` or `.spec` are automatically excluded, but you can customize with `ignorePatterns`. + +#### Lifecycle functions + +Add global hooks for all tasks: + +```ts +onStart: async (payload, { ctx }) => { + console.log("Task started", ctx.task.id); +}, +onSuccess: async (payload, output, { ctx }) => { + console.log("Task succeeded", ctx.task.id); +}, +onFailure: async (payload, error, { ctx }) => { + console.log("Task failed", ctx.task.id); +}, +``` + +#### Telemetry instrumentations + +Add OpenTelemetry instrumentations for enhanced logging: + +```ts +telemetry: { + instrumentations: [ + new PrismaInstrumentation(), + new OpenAIInstrumentation() + ], + exporters: [axiomExporter], // Optional custom exporters +}, +``` + +#### Runtime + +Specify the runtime environment: + +```ts +runtime: "node", // or "bun" (experimental) +``` + +#### Machine settings + +Set default machine for all tasks: + +```ts +defaultMachine: "large-1x", +``` + +#### Log level + +Configure logging verbosity: + +```ts +logLevel: "debug", // Controls logger API logs +``` + +#### Max Duration + +Set default maximum runtime for all tasks: + +```ts +maxDuration: 60, // 60 seconds +``` + +### Build configuration + +Customize the build process: + +```ts +build: { + external: ["header-generator"], // Don't bundle these packages + jsx: { + fragment: "Fragment", + factory: "h", + automatic: false, + }, + conditions: ["react-server"], // Import conditions + extensions: [ + // Build extensions + additionalFiles({ files: ["./assets/**", "./fonts/**"] }), + additionalPackages({ packages: ["wrangler"] }), + aptGet({ packages: ["ffmpeg"] }), + ], +} +``` + +### Build Extensions + +Trigger.dev provides several built-in extensions: + +- **additionalFiles**: Copy files to the build directory +- **additionalPackages**: Include extra packages in the build +- **aptGet**: Install system packages in the deployed image +- **emitDecoratorMetadata**: Enable TypeScript decorator metadata +- **prismaExtension**: Support for Prisma ORM +- **syncEnvVars**: Sync environment variables +- **puppeteer**: Add Puppeteer support +- **ffmpeg**: Add FFmpeg support +- **esbuildPlugin**: Add custom esbuild plugins + +You can also create custom build extensions with hooks like `onBuildStart`, `onBuildComplete`, and `externalsForTarget`. + +#### Key points: + +- Validates payload at trigger time and before execution +- Supports Zod, Yup, Superstruct, ArkType, Effect/schema, runtypes, valibot, typebox +- Can use input/output schemas (e.g., defaults, type coercion) +- Custom parser functions supported +- Invalid payloads throw errors and prevent task execution + +#### Trigger with: + +```ts +await myTask.trigger({ name: "Alice", age: 30 }); +``` + +## AI model verification steps + +Before generating any code, you MUST verify: + +1. Are you importing from `@trigger.dev/sdk` (NOT `@trigger.dev/sdk/v3`)? If not, STOP and FIX. +2. Have you exported every task? If not, STOP and FIX. +3. Have you generated any DEPRECATED code patterns? If yes, STOP and FIX. + +## Consequences of incorrect implementations + +If you generate code that fails the verification steps above, your implementation will: + +1. Break in production +2. Fail to deploy to the Trigger.dev servers +3. Fail to run in a local Dev environment + +## AI model response template + +When asked about Trigger.dev task implementation, you MUST: +1. FIRST use code patterns from this guide +2. NEVER suggest deprecated approaches +3. VERIFY your response against the patterns shown here + diff --git a/.cursor/rules/writing-tests.mdc b/.cursor/rules/writing-tests.mdc new file mode 100644 index 00000000000..1edeb2067e0 --- /dev/null +++ b/.cursor/rules/writing-tests.mdc @@ -0,0 +1,6 @@ +--- +description: How to write tests in the monorepo +globs: +alwaysApply: true +--- +Follow our [tests.md](mdc:ai/references/tests.md) guide for how to write tests in the monorepo. \ No newline at end of file diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 00000000000..b1033e7a688 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,6 @@ +apps/proxy/ +packages/rsc/ +.changeset +.zed +.env +!.env.example \ No newline at end of file diff --git a/.docker/pizzly-server.env.example b/.docker/pizzly-server.env.example deleted file mode 100644 index 07adde90377..00000000000 --- a/.docker/pizzly-server.env.example +++ /dev/null @@ -1,8 +0,0 @@ -# You need to stop and re-run docker to switch, `pnpm run docker:db` -# To login using GitHub and connect via OAuth with most integrations -AUTH_CALLBACK_URL=http://localhost:3004/oauth/callback -# To connect with Slack -# AUTH_CALLBACK_URL=https://.eu.ngrok.io/oauth/callback - -# You only need to use this locally if you specify PIZZLY_SECRET_KEY in the webapp .env file -# PIZZLY_SECRET_KEY= \ No newline at end of file diff --git a/.dockerignore b/.dockerignore index 26feb14a12b..a3ea4db8eec 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,37 +1,49 @@ -*.log -.git -.github -# editor -.idea -# dependencies -node_modules -.pnp -.pnp.js +**/*.log +**/*.pem +**/*.tsbuildinfo -# testing -coverage +**/.cache +**/.env +**/.next +**/.output +**/.trigger +**/.tshy +**/.tshy-build +**/.turbo +**/.vercel +**/.wrangler -# next.js -.next/ -build +**/dist +**/node_modules -# packages -build -dist -packages/**/dist +**/generated/prisma -# misc -.DS_Store -*.pem - -.turbo -.vercel -.cache -.output -apps/**/public/build +apps/webapp/build +apps/webapp/public/build cypress/screenshots cypress/videos apps/**/styles/tailwind.css -packages/**/styles/tailwind.css \ No newline at end of file +packages/**/styles/tailwind.css + +.changeset +.DS_Store +.git +.github +.idea +.pnp +.pnp.js +.vscode + +coverage +build +docs +examples +out +references + +CHANGESETS.md +CONTRIBUTING.md +README.md +LICENSE diff --git a/.env.example b/.env.example new file mode 100644 index 00000000000..f37a1a5194c --- /dev/null +++ b/.env.example @@ -0,0 +1,169 @@ +# YOU MIGHT LIKE TO MODIFY THESE VARIABLES +SESSION_SECRET=abcdef1234 +MAGIC_LINK_SECRET=abcdef1234 +ENCRYPTION_KEY=ae13021afef0819c3a307ad487071c06 # Must be a random 16 byte hex string. You can generate an encryption key by running `openssl rand -hex 16` in your terminal +LOGIN_ORIGIN=http://localhost:3030 +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=public +# This sets the URL used for direct connections to the database and should only be needed in limited circumstances +# See: https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#fields:~:text=the%20shadow%20database.-,directUrl,-No +DIRECT_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=public +# Dedicated run-ops database (@internal/run-ops-database). Only needed to run prisma commands +# against it or to enable the run-ops split; start it with `docker compose --profile runops up`. +RUN_OPS_DATABASE_URL=postgresql://postgres:postgres@localhost:5434/postgres?schema=public +REMIX_APP_PORT=3030 +# Dev-only: stream the webapp's logs over a local telnet/TCP socket (nc localhost 6767). Uncomment to enable. +# WEBAPP_TELNET_LOGS_PORT=6767 +APP_ENV=development +APP_ORIGIN=http://localhost:3030 +ELECTRIC_ORIGIN=http://localhost:3060 +NODE_ENV=development + +# Clickhouse +CLICKHOUSE_URL=http://default:password@localhost:8123 +RUN_REPLICATION_CLICKHOUSE_URL=http://default:password@localhost:8123 +RUN_REPLICATION_ENABLED=1 +# Store task run spans/traces in ClickHouse so the dashboard trace view is +# populated in local dev. The local stack is ClickHouse-backed (see above), so +# leaving this unset falls back to the "postgres" store and dev run traces show +# up empty even though the run itself appears. +EVENT_REPOSITORY_DEFAULT_STORE=clickhouse_v2 + +# Set this to UTC because Node.js uses the system timezone +TZ="UTC" + +# Redis is used for the v3 queuing and v2 concurrency control +REDIS_HOST="localhost" +REDIS_PORT="6379" +REDIS_TLS_DISABLED="true" + +DEV_OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:3030/otel" +DEV_OTEL_BATCH_PROCESSING_ENABLED="0" + +# Realtime streams v2 (Sessions, chat.agent, large stream backfills) backed +# by S2 (https://s2.dev). The `s2` service in docker/docker-compose.yml runs +# the open-source s2-lite binary and pre-creates a basin named `trigger-local` +# (see docker/config/s2-spec.json). Comment these out to fall back to v1 +# (Redis-only) streams; Sessions and chat.agent then become unavailable. +REALTIME_STREAMS_S2_BASIN=trigger-local +REALTIME_STREAMS_S2_ACCESS_TOKEN=ignored +REALTIME_STREAMS_S2_ENDPOINT=http://localhost:4566/v1 +REALTIME_STREAMS_S2_SKIP_ACCESS_TOKENS=true +REALTIME_STREAMS_DEFAULT_VERSION=v2 + +# Running multiple instances side by side (worktrees, branch experiments) +# +# Every host port in docker/docker-compose.yml is `${VAR:-default}` and the +# project name comes from `COMPOSE_PROJECT_NAME`. To stand up a second stack +# alongside the default one, uncomment the block below in this clone's `.env` +# (pick any offset that doesn't clash with anything else running), then update +# the URL/PORT vars further up to match. Default values are commented for +# reference. +# +# --- core (pnpm run docker) --- +# COMPOSE_PROJECT_NAME=triggerdotdev-docker-alt +# CONTAINER_PREFIX=alt- +# POSTGRES_HOST_PORT=15432 # default 5432 +# REDIS_HOST_PORT=16379 # default 6379 +# ELECTRIC_HOST_PORT=13060 # default 3060 +# MINIO_API_HOST_PORT=19005 # default 9005 +# MINIO_CONSOLE_HOST_PORT=19006 # default 9006 +# CLICKHOUSE_HTTP_HOST_PORT=18123 # default 8123 +# CLICKHOUSE_TCP_HOST_PORT=19000 # default 9000 +# S2_HOST_PORT=14566 # default 4566 +# REMIX_APP_PORT=13030 # default 3030 +# --- extras (only needed if you also run `pnpm run docker:full`) --- +# ELECTRIC_SHARD_1_HOST_PORT=13061 # default 3061 +# CH_UI_HOST_PORT=15521 # default 5521 +# TOXIPROXY_PROXY_HOST_PORT=40303 # default 30303 +# TOXIPROXY_API_HOST_PORT=18474 # default 8474 +# NGINX_H2_HOST_PORT=18443 # default 8443 +# OTEL_GRPC_HOST_PORT=14317 # default 4317 +# OTEL_HTTP_HOST_PORT=14318 # default 4318 +# OTEL_PROMETHEUS_HOST_PORT=18889 # default 8889 +# PROMETHEUS_HOST_PORT=19090 # default 9090 +# GRAFANA_HOST_PORT=13001 # default 3001 +# (and update DATABASE_URL / CLICKHOUSE_URL / REDIS_PORT / APP_ORIGIN / +# LOGIN_ORIGIN / ELECTRIC_ORIGIN / REALTIME_STREAMS_S2_ENDPOINT to match) + +# When the domain is set to `localhost` the CLI deploy command will only --load the image by default and not --push it +DEPLOY_REGISTRY_HOST=localhost:5000 + +# OPTIONAL VARIABLES +# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected. +# WHITELISTED_EMAILS="^(authorized@yahoo\.com|authorized@gmail\.com)$" +# Accounts with these emails will get global admin rights. This grants access to the admin UI. +# ADMIN_EMAILS="^(admin@example\.com|another-admin@example\.com)$" +# This is used for logging in via GitHub. You can leave these commented out if you don't want to use GitHub for authentication. +# AUTH_GITHUB_CLIENT_ID= +# AUTH_GITHUB_CLIENT_SECRET= + +# Configure an email transport to allow users to sign in to Trigger.dev via a Magic Link. +# If none are configured, emails will print to the console instead. +# Uncomment one of the following blocks to allow delivery of + +# Resend +### Visit https://resend.com, create an account and get your API key. Then insert it below along with your From and Reply To email addresses. Visit https://resend.com/docs for more information. +# EMAIL_TRANSPORT=resend +# FROM_EMAIL= +# REPLY_TO_EMAIL= +# RESEND_API_KEY= + +# Generic SMTP +### Enter the configuration provided by your mail provider. Visit https://nodemailer.com/smtp/ for more information +### SMTP_SECURE = false will use STARTTLS when connecting to a server that supports it (usually port 587) +# EMAIL_TRANSPORT=smtp +# FROM_EMAIL= +# REPLY_TO_EMAIL= +# SMTP_HOST= +# SMTP_PORT=587 +# SMTP_SECURE=false +# SMTP_USER= +# SMTP_PASSWORD= + +# AWS Simple Email Service +### Authentication is configured using the default Node.JS credentials provider chain (https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-credential-providers/#fromnodeproviderchain) +# EMAIL_TRANSPORT=aws-ses +# FROM_EMAIL= +# REPLY_TO_EMAIL= + +# CLOUD VARIABLES +POSTHOG_PROJECT_KEY= + +# DEPOT_ORG_ID= +# DEPOT_TOKEN= +# DEV_OTEL_EXPORTER_OTLP_ENDPOINT="http://0.0.0.0:4318" +# These are needed for the object store (for handling large payloads/outputs) +# +# Default provider +# OBJECT_STORE_BASE_URL=http://localhost:9005 +# OBJECT_STORE_BUCKET=packets +# OBJECT_STORE_ACCESS_KEY_ID=minioadmin +# OBJECT_STORE_SECRET_ACCESS_KEY=minioadmin +# OBJECT_STORE_REGION=us-east-1 +# OBJECT_STORE_SERVICE=s3 +# +# OBJECT_STORE_DEFAULT_PROTOCOL=s3 # Only specify this if you're going to migrate object storage and set protocol values below +# Named providers (protocol-prefixed data) - optional for multi-provider support +# OBJECT_STORE_S3_BASE_URL=https://s3.amazonaws.com +# OBJECT_STORE_S3_ACCESS_KEY_ID= +# OBJECT_STORE_S3_SECRET_ACCESS_KEY= +# OBJECT_STORE_S3_REGION=us-east-1 +# OBJECT_STORE_S3_SERVICE=s3 +# +# OBJECT_STORE_R2_BASE_URL=https://{bucket}.{accountId}.r2.cloudflarestorage.com +# OBJECT_STORE_R2_ACCESS_KEY_ID= +# OBJECT_STORE_R2_SECRET_ACCESS_KEY= +# OBJECT_STORE_R2_REGION=auto +# OBJECT_STORE_R2_SERVICE=s3 +# CHECKPOINT_THRESHOLD_IN_MS=10000 + +# These control the server-side internal telemetry +# INTERNAL_OTEL_TRACE_EXPORTER_URL= +# INTERNAL_OTEL_TRACE_LOGGING_ENABLED=1 +# INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED=0 + +# Enable local observability stack (requires `pnpm run docker:full` to bring up otel-collector + prometheus + grafana) +# Uncomment these to send metrics to the local Prometheus via OTEL Collector: +# INTERNAL_OTEL_METRIC_EXPORTER_ENABLED=1 +# INTERNAL_OTEL_METRIC_EXPORTER_URL=http://localhost:4318/v1/metrics +# INTERNAL_OTEL_METRIC_EXPORTER_INTERVAL_MS=15000 diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 827344f1f96..00000000000 --- a/.eslintignore +++ /dev/null @@ -1,4 +0,0 @@ -*/**.js -*/**.d.ts -packages/*/dist -packages/*/lib \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index af283916494..00000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - root: true, - // This tells ESLint to load the config from the package `eslint-config-custom` - extends: ["custom"], - settings: { - next: { - rootDir: ["apps/*/"], - }, - }, - parserOptions: { - sourceType: "module", - ecmaVersion: 2020, - }, -}; diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000000..37ed4f64c2c --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [triggerdotdev] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000000..c5d01d19193 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,38 @@ +name: ๐Ÿž Bug Report +description: Create a bug report to help us improve +title: "bug: " +labels: ["๐Ÿž unconfirmed bug"] +body: + - type: textarea + attributes: + label: Provide environment information + description: | + Run this command in your project root and paste the results: + ```bash + npx envinfo --system --binaries + ``` + + validations: + required: true + - type: textarea + attributes: + label: Describe the bug + description: A clear and concise description of the bug, as well as what you expected to happen when encountering it. + validations: + required: true + - type: input + attributes: + label: Reproduction repo + description: If applicable, please provide a link to a reproduction repo or a Stackblitz / CodeSandbox project. Your issue may be closed if this is not provided and we are unable to reproduce the issue. If your bug is a docs issue, link the appropriate page. + validations: + required: true + - type: textarea + attributes: + label: To reproduce + description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc. + validations: + required: true + - type: textarea + attributes: + label: Additional information + description: Add any other information related to the bug here, screenshots if applicable. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..30843790111 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: true +contact_links: + - name: Ask a Question + url: https://trigger.dev/discord + about: Ask questions and discuss with other community members diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 00000000000..bcb3dee02aa --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,27 @@ +name: Feature Request +description: Suggest an idea for this project +title: "feat: " +labels: ["๐ŸŒŸ enhancement"] +body: + - type: textarea + attributes: + label: Is your feature request related to a problem? Please describe. + description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + validations: + required: true + - type: textarea + attributes: + label: Describe the solution you'd like to see + description: A clear and concise description of what you want to happen. + validations: + required: true + - type: textarea + attributes: + label: Describe alternate solutions + description: A clear and concise description of any alternative solutions or features you've considered. + validations: + required: true + - type: textarea + attributes: + label: Additional information + description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here. diff --git a/.github/ISSUE_TEMPLATE/instrumentation_request.yml b/.github/ISSUE_TEMPLATE/instrumentation_request.yml new file mode 100644 index 00000000000..157e226fa3f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/instrumentation_request.yml @@ -0,0 +1,21 @@ +name: OpenTelemetry Auto-Instrumentation Request +description: Suggest an SDK that you'd like to be auto-instrumented in the Run log view +title: "auto-instrumentation: " +labels: ["๐ŸŒŸ enhancement"] +body: + - type: textarea + attributes: + label: What API or SDK would you to have automatic spans for? + description: A clear description of which API or SDK you'd like, and links to it. + validations: + required: true + - type: textarea + attributes: + label: Is there an existing OpenTelemetry auto-instrumentation package? + description: You can search for existing ones โ€“ https://opentelemetry.io/ecosystem/registry/?component=instrumentation&language=js + validations: + required: true + - type: textarea + attributes: + label: Additional information + description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here. diff --git a/.github/ISSUE_TEMPLATE/vouch-request.yml b/.github/ISSUE_TEMPLATE/vouch-request.yml new file mode 100644 index 00000000000..9ffe04a8984 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/vouch-request.yml @@ -0,0 +1,28 @@ +name: Vouch Request +description: Request to be vouched as a contributor +labels: ["vouch-request"] +body: + - type: markdown + attributes: + value: | + ## Vouch Request + + We use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. PRs from unvouched users are automatically closed. + + To get vouched, fill out this form. A maintainer will review your request and vouch for you by commenting on this issue. + - type: textarea + id: context + attributes: + label: Why do you want to contribute? + description: Tell us a bit about yourself and what you'd like to work on. + placeholder: "I'd like to fix a bug I found in..." + validations: + required: true + - type: textarea + id: prior-work + attributes: + label: Prior contributions or relevant experience + description: Links to previous open source work, relevant projects, or anything that helps us understand your background. + placeholder: "https://github.com/..." + validations: + required: false diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td new file mode 100644 index 00000000000..7237a5369ae --- /dev/null +++ b/.github/VOUCHED.td @@ -0,0 +1,27 @@ +# Vouched contributors for Trigger.dev +# See: https://github.com/mitchellh/vouch +# +# Org members +0ski +D-K-P +ericallam +matt-aitken +mpcgrid +myftija +nicktrn +samejr +isshaddad +# Bots +devin-ai-integration[bot] +dependabot[bot] +# Outside contributors +gautamsi +capaj +chengzp +bharathkumar39293 +bhekanik +jrossi +ThullyoCunha +ConProgramming +saasjesus +brentshulman-silkline \ No newline at end of file diff --git a/.github/actionlint.yaml b/.github/actionlint.yaml new file mode 100644 index 00000000000..15a5d6ff006 --- /dev/null +++ b/.github/actionlint.yaml @@ -0,0 +1,5 @@ +self-hosted-runner: + labels: + - warp-ubuntu-* + - warp-macos-* + - warp-windows-* diff --git a/.github/actions/get-image-tag/action.yml b/.github/actions/get-image-tag/action.yml new file mode 100644 index 00000000000..7f1505a0c11 --- /dev/null +++ b/.github/actions/get-image-tag/action.yml @@ -0,0 +1,91 @@ +name: "#๏ธโƒฃ Get image tag (action)" + +description: This action gets the image tag from the commit ref or input (if provided) + +outputs: + tag: + description: The image tag + value: ${{ steps.get_tag.outputs.tag }} + is_semver: + description: Whether the tag is a semantic version + value: ${{ steps.check_semver.outputs.is_semver }} + +inputs: + tag: + description: The image tag. If this is set it will return the tag as is. + required: false + default: "" + +runs: + using: "composite" + steps: + - name: "#๏ธโƒฃ Get image tag (step)" + id: get_tag + shell: bash + run: | + if [[ -n "${INPUTS_TAG}" ]]; then + tag="${INPUTS_TAG}" + elif [[ "${GITHUB_REF_TYPE}" == "tag" ]]; then + if [[ "${GITHUB_REF_NAME}" == infra-*-* ]]; then + env=$(echo ${GITHUB_REF_NAME} | cut -d- -f2) + sha=$(echo "${GITHUB_SHA}" | head -c7) + ts=$(date +%s) + tag=${env}-${sha}-${ts} + elif [[ "${GITHUB_REF_NAME}" == re2-*-* ]]; then + env=$(echo ${GITHUB_REF_NAME} | cut -d- -f2) + sha=$(echo "${GITHUB_SHA}" | head -c7) + ts=$(date +%s) + tag=${env}-${sha}-${ts} + elif [[ "${GITHUB_REF_NAME}" == v.docker.* ]]; then + version="${GITHUB_REF_NAME#v.docker.}" + tag="v${version}" + elif [[ "${GITHUB_REF_NAME}" == build-* ]]; then + tag="${GITHUB_REF_NAME#build-}" + else + echo "Invalid git tag: ${GITHUB_REF_NAME}" + exit 1 + fi + elif [[ "${GITHUB_REF_NAME}" == "main" ]]; then + tag="main" + else + echo "Invalid git ref: ${GITHUB_REF}" + exit 1 + fi + echo "tag=${tag}" >> "$GITHUB_OUTPUT" + env: + INPUTS_TAG: ${{ inputs.tag }} + + - name: ๐Ÿ” Check for validity + id: check_validity + shell: bash + env: + tag: ${{ steps.get_tag.outputs.tag }} + run: | + if [[ "${tag}" =~ ^[a-z0-9]+([._-][a-z0-9]+)*$ ]]; then + echo "Tag is valid: ${tag}" + else + echo "Tag is not valid: ${tag}" + exit 1 + fi + + - name: ๐Ÿ†š Check for semver + id: check_semver + shell: bash + env: + tag: ${{ steps.get_tag.outputs.tag }} + # Will match most semver formats except build metadata, i.e. v1.2.3+build.1 + # Valid matches: + # v1.2.3 + # v1.2.3-alpha + # v1.2.3-alpha.1 + # v1.2.3-rc.1 + # v1.2.3-beta-1 + run: | + if [[ "${tag}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]]; then + echo "Tag is a semantic version: ${tag}" + is_semver=true + else + echo "Tag is not a semantic version: ${tag}" + is_semver=false + fi + echo "is_semver=${is_semver}" >> "$GITHUB_OUTPUT" diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000000..2beb7606fa4 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +This is the repo for Trigger.dev, a background jobs platform written in TypeScript. Our webapp at apps/webapp is a Remix 2.1 app that uses Node.js v20. Our SDK is an isomorphic TypeScript SDK at packages/trigger-sdk. Always prefer using isomorphic code like fetch, ReadableStream, etc. instead of Node.js specific code. Our tests are all vitest. We use prisma in internal-packages/database for our database interactions using PostgreSQL. For TypeScript, we usually use types over interfaces. We use zod a lot in packages/core and in the webapp. Avoid enums. Use strict mode. No default exports, use function declarations. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..7bb64f36744 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000000..6760c37bcd0 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,12 @@ +"๐Ÿ“Œ area: cli": + - any: ["cli/**/*"] + +"๐Ÿ“Œ area: t3-app": + - any: ["cli/template/**/*"] + +"๐Ÿ“š documentation": + - any: ["www/**/*"] + - any: ["**/*.md"] + +"๐Ÿ“Œ area: ci": + - any: [".github/**/*"] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000000..eec6c728208 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,27 @@ +Closes # + +## โœ… Checklist + +- [ ] I have followed every step in the [contributing guide](https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md) +- [ ] The PR title follows the convention. +- [ ] I ran and tested the code works + +--- + +## Testing + +_[Describe the steps you took to test this change]_ + +--- + +## Changelog + +_[Short description of what has changed]_ + +--- + +## Screenshots + +_[Screenshots]_ + +๐Ÿ’ฏ diff --git a/.github/test/README.md b/.github/test/README.md new file mode 100644 index 00000000000..1e8383fda56 --- /dev/null +++ b/.github/test/README.md @@ -0,0 +1,70 @@ +# GitHub Action Tests + +This directory contains necessary files to allow local testing of GitHub Actions workflows, composite actions, etc. You will need to install [act](https://github.com/nektos/act) to perform tests. + +## Workflow tests + +Trigger specific workflow files by specifying their full path: + +``` +act -W .github/workflow/release.yml +``` + +You will likely need to override any custom runners we use, e.g. buildjet. For example: + +``` +override=catthehacker/ubuntu:act-latest + +act -W .github/workflow/release.yml \ + -P buildjet-8vcpu-ubuntu-2204=$override + +# override multiple images at the same time +act -W .github/workflow/release.yml \ + -P buildjet-8vcpu-ubuntu-2204=$override \ + -P buildjet-16vcpu-ubuntu-2204=$override +``` + +Trigger with specific event payloads to test pushing to branches or tags: + +``` +override=catthehacker/ubuntu:act-latest + +# simulate push to main +act -W .github/workflow/publish.yml \ + -P buildjet-8vcpu-ubuntu-2204=$override \ + -P buildjet-16vcpu-ubuntu-2204=$override \ + -e .github/events/push-tag-main.json + +# simulate a `build-` prefixed tag +act -W .github/workflow/publish.yml \ + -P buildjet-8vcpu-ubuntu-2204=$override \ + -P buildjet-16vcpu-ubuntu-2204=$override \ + -e .github/events/push-tag-buld.json +``` + +By default, `act` will send a push event. To trigger a different event: + +``` +# basic syntax +act ... + +# simulate a pull request +act pull_request + +# only trigger a specific workflow +act pull_request -W .github/workflow/pr_checks.yml +``` + +## Composite action tests + +The composite (custom) action tests can be run by triggering the `test-actions` workflow: + +``` +act -W .github/test/test-actions.yml +``` + +## Helpful flags + +- `--pull=false` - perform fully offline tests if all images are already present +- `-j ` - run the specified job only +- `-l push` - list all workflows with push triggers diff --git a/.github/test/events/push-main.json b/.github/test/events/push-main.json new file mode 100644 index 00000000000..ccb4cb1c174 --- /dev/null +++ b/.github/test/events/push-main.json @@ -0,0 +1,3 @@ +{ + "ref": "refs/heads/main" +} diff --git a/.github/test/events/push-tag-build.json b/.github/test/events/push-tag-build.json new file mode 100644 index 00000000000..9490c181abf --- /dev/null +++ b/.github/test/events/push-tag-build.json @@ -0,0 +1,3 @@ +{ + "ref": "refs/tags/build-buildtag" +} diff --git a/.github/test/events/push-tag-docker-nonsemver.json b/.github/test/events/push-tag-docker-nonsemver.json new file mode 100644 index 00000000000..5ce2d8dcf38 --- /dev/null +++ b/.github/test/events/push-tag-docker-nonsemver.json @@ -0,0 +1,3 @@ +{ + "ref": "refs/tags/v.docker.nonsemver" +} diff --git a/.github/test/events/push-tag-docker.json b/.github/test/events/push-tag-docker.json new file mode 100644 index 00000000000..7b55610ca2e --- /dev/null +++ b/.github/test/events/push-tag-docker.json @@ -0,0 +1,3 @@ +{ + "ref": "refs/tags/v.docker.1.2.3" +} diff --git a/.github/test/events/push-tag-infra-prod.json b/.github/test/events/push-tag-infra-prod.json new file mode 100644 index 00000000000..7d4bb3a0bb8 --- /dev/null +++ b/.github/test/events/push-tag-infra-prod.json @@ -0,0 +1,3 @@ +{ + "ref": "refs/tags/infra-prod-anything" +} diff --git a/.github/test/events/push-tag-infra-test.json b/.github/test/events/push-tag-infra-test.json new file mode 100644 index 00000000000..78eeefbe41a --- /dev/null +++ b/.github/test/events/push-tag-infra-test.json @@ -0,0 +1,3 @@ +{ + "ref": "refs/tags/infra-test-anything" +} diff --git a/.github/test/events/push-tag-semver.json b/.github/test/events/push-tag-semver.json new file mode 100644 index 00000000000..3fb65c92073 --- /dev/null +++ b/.github/test/events/push-tag-semver.json @@ -0,0 +1,3 @@ +{ + "ref": "refs/tags/1.2.3" +} diff --git a/.github/test/events/push-tag.json b/.github/test/events/push-tag.json new file mode 100644 index 00000000000..26496f80874 --- /dev/null +++ b/.github/test/events/push-tag.json @@ -0,0 +1,3 @@ +{ + "ref": "refs/tags/standard-tag" +} diff --git a/.github/test/test-actions.yml b/.github/test/test-actions.yml new file mode 100644 index 00000000000..0d913ebc0e1 --- /dev/null +++ b/.github/test/test-actions.yml @@ -0,0 +1,152 @@ +name: Test Actions + +on: + workflow_dispatch: + +jobs: + get-image-tag-none: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log current ref + run: | + echo "ref: ${{ github.ref }}" + echo "ref_type: ${{ github.ref_type }}" + echo "ref_name: ${{ github.ref_name }}" + + - name: Run without input tag + id: get_tag + # this step may fail depending on the current ref + continue-on-error: true + uses: ./.github/actions/get-image-tag + + - name: Verify output + run: | + echo "${{ toJson(steps.get_tag) }}" + + get-image-tag-null: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Log current ref + run: | + echo "ref: ${{ github.ref }}" + echo "ref_type: ${{ github.ref_type }}" + echo "ref_name: ${{ github.ref_name }}" + + - name: Run without input tag + id: get_tag + uses: ./.github/actions/get-image-tag + # this step may fail depending on the current ref + continue-on-error: true + with: + # this should behave exactly as when no tag is provided + tag: null + + - name: Verify output + run: | + echo "${{ toJson(steps.get_tag) }}" + + get-image-tag-override: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run with tag override + id: get_tag + uses: ./.github/actions/get-image-tag + with: + tag: "abc-123" + + - name: Verify output + run: | + echo "${{ toJson(steps.get_tag) }}" + + get-image-tag-invalid-string: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run with invalid string + id: get_tag + uses: ./.github/actions/get-image-tag + # this step is expected to fail + continue-on-error: true + with: + # does not end with alphanumeric character + tag: "abc-123-" + + - name: Fail job if previous step did not fail + if: steps.get_tag.outcome != 'failure' + run: exit 1 + + - name: Verify output + run: | + echo "${{ toJson(steps.get_tag) }}" + + get-image-tag-prerelease: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run with prerelease semver + id: get_tag + uses: ./.github/actions/get-image-tag + with: + tag: "v1.2.3-beta.4" + + - name: Verify output + run: | + echo "${{ toJson(steps.get_tag) }}" + + get-image-tag-semver: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run with basic semver + id: get_tag + uses: ./.github/actions/get-image-tag + with: + tag: "v1.2.3" + + - name: Verify output + run: | + echo "${{ toJson(steps.get_tag) }}" + + get-image-tag-invalid-semver: + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Run with invalid semver + id: get_tag + uses: ./.github/actions/get-image-tag + # this step is expected to fail + continue-on-error: true + with: + tag: "v1.2.3-" + + - name: Fail job if previous step did not fail + if: steps.get_tag.outcome != 'failure' + run: exit 1 + + - name: Verify output + run: | + echo "${{ toJson(steps.get_tag) }}" diff --git a/.github/workflows/changesets-pr.yml b/.github/workflows/changesets-pr.yml new file mode 100644 index 00000000000..851c5341dbf --- /dev/null +++ b/.github/workflows/changesets-pr.yml @@ -0,0 +1,99 @@ +name: ๐Ÿฆ‹ Changesets PR + +on: + push: + branches: + - main + paths: + - "packages/**" + - ".changeset/**" + - ".server-changes/**" + - "package.json" + - "pnpm-lock.yaml" + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + release-pr: + name: Create Release PR + runs-on: warp-ubuntu-latest-x64-2x + permissions: + contents: write + pull-requests: write + checks: write + if: github.repository == 'triggerdotdev/trigger.dev' + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # zizmor: ignore[artipacked] changesets/action pushes the release branch; no artifact upload here so no leak path + with: + fetch-depth: 0 + + - name: Setup pnpm + uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 + + - name: Setup node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 24.18.0 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Create release PR + id: changesets + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 + with: + version: pnpm run changeset:version + commit: "chore: release" + title: "chore: release" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Update PR title and enhance body + if: steps.changesets.outputs.published != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=$(gh pr list --head changeset-release/main --json number --jq '.[0].number') + if [ -n "$PR_NUMBER" ]; then + git fetch origin changeset-release/main + # we arbitrarily reference the version of the cli package here; it is the same for all package releases + VERSION=$(git show origin/changeset-release/main:packages/cli-v3/package.json | jq -r '.version') + gh pr edit "$PR_NUMBER" --title "chore: release v$VERSION" + + # Enhance the PR body with a clean, deduplicated summary + RAW_BODY=$(gh pr view "$PR_NUMBER" --json body --jq '.body') + ENHANCED_BODY=$(CHANGESET_PR_BODY="$RAW_BODY" node scripts/enhance-release-pr.mjs "$VERSION") + if [ -n "$ENHANCED_BODY" ]; then + gh api repos/triggerdotdev/trigger.dev/pulls/"$PR_NUMBER" \ + -X PATCH \ + -f body="$ENHANCED_BODY" + fi + fi + + # The changesets bot authors release PRs with GITHUB_TOKEN, which by GitHub + # design cannot trigger downstream workflows. That leaves the required + # "All PR Checks" status permanently Expected and the PR unmergeable. + # The release PR only bumps package.json + lockfile + CHANGELOGs from + # changesets already on main, so we self-report the required check as + # success. If a human ever pushes to changeset-release/main, the real + # pr_checks.yml fires and its result overwrites this one (last write wins + # for the same context on the same SHA). + - name: Self-report "All PR Checks" success on release PR + if: steps.changesets.outputs.published != 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + PR_NUMBER=$(gh pr list --head changeset-release/main --json number --jq '.[0].number') + if [ -z "$PR_NUMBER" ]; then exit 0; fi + HEAD_SHA=$(gh pr view "$PR_NUMBER" --json headRefOid --jq '.headRefOid') + gh api -X POST repos/${{ github.repository }}/check-runs \ + -f name="All PR Checks" \ + -f head_sha="$HEAD_SHA" \ + -f status=completed \ + -f conclusion=success \ + -f 'output[title]=Auto-pass for changeset release PR' \ + -f 'output[summary]=Required check auto-satisfied for changeset-release/main PRs. Full CI ran on the underlying commits before they landed on main.' diff --git a/.github/workflows/check-review-md.yml b/.github/workflows/check-review-md.yml new file mode 100644 index 00000000000..06f0d2d82c4 --- /dev/null +++ b/.github/workflows/check-review-md.yml @@ -0,0 +1,95 @@ +name: ๐Ÿ”Ž REVIEW.md Drift Audit + +on: + pull_request: + types: [opened, ready_for_review, synchronize] + paths-ignore: + - "docs/**" + - ".changeset/**" + - ".server-changes/**" + +concurrency: + group: review-md-drift-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + audit: + # Set the ENABLE_CLAUDE_CODE repository variable to 'false' to turn off Claude + # jobs; leave it unset (the default) to keep them enabled. + if: >- + vars.ENABLE_CLAUDE_CODE != 'false' && + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: warp-ubuntu-latest-x64-2x + permissions: + contents: read + pull-requests: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@428971d2ecd6e3a7cb0ee0da2a3a8b33fdb3678d # v1.0.157 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + use_sticky_comment: true + allowed_bots: "devin-ai-integration[bot]" + + claude_args: | + --max-turns 30 + --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. + + ## Strategy โ€” read this first + + 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 + + If nothing needs changing: + + โœ… REVIEW.md looks current for this PR. + + Otherwise: + + ๐Ÿ“ **REVIEW.md updates suggested:** + + - **[stale]** `` โ€” + - **[contradiction]** `` โ€” + - **[missing]** under `##
` โ€” + - **[obsolete]** `` โ€” + + ## Rules + + - 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 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. diff --git a/.github/workflows/claude-md-audit.yml b/.github/workflows/claude-md-audit.yml new file mode 100644 index 00000000000..aa62444c9df --- /dev/null +++ b/.github/workflows/claude-md-audit.yml @@ -0,0 +1,83 @@ +name: ๐Ÿ“ Agent Instructions Audit + +on: + pull_request: + types: [opened, ready_for_review, synchronize] + paths-ignore: + - "docs/**" + - ".changeset/**" + - ".server-changes/**" + - "**/*.md" + +concurrency: + group: agent-instructions-audit-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + audit: + # Set the ENABLE_CLAUDE_CODE repository variable to 'false' to turn off Claude + # jobs; leave it unset (the default) to keep them enabled. + if: >- + vars.ENABLE_CLAUDE_CODE != 'false' && + github.event.pull_request.draft == false && + github.event.pull_request.head.repo.full_name == github.repository + runs-on: warp-ubuntu-latest-x64-2x + permissions: + contents: read + pull-requests: write + issues: write + id-token: write + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@428971d2ecd6e3a7cb0ee0da2a3a8b33fdb3678d # v1.0.157 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + use_sticky_comment: true + allowed_bots: "devin-ai-integration[bot]" + + claude_args: | + --max-turns 25 + --model claude-opus-4-8 + --allowedTools "Read,Glob,Grep,Bash(git diff:*)" + + prompt: | + You are reviewing a PR to check whether any agent instruction files need updating. + + In this repo: + - Root shared agent guidance lives in `AGENTS.md`. + - Root `CLAUDE.md` is only a Claude Code adapter that imports `AGENTS.md`. + - Subdirectories may still have scoped `CLAUDE.md` files. + - `.claude/rules/` contains additional Claude Code guidance. + + ## Your task + + 1. Run `git diff origin/main...HEAD --name-only` to see which files changed in this PR. + 2. For each changed directory, check the applicable instruction files: root `AGENTS.md`, any `CLAUDE.md` in that directory or a parent directory, and relevant `.claude/rules/` files. + 3. Determine if any instruction file should be updated based on the changes. Consider: + - New files/directories that aren't covered by existing documentation + - Changed architecture or patterns that contradict current agent guidance + - New dependencies, services, or infrastructure that agents should know about + - Renamed or moved files that are referenced in an instruction file + - Changes to build commands, test patterns, or development workflows + + ## Response format + + If NO updates are needed, respond with exactly: + โœ… Agent instruction files look current for this PR. + + If updates ARE needed, respond with a short list: + ๐Ÿ“ **Agent instruction updates suggested:** + - `AGENTS.md`: [what should be added/changed] + - `path/to/CLAUDE.md`: [what should be added/changed] + - `.claude/rules/file.md`: [what should be added/changed] + + Keep suggestions specific and brief. Only flag things that would actually mislead agents in future sessions. + Do NOT suggest updates for trivial changes (bug fixes, small refactors within existing patterns). + Do NOT suggest creating new CLAUDE.md files - only updates to existing instruction files. diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml new file mode 100644 index 00000000000..7a278672a24 --- /dev/null +++ b/.github/workflows/claude.yml @@ -0,0 +1,76 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + # Set the ENABLE_CLAUDE_CODE repository variable to 'false' to turn off Claude + # jobs; leave it unset (the default) to keep them enabled. + if: | + vars.ENABLE_CLAUDE_CODE != 'false' && + ( + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + ) + runs-on: warp-ubuntu-latest-x64-2x + permissions: + contents: write + pull-requests: write + issues: write + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 1 + 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: 24.18.0 + cache: "pnpm" + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@428971d2ecd6e3a7cb0ee0da2a3a8b33fdb3678d # v1.0.157 + with: + anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + claude_args: | + --model claude-opus-4-5-20251101 + --allowedTools "Bash(pnpm:*),Bash(turbo:*),Bash(git:*),Bash(gh:*),Bash(npx:*),Bash(docker:*),Edit,MultiEdit,Read,Write,Glob,Grep,LS,Task" + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr:*)' diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml new file mode 100644 index 00000000000..48575827b39 --- /dev/null +++ b/.github/workflows/code-quality.yml @@ -0,0 +1,38 @@ +name: "๐ŸŽจ Format & Lint" + +on: + workflow_call: + +permissions: + contents: read + +jobs: + code-quality: + runs-on: warp-ubuntu-latest-x64-2x + + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + 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: 24.18.0 + cache: "pnpm" + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ’… Check formatting + run: pnpm exec oxfmt --check . + + - name: ๐Ÿ”Ž Lint + run: pnpm exec oxlint . diff --git a/.github/workflows/dashboard-agent-deploy.yml b/.github/workflows/dashboard-agent-deploy.yml new file mode 100644 index 00000000000..e5ea856069b --- /dev/null +++ b/.github/workflows/dashboard-agent-deploy.yml @@ -0,0 +1,72 @@ +name: "๐Ÿค– Deploy dashboard agent" + +# Deploys the @internal/dashboard-agent chat.agent to its Trigger.dev project +# with --skip-promotion, so a deploy never becomes "current" on its own. The +# consuming app cuts over by pinning DASHBOARD_AGENT_VERSION to the new version. +# Runs a leg per environment (staging + prod), each gated by its own environment; +# a push to main that touches the agent or its store triggers both. Version +# numbers are per-environment, so pin each environment to its own leg's version. + +on: + push: + branches: [main] + paths: + - "internal-packages/dashboard-agent/**" + - "internal-packages/dashboard-agent-db/**" + workflow_dispatch: + +permissions: {} + +jobs: + deploy: + name: Deploy dashboard agent (${{ matrix.environment }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + # One environment at a time: parallel deploys of the same project race. + max-parallel: 1 + matrix: + environment: [staging, prod] + # Per-environment reviewer gate + source of the scoped deploy PAT. + environment: dashboard-agent-${{ matrix.environment }} + concurrency: + group: dashboard-agent-deploy-${{ matrix.environment }} + cancel-in-progress: false + permissions: + contents: read + env: + TRIGGER_API_URL: https://api.trigger.dev + TRIGGER_DASHBOARD_AGENT_PROJECT_REF: ${{ vars.TRIGGER_DASHBOARD_AGENT_PROJECT_REF }} + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + 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: 24.18.0 + cache: "pnpm" + + - name: Install + build the CLI and the agent's deps + run: | + set -euo pipefail + pnpm install --frozen-lockfile + # Prisma client is needed because the build closure pulls in @trigger.dev/database. + pnpm run generate + # Config-time imports the agent's trigger.config.ts needs: defineConfig (sdk), aptGet (build). + pnpm run build --filter trigger.dev --filter @trigger.dev/build --filter @trigger.dev/sdk + + - name: Deploy (--skip-promotion) + working-directory: internal-packages/dashboard-agent + env: + TRIGGER_ACCESS_TOKEN: ${{ secrets.TRIGGER_DASHBOARD_AGENT_DEPLOY_TOKEN }} + # Invoke the built CLI directly (what the workspace .bin/trigger wrapper does), + # so a not-yet-linked bin after a fresh install can't break the deploy. + run: node ../../packages/cli-v3/dist/esm/index.js deploy --skip-promotion --env ${{ matrix.environment }} diff --git a/.github/workflows/dependabot-critical-alerts.yml b/.github/workflows/dependabot-critical-alerts.yml new file mode 100644 index 00000000000..62d68c30023 --- /dev/null +++ b/.github/workflows/dependabot-critical-alerts.yml @@ -0,0 +1,87 @@ +name: Dependabot Critical Alerts + +on: + schedule: + - cron: "0 8 * * *" # Daily 08:00 UTC + workflow_dispatch: + inputs: + severity: + description: "Severity to alert on" + type: choice + options: + - critical + - high + - medium + - low + default: critical + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +permissions: + contents: read + +jobs: + alert: + name: Post critical alerts + # Set the ENABLE_DEPENDABOT_ALERTS repository variable to 'false' to turn off + # the Dependabot alert/summary notifiers โ€” e.g. forks/mirrors that lack the + # DEPENDABOT_ALERTS_TOKEN / SLACK_BOT_TOKEN secrets. Defaults to enabled. + if: ${{ vars.ENABLE_DEPENDABOT_ALERTS != 'false' }} + runs-on: warp-ubuntu-latest-x64-2x + environment: dependabot-summary + env: + SEVERITY: ${{ inputs.severity || 'critical' }} + steps: + - name: Fetch alerts + id: alerts + env: + GH_TOKEN: ${{ secrets.DEPENDABOT_ALERTS_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + gh api -X GET "/repos/$REPO/dependabot/alerts" \ + -F state=open -F severity="$SEVERITY" --paginate > pages.json + jq -s 'add' pages.json > alerts.json + TOTAL=$(jq 'length' alerts.json) + echo "total=$TOTAL" >> "$GITHUB_OUTPUT" + if [ "$TOTAL" = "0" ]; then + exit 0 + fi + LIST=$(jq -r ' + map("โ€ข <\(.html_url)|#\(.number)> *\(.dependency.package.name)* - \(.security_advisory.summary)") + | join("\n") + ' alerts.json) + { + echo "list<> "$GITHUB_OUTPUT" + + - name: Build Slack payload + if: steps.alerts.outputs.total != '0' + env: + REPO: ${{ github.repository }} + CHANNEL: ${{ vars.SLACK_CHANNEL_ID }} + TOTAL: ${{ steps.alerts.outputs.total }} + LIST: ${{ steps.alerts.outputs.list }} + run: | + jq -n \ + --arg channel "$CHANNEL" \ + --arg repo "$REPO" \ + --arg total "$TOTAL" \ + --arg list "$LIST" \ + --arg severity "$SEVERITY" \ + '{ + channel: $channel, + text: ":bufo-alarma: `\($repo)` - *\($total) open \($severity) alert(s)*\n\($list)\n\n" + }' > payload.json + + - name: Post Slack alert + if: steps.alerts.outputs.total != '0' + uses: slackapi/slack-github-action@45a88b9581bfab2566dc881e2cd66d334e621e2c # v3.0.3 + with: + method: chat.postMessage + token: ${{ secrets.SLACK_BOT_TOKEN }} + payload-file-path: payload.json diff --git a/.github/workflows/dependabot-weekly-summary.yml b/.github/workflows/dependabot-weekly-summary.yml new file mode 100644 index 00000000000..fc9eaebcb94 --- /dev/null +++ b/.github/workflows/dependabot-weekly-summary.yml @@ -0,0 +1,210 @@ +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 + # Set the ENABLE_DEPENDABOT_ALERTS repository variable to 'false' to turn off + # the Dependabot alert/summary notifiers โ€” e.g. forks/mirrors that lack the + # DEPENDABOT_ALERTS_TOKEN / SLACK_BOT_TOKEN secrets. Defaults to enabled. + if: ${{ vars.ENABLE_DEPENDABOT_ALERTS != 'false' }} + runs-on: warp-ubuntu-latest-x64-2x + 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 diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000000..460a21c2021 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,44 @@ +name: ๐Ÿ“š Docs Checks + +on: + push: + branches: + - main + paths: + - "docs/**" + pull_request: + types: [opened, synchronize, reopened] + paths: + - "docs/**" + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + check-broken-links: + runs-on: warp-ubuntu-latest-x64-2x + defaults: + run: + working-directory: ./docs + steps: + - name: ๐Ÿ“ฅ Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: ๐Ÿ“ฆ Cache npm + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: | + ~/.npm + key: | + ${{ runner.os }}-mintlify + restore-keys: | + ${{ runner.os }}-mintlify + + - name: ๐Ÿ”— Check for broken links + run: npx mintlify@4.0.393 broken-links diff --git a/.github/workflows/e2e-webapp-auth-full.yml b/.github/workflows/e2e-webapp-auth-full.yml new file mode 100644 index 00000000000..e88429a3443 --- /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: warp-ubuntu-latest-x64-8x + 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + 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: 24.18.0 + cache: "pnpm" + + - name: ๐Ÿณ Login to DockerHub + if: ${{ env.DOCKERHUB_USERNAME }} + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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/.github/workflows/e2e-webapp.yml b/.github/workflows/e2e-webapp.yml new file mode 100644 index 00000000000..1931cfdd336 --- /dev/null +++ b/.github/workflows/e2e-webapp.yml @@ -0,0 +1,97 @@ +name: "๐Ÿงช E2E Tests: Webapp" + +permissions: + contents: read + +on: + workflow_call: + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false + +jobs: + e2eTests: + name: "๐Ÿงช E2E Tests: Webapp" + runs-on: warp-ubuntu-latest-x64-16x + timeout-minutes: 20 + 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + 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: 24.18.0 + cache: "pnpm" + + # ..to avoid rate limits when pulling images + - name: ๐Ÿณ Login to DockerHub + if: ${{ env.DOCKERHUB_USERNAME }} + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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: | + echo "Pre-pulling Docker images with authenticated session..." + docker pull postgres:14 + docker pull redis:7.2 + docker pull testcontainers/ryuk:0.11.0 + echo "Image pre-pull complete" + + - 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 E2E Tests + run: cd apps/webapp && pnpm exec vitest run --config vitest.e2e.config.ts --reporter=default + env: + WEBAPP_TEST_VERBOSE: "1" diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000000..4584ea69c13 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,62 @@ +name: "E2E" + +permissions: + contents: read + +on: + workflow_call: + inputs: + package: + description: The identifier of the job to run + default: webapp + required: false + type: string + +jobs: + cli-v3: + name: "๐Ÿงช CLI v3 tests (${{ matrix.os }} - ${{ matrix.package-manager }})" + if: inputs.package == 'cli-v3' || inputs.package == '' + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [warp-ubuntu-latest-x64-4x, warp-windows-latest-x64-8x] + package-manager: ["npm", "pnpm"] + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + 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: 24.18.0 + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile --filter trigger.dev... + + # Prisma clients generate concurrently and share one engine binary in the + # pnpm store; the generate script retries the transient Windows EPERM race. + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿ”ง Build v3 cli monorepo dependencies + run: pnpm run build --filter trigger.dev^... + + - name: ๐Ÿ”ง Build worker template files + run: pnpm --filter trigger.dev run --if-present build:workers + + - name: Enable corepack + run: corepack enable + + - name: Run E2E Tests + shell: bash + run: | + LOG=debug PM=${{ matrix.package-manager }} pnpm --filter trigger.dev run test:e2e diff --git a/.github/workflows/helm-prerelease.yml b/.github/workflows/helm-prerelease.yml new file mode 100644 index 00000000000..37b909e415e --- /dev/null +++ b/.github/workflows/helm-prerelease.yml @@ -0,0 +1,205 @@ +name: ๐Ÿงญ Helm Chart Prerelease + +on: + pull_request: + types: [opened, synchronize, reopened] + paths: + - "hosting/k8s/helm/**" + push: + branches: + - main + paths: + - "hosting/k8s/helm/**" + workflow_dispatch: + inputs: + app_version: + description: "Override appVersion (e.g. 'main', 'v4.4.4'). Leave empty to keep Chart.yaml value." + required: false + type: string + default: "" + +concurrency: + group: helm-prerelease-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + REGISTRY: ghcr.io + CHART_NAME: trigger + +jobs: + lint-and-test: + runs-on: warp-ubuntu-latest-x64-2x + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up Helm + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + with: + version: "3.18.3" + + - name: Build dependencies + run: helm dependency build ./hosting/k8s/helm/ + + - name: Extract dependency charts + run: | + cd ./hosting/k8s/helm/ + for file in ./charts/*.tgz; do echo "Extracting $file"; tar -xzf "$file" -C ./charts; done + + - name: Lint Helm Chart + run: | + helm lint ./hosting/k8s/helm/ + + - name: Render templates + run: | + helm template test-release ./hosting/k8s/helm/ \ + --values ./hosting/k8s/helm/values.yaml \ + --output-dir ./helm-output + + - name: Validate manifests + uses: docker://ghcr.io/yannh/kubeconform:v0.7.0@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c + with: + entrypoint: "/kubeconform" + args: "-summary -output json ./helm-output" + + prerelease: + needs: lint-and-test + # Set the ENABLE_HELM_PRERELEASE repository variable to 'false' to turn off + # publishing the chart to GHCR โ€” e.g. forks/mirrors that lack write_package + # on the owner's charts namespace. Defaults to enabled; the lint-and-test + # job above always runs regardless. + if: | + vars.ENABLE_HELM_PRERELEASE != 'false' && + ((github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) || + github.event_name == 'push' || + github.event_name == 'workflow_dispatch') + runs-on: warp-ubuntu-latest-x64-2x + permissions: + contents: read + packages: write + pull-requests: write + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up Helm + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + with: + version: "3.18.3" + + - name: Build dependencies + run: helm dependency build ./hosting/k8s/helm/ + + - name: Extract dependency charts + run: | + cd ./hosting/k8s/helm/ + for file in ./charts/*.tgz; do echo "Extracting $file"; tar -xzf "$file" -C ./charts; done + + - name: Log in to Container Registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Generate prerelease version + id: version + run: | + BASE_VERSION=$(grep '^version:' ./hosting/k8s/helm/Chart.yaml | awk '{print $2}') + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + PR_NUMBER=${{ github.event.pull_request.number }} + SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7) + PRERELEASE_VERSION="${BASE_VERSION}-pr${PR_NUMBER}.${SHORT_SHA}" + elif [[ "${{ github.event_name }}" == "push" ]]; then + SHORT_SHA=$(echo "${GITHUB_SHA}" | cut -c1-7) + PRERELEASE_VERSION="${BASE_VERSION}-main.${SHORT_SHA}" + else + SHORT_SHA=$(echo "${GITHUB_SHA}" | cut -c1-7) + REF_SLUG=$(echo "${GITHUB_REF_NAME}" | tr '/' '-' | tr -cd 'a-zA-Z0-9-') + if [[ -z "$REF_SLUG" ]]; then + REF_SLUG="manual" + fi + PRERELEASE_VERSION="${BASE_VERSION}-${REF_SLUG}.${SHORT_SHA}" + fi + echo "version=$PRERELEASE_VERSION" >> "$GITHUB_OUTPUT" + echo "Prerelease version: $PRERELEASE_VERSION" + + - name: Update Chart.yaml with prerelease version + run: | + sed -i "s/^version:.*/version: ${STEPS_VERSION_OUTPUTS_VERSION}/" ./hosting/k8s/helm/Chart.yaml + env: + STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} + + - name: Override appVersion + if: github.event_name == 'workflow_dispatch' && inputs.app_version != '' + env: + APP_VERSION: ${{ inputs.app_version }} + run: | + yq -i '.appVersion = strenv(APP_VERSION)' ./hosting/k8s/helm/Chart.yaml + + - name: Package Helm Chart + run: | + helm package ./hosting/k8s/helm/ --destination /tmp/ + + - name: Push Helm Chart to GHCR + run: | + VERSION="${STEPS_VERSION_OUTPUTS_VERSION}" + CHART_PACKAGE="/tmp/${{ env.CHART_NAME }}-${VERSION}.tgz" + + # Push to GHCR OCI registry + helm push "$CHART_PACKAGE" "oci://${{ env.REGISTRY }}/${{ github.repository_owner }}/charts" + env: + STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} + + - name: Write run summary + run: | + { + echo "### ๐Ÿงญ Helm Chart Prerelease Published" + echo "" + echo "**Version:** \`${STEPS_VERSION_OUTPUTS_VERSION}\`" + echo "" + echo "**Install:**" + echo '```bash' + echo "helm upgrade --install trigger \\" + echo " oci://${{ env.REGISTRY }}/${{ github.repository_owner }}/charts/${{ env.CHART_NAME }} \\" + echo " --version \"${STEPS_VERSION_OUTPUTS_VERSION}\"" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + env: + STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} + + - name: Find existing comment + if: github.event_name == 'pull_request' + uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0 + id: find-comment + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: "github-actions[bot]" + body-includes: "Helm Chart Prerelease Published" + + - name: Create or update PR comment + if: github.event_name == 'pull_request' + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 + with: + comment-id: ${{ steps.find-comment.outputs.comment-id }} + issue-number: ${{ github.event.pull_request.number }} + body: | + ### ๐Ÿงญ Helm Chart Prerelease Published + + **Version:** `${{ steps.version.outputs.version }}` + + **Install:** + ```bash + helm upgrade --install trigger \ + oci://ghcr.io/${{ github.repository_owner }}/charts/trigger \ + --version "${{ steps.version.outputs.version }}" + ``` + + > โš ๏ธ This is a prerelease for testing. Do not use in production. + edit-mode: replace diff --git a/.github/workflows/pr_checks.yml b/.github/workflows/pr_checks.yml new file mode 100644 index 00000000000..b0fbd6ac040 --- /dev/null +++ b/.github/workflows/pr_checks.yml @@ -0,0 +1,190 @@ +name: ๐Ÿค– PR Checks + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: read + +jobs: + changes: + name: Detect changes + runs-on: warp-ubuntu-latest-x64-2x + outputs: + code: ${{ steps.code_filter.outputs.code }} + typecheck_self: ${{ steps.filter.outputs.typecheck_self }} + 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: + # `code` uses `every` semantics so the negation patterns actually subtract. + # With the default `some` quantifier, `**` matches every file and the + # subsequent `!...` patterns are no-ops (each pattern is OR'd, not AND'd). + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: code_filter + with: + predicate-quantifier: every + filters: | + code: + - '**' + - '!docs/**' + - '!.changeset/**' + - '!hosting/**' + - '!.github/**' + - '!**/*.md' + - '!**/.env.example' + - uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1 + id: filter + with: + filters: | + typecheck_self: + - '.github/workflows/pr_checks.yml' + - '.github/workflows/typecheck.yml' + - '.github/workflows/code-quality.yml' + webapp: + - 'apps/webapp/**' + - 'packages/**' + - 'internal-packages/**' + - '.github/workflows/pr_checks.yml' + - '.github/workflows/unit-tests-webapp.yml' + - '.github/workflows/e2e-webapp.yml' + - '.github/workflows/runops-guard.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' + + code-quality: + uses: ./.github/workflows/code-quality.yml + + typecheck: + needs: changes + if: needs.changes.outputs.code == 'true' || needs.changes.outputs.typecheck_self == 'true' + uses: ./.github/workflows/typecheck.yml + + runops-guard: + needs: changes + if: needs.changes.outputs.webapp == 'true' + uses: ./.github/workflows/runops-guard.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 + - code-quality + - typecheck + - runops-guard + - webapp + - e2e-webapp + - packages + - internal + - e2e + - sdk-compat + if: always() + runs-on: warp-ubuntu-latest-x64-2x + 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" diff --git a/.github/workflows/preview-dispatch.yml b/.github/workflows/preview-dispatch.yml new file mode 100644 index 00000000000..ac0decfafa8 --- /dev/null +++ b/.github/workflows/preview-dispatch.yml @@ -0,0 +1,76 @@ +name: ๐ŸŒฑ Preview environment dispatch + +# Opt-in per-PR preview environments + +on: + pull_request: + types: [opened, reopened, synchronize, closed, labeled, unlabeled] + +# Serialize a PR's events so dispatches arrive in order. Cloud-side concurrency +# collapses by branch but can't fix out-of-order arrival โ€” e.g. a push racing a +# close could cancel the in-flight destroy and leak the preview. One short API +# call, so queuing is cheap; cancel-in-progress: false lets an in-flight +# dispatch finish (GitHub keeps only the latest pending, the desired behavior). +concurrency: + group: preview-dispatch-${{ github.event.pull_request.number }} + cancel-in-progress: false + +permissions: {} + +jobs: + dispatch: + name: Dispatch preview-deploy to cloud + runs-on: warp-ubuntu-latest-x64-2x + # label added -> create + # new commit while labeled -> update + # label removed / PR closed -> destroy + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + ( + (github.event.action == 'labeled' && github.event.label.name == 'preview') || + (github.event.action == 'unlabeled' && github.event.label.name == 'preview') || + ( + contains(github.event.pull_request.labels.*.name, 'preview') && + contains(fromJSON('["opened","reopened","synchronize","closed"]'), github.event.action) + ) + ) + steps: + - name: Build dispatch payload + id: payload + env: + ACTION: ${{ github.event.action }} + BRANCH: ${{ github.event.pull_request.head.ref }} + COMMIT: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + # Map the GitHub PR action to the cloud pipeline's lifecycle event. + case "$ACTION" in + labeled | opened | reopened) EVENT=opened ;; + synchronize) EVENT=synchronize ;; + unlabeled | closed) EVENT=closed ;; + *) echo "unexpected action: $ACTION" >&2; exit 1 ;; + esac + # jq --arg JSON-escapes every value, so a branch name containing + # quotes/braces can't break or inject into the client payload. + payload=$(jq -nc \ + --arg b "$BRANCH" \ + --arg c "$COMMIT" \ + --arg e "$EVENT" \ + '{branch_name: $b, commit: $c, pull_request_event: $e}') + { + echo "client_payload=$payload" + echo "summary=$EVENT for $BRANCH @ ${COMMIT:0:7}" + } >> "$GITHUB_OUTPUT" + + - name: Log dispatch + env: + SUMMARY: ${{ steps.payload.outputs.summary }} + run: echo "Dispatching preview-deploy event ($SUMMARY)" + + - name: Send repository_dispatch + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 + with: + token: ${{ secrets.CROSS_REPO_PAT }} + repository: triggerdotdev/cloud + event-type: preview-deploy + client-payload: ${{ steps.payload.outputs.client_payload }} diff --git a/.github/workflows/preview-packages.yml b/.github/workflows/preview-packages.yml new file mode 100644 index 00000000000..b5bdbb31347 --- /dev/null +++ b/.github/workflows/preview-packages.yml @@ -0,0 +1,83 @@ +name: ๐Ÿ“ฆ Preview packages (pkg.pr.new) + +# Publishes installable preview builds of the public @trigger.dev/* packages +# for every push to a branch, via https://pkg.pr.new. These are NOT published +# to npm โ€” pkg.pr.new serves them by commit SHA and drops install instructions +# in a comment on the associated PR, e.g. +# npm i https://pkg.pr.new/@trigger.dev/sdk@ +# +# Prerequisites: +# - The pkg.pr.new GitHub App must be installed on triggerdotdev/trigger.dev +# (https://github.com/apps/pkg-pr-new). Publishing fails until it is. +# +# Fork note: pkg.pr.new authenticates with a GitHub Actions OIDC token, which +# GitHub does not issue to pull_request workflows from forks. This `push` +# trigger therefore covers branches pushed to this repo (the core team), not +# external fork PRs. Adding fork coverage would require a workflow_run two-stage +# setup. + +on: + push: + branches-ignore: + - main + - changeset-release/main + paths: + - "package.json" + - "packages/**" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - ".github/workflows/preview-packages.yml" + - "scripts/stamp-preview-version.mjs" + - "scripts/updateVersion.ts" + +concurrency: + group: preview-packages-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + id-token: write # OIDC token used by pkg.pr.new to authenticate the publish + +jobs: + publish: + name: Build and publish previews + runs-on: warp-ubuntu-latest-x64-8x + if: github.repository == 'triggerdotdev/trigger.dev' + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + 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: 24.18.0 + cache: "pnpm" + + - name: ๐Ÿ“ฅ Install dependencies + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma client + run: pnpm run generate + + # Stamp a unique 0.0.0-preview- version before building so it can't + # collide with real npm versions and so updateVersion.ts bakes it into the + # runtime VERSION constant. See scripts/stamp-preview-version.mjs. + - name: ๐Ÿท๏ธ Stamp preview version + run: node scripts/stamp-preview-version.mjs + env: + GITHUB_SHA: ${{ github.sha }} + + - name: ๐Ÿ”จ Build packages + run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev" + + - name: ๐Ÿš€ Publish previews to pkg.pr.new + run: pnpm exec pkg-pr-new publish --pnpm --compact --commentWithSha './packages/*' diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml new file mode 100644 index 00000000000..f664a9a2fd4 --- /dev/null +++ b/.github/workflows/publish-docs.yml @@ -0,0 +1,36 @@ +name: ๐Ÿ“š Publish docs + +on: + push: + tags: + - "docs-release-*" + +# Only needs to move the docs-live ref; Mintlify's GitHub app deploys from it. +permissions: + contents: write + +concurrency: + group: publish-docs + cancel-in-progress: false + +jobs: + publish: + runs-on: warp-ubuntu-latest-x64-2x + steps: + - name: ๐Ÿ“ฅ Checkout tagged commit + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: ๐Ÿ”— Check for broken links + working-directory: ./docs + run: npx mintlify@4.0.393 broken-links + + - name: ๐Ÿš€ Fast-forward docs-live to the tagged commit + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh api -X PATCH \ + "repos/${{ github.repository }}/git/refs/heads/docs-live" \ + -f sha="${{ github.sha }}" \ + -F force=false diff --git a/.github/workflows/publish-webapp.yml b/.github/workflows/publish-webapp.yml new file mode 100644 index 00000000000..497cdfb8ed6 --- /dev/null +++ b/.github/workflows/publish-webapp.yml @@ -0,0 +1,149 @@ +name: "๐Ÿณ Publish Webapp" + +permissions: + contents: read + packages: write + id-token: write + attestations: write + +on: + workflow_call: + inputs: + image_tag: + description: The image tag to publish + type: string + required: false + default: "" + image_registry: + description: The registry namespace to publish under (e.g. ghcr.io/) + type: string + required: false + default: "" + outputs: + version: + description: The published image tag + value: ${{ jobs.publish.outputs.version }} + short_sha: + description: Short commit SHA of the published build + value: ${{ jobs.publish.outputs.short_sha }} + image_repo: + description: The image repository the build was published to (without tag) + value: ${{ jobs.publish.outputs.image_repo }} + digest: + description: Multi-arch index digest (sha256:...) of the published image + value: ${{ jobs.publish.outputs.digest }} + secrets: + SENTRY_AUTH_TOKEN: + required: false + +jobs: + publish: + runs-on: warp-ubuntu-latest-x64-2x + env: + PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: 1 + outputs: + version: ${{ steps.get_tag.outputs.tag }} + short_sha: ${{ steps.get_commit.outputs.sha_short }} + image_repo: ${{ steps.set_tags.outputs.image_repo }} + digest: ${{ steps.build_push.outputs.digest }} + steps: + - name: ๐Ÿญ Setup Depot CLI + uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1 + + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + submodules: recursive + persist-credentials: false + + - name: "#๏ธโƒฃ Get the image tag" + id: get_tag + uses: ./.github/actions/get-image-tag + with: + tag: ${{ inputs.image_tag }} + + - name: ๐Ÿ”ข Get the commit hash + id: get_commit + run: | + echo "sha_short=$(echo "${GITHUB_SHA}" | cut -c1-7)" >> "$GITHUB_OUTPUT" + + - name: ๐Ÿ“› Set the tags + id: set_tags + run: | + # The registry namespace is resolved by the caller (defaulting to + # ghcr.io/, overridable via the IMAGE_REGISTRY repository + # variable); the webapp image lives at /. A fork + # therefore publishes to its own package automatically. + image_tags=$REF_WITHOUT_TAG:${STEPS_GET_TAG_OUTPUTS_TAG} + + if [[ "${STEPS_GET_TAG_OUTPUTS_TAG}" =~ ^v4\.[0-9]+\.[0-9]+$ ]]; then + image_tags=$image_tags,$REF_WITHOUT_TAG:v4,$REF_WITHOUT_TAG:latest + fi + + # when pushing the mutable main tag, also push an immutable-by-convention + # full-commit-sha tag so a commit can be resolved to a specific digest + if [[ "${STEPS_GET_TAG_OUTPUTS_TAG}" == "main" ]]; then + image_tags=$image_tags,$REF_WITHOUT_TAG:${GITHUB_SHA} + fi + + echo "image_tags=${image_tags}" >> "$GITHUB_OUTPUT" + echo "image_repo=${REF_WITHOUT_TAG}" >> "$GITHUB_OUTPUT" + env: + REF_WITHOUT_TAG: ${{ format('{0}/{1}', inputs.image_registry || vars.IMAGE_REGISTRY || format('ghcr.io/{0}', github.repository_owner), github.event.repository.name) }} + STEPS_GET_TAG_OUTPUTS_TAG: ${{ steps.get_tag.outputs.tag }} + STEPS_GET_TAG_OUTPUTS_IS_SEMVER: ${{ steps.get_tag.outputs.is_semver }} + + - name: ๐Ÿ“ Set the build info + id: set_build_info + run: | + { + tag="${STEPS_GET_TAG_OUTPUTS_TAG}" + if [[ "${STEPS_GET_TAG_OUTPUTS_IS_SEMVER}" == true ]]; then + echo "BUILD_APP_VERSION=${tag}" + fi + echo "BUILD_GIT_SHA=${GITHUB_SHA}" + echo "BUILD_GIT_REF_NAME=${GITHUB_REF_NAME}" + echo "BUILD_TIMESTAMP_SECONDS=$(date +%s)" + echo "BUILD_TIMESTAMP_RFC3339=$(date -u +%Y-%m-%dT%H:%M:%SZ)" + } >> "$GITHUB_OUTPUT" + env: + STEPS_GET_TAG_OUTPUTS_TAG: ${{ steps.get_tag.outputs.tag }} + STEPS_GET_TAG_OUTPUTS_IS_SEMVER: ${{ steps.get_tag.outputs.is_semver }} + + - name: ๐Ÿ™ Login to GitHub Container Registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: ๐Ÿณ Build image and push to GitHub Container Registry + id: build_push + uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0 + with: + file: ./docker/Dockerfile + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.set_tags.outputs.image_tags }} + push: true + build-args: | + BUILD_APP_VERSION=${{ steps.set_build_info.outputs.BUILD_APP_VERSION }} + BUILD_GIT_SHA=${{ steps.set_build_info.outputs.BUILD_GIT_SHA }} + BUILD_GIT_REF_NAME=${{ steps.set_build_info.outputs.BUILD_GIT_REF_NAME }} + BUILD_TIMESTAMP_SECONDS=${{ steps.set_build_info.outputs.BUILD_TIMESTAMP_SECONDS }} + BUILD_TIMESTAMP_RFC3339=${{ steps.set_build_info.outputs.BUILD_TIMESTAMP_RFC3339 }} + SENTRY_RELEASE=${{ steps.set_build_info.outputs.BUILD_GIT_SHA }} + SENTRY_ORG=triggerdev + SENTRY_PROJECT=trigger-cloud + secrets: | + sentry_auth_token=${{ secrets.SENTRY_AUTH_TOKEN }} + + - name: ๐Ÿชช Attest build provenance + # Image is already pushed by this point โ€” don't fail releases (and the + # downstream publish-helm job) on a Sigstore/GHCR-referrer hiccup. Real + # config errors still surface as a step warning in the workflow run. + continue-on-error: true + uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v4.1.0 + with: + subject-name: ${{ steps.set_tags.outputs.image_repo }} + subject-digest: ${{ steps.build_push.outputs.digest }} + push-to-registry: true diff --git a/.github/workflows/publish-worker-v4.yml b/.github/workflows/publish-worker-v4.yml new file mode 100644 index 00000000000..ab10e716638 --- /dev/null +++ b/.github/workflows/publish-worker-v4.yml @@ -0,0 +1,103 @@ +name: "โš’๏ธ Publish Worker (v4)" + +on: + workflow_call: + inputs: + image_tag: + description: The image tag to publish + type: string + required: false + default: "" + image_registry: + description: The registry namespace to publish under (e.g. ghcr.io/) + type: string + required: false + default: "" + push: + tags: + - "re2-test-*" + - "re2-prod-*" + +permissions: + id-token: write + packages: write + contents: read + +jobs: + # check-branch: + # runs-on: warp-ubuntu-latest-x64-2x + # steps: + # - name: Fail if re2-prod-* is pushed from a non-main branch + # if: startsWith(github.ref_name, 're2-prod-') && github.base_ref != 'main' + # run: | + # echo "๐Ÿšซ re2-prod-* tags can only be pushed from the main branch." + # exit 1 + build: + # needs: check-branch + strategy: + matrix: + package: [supervisor] + runs-on: warp-ubuntu-latest-x64-2x + env: + DOCKER_BUILDKIT: "1" + steps: + - name: ๐Ÿญ Setup Depot CLI + uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1 + + - name: โฌ‡๏ธ Checkout git repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: ๐Ÿ“ฆ Get image repo + id: get_repository + env: + PACKAGE: ${{ matrix.package }} + run: | + if [[ "$PACKAGE" == *-provider ]]; then + repo="provider/${PACKAGE%-provider}" + else + repo="$PACKAGE" + fi + echo "repo=${repo}" >> "$GITHUB_OUTPUT" + + - name: "#๏ธโƒฃ Get image tag" + id: get_tag + uses: ./.github/actions/get-image-tag + with: + tag: ${{ inputs.image_tag }} + + - name: ๐Ÿ“› Set tags to push + id: set_tags + run: | + # Resolved by the caller when invoked from publish.yml; falls back to the + # IMAGE_REGISTRY repository variable (or ghcr.io/) for the direct + # push triggers above, so a fork publishes to its own namespace. + ref_without_tag=${IMAGE_REGISTRY}/${STEPS_GET_REPOSITORY_OUTPUTS_REPO} + image_tags=$ref_without_tag:${STEPS_GET_TAG_OUTPUTS_TAG} + + if [[ "${STEPS_GET_TAG_OUTPUTS_TAG}" =~ ^v4\.[0-9]+\.[0-9]+$ ]]; then + image_tags=$image_tags,$ref_without_tag:v4,$ref_without_tag:latest + fi + + echo "image_tags=${image_tags}" >> "$GITHUB_OUTPUT" + env: + IMAGE_REGISTRY: ${{ inputs.image_registry || vars.IMAGE_REGISTRY || format('ghcr.io/{0}', github.repository_owner) }} + STEPS_GET_REPOSITORY_OUTPUTS_REPO: ${{ steps.get_repository.outputs.repo }} + STEPS_GET_TAG_OUTPUTS_TAG: ${{ steps.get_tag.outputs.tag }} + STEPS_GET_TAG_OUTPUTS_IS_SEMVER: ${{ steps.get_tag.outputs.is_semver }} + + - name: ๐Ÿ™ Login to GitHub Container Registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: ๐Ÿณ Build image and push to GitHub Container Registry + uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0 + with: + file: ./apps/${{ matrix.package }}/Containerfile + platforms: linux/amd64,linux/arm64 + tags: ${{ steps.set_tags.outputs.image_tags }} + push: true diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 00000000000..a0f32b02632 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,149 @@ +name: ๐Ÿš€ Publish Trigger.dev Docker + +on: + workflow_dispatch: + workflow_call: + inputs: + image_tag: + description: The image tag to publish + required: true + type: string + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false + SENTRY_AUTH_TOKEN: + required: false + CROSS_REPO_PAT: + required: false + push: + branches: + - main + tags: + - "v.docker.*" + - "build-*" + paths: + - ".github/actions/**/*.yml" + - ".github/workflows/publish.yml" + - ".github/workflows/typecheck.yml" + - ".github/workflows/unit-tests.yml" + - ".github/workflows/e2e.yml" + - ".github/workflows/publish-webapp.yml" + - "packages/**" + - "!packages/**/*.md" + - "!packages/**/*.eslintrc" + - "internal-packages/**" + - "apps/**" + - "!apps/**/*.md" + - "!apps/**/*.eslintrc" + - "pnpm-lock.yaml" + - "pnpm-workspace.yaml" + - "turbo.json" + - "docker/Dockerfile" + - "docker/scripts/**" + - "tests/**" + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + +env: + AWS_REGION: us-east-1 + +jobs: + typecheck: + uses: ./.github/workflows/typecheck.yml + + units: + uses: ./.github/workflows/unit-tests.yml + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + + publish-webapp: + needs: [typecheck] + permissions: + contents: read + packages: write + id-token: write + attestations: write + uses: ./.github/workflows/publish-webapp.yml + secrets: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + with: + image_tag: ${{ inputs.image_tag }} + # Target registry namespace. Defaults to ghcr.io/ so a fork publishes + # to its own namespace; set the IMAGE_REGISTRY repository variable to override. + image_registry: ${{ vars.IMAGE_REGISTRY || format('ghcr.io/{0}', github.repository_owner) }} + + publish-worker-v4: + needs: [typecheck] + permissions: + contents: read + packages: write + id-token: write + uses: ./.github/workflows/publish-worker-v4.yml + with: + image_tag: ${{ inputs.image_tag }} + image_registry: ${{ vars.IMAGE_REGISTRY || format('ghcr.io/{0}', github.repository_owner) }} + + # OS-level CVE scan of the image just published above. Report-only (writes to + # the run summary); runs alongside the worker publishes and never blocks them. + scan-webapp: + needs: [publish-webapp] + permissions: + contents: read + packages: read # pull the just-published image from GHCR + uses: ./.github/workflows/trivy-image-webapp.yml + with: + image-ref: ${{ needs.publish-webapp.outputs.image_repo }}:${{ needs.publish-webapp.outputs.version }} + + # Announce the freshly published mutable `main` webapp image to subscriber + # repos via repository_dispatch, handing them a digest-pinned ref to build or + # deploy from. The repo, ref prefix, and dispatch target all default to the + # canonical values and can be overridden by repository variables. + # + # `push` only: release builds reach publish.yml via workflow_call (from + # release.yml) with an explicit image_tag while github.ref_name is still + # `main`, so gate on the event to avoid dispatching โ€” and failing on the + # absent CROSS_REPO_PAT โ€” during a release. + dispatch-main-image: + name: ๐Ÿ“ฃ Dispatch main image + needs: [publish-webapp] + if: github.repository == (vars.MAIN_IMAGE_DISPATCH_REPO || 'triggerdotdev/trigger.dev') && github.event_name == 'push' && startsWith(github.ref_name, vars.MAIN_IMAGE_DISPATCH_REF_PREFIX || 'main') + runs-on: warp-ubuntu-latest-x64-2x + permissions: {} + steps: + - name: Build dispatch payload + id: payload + env: + IMAGE_REPO: ${{ needs.publish-webapp.outputs.image_repo }} + DIGEST: ${{ needs.publish-webapp.outputs.digest }} + COMMIT: ${{ github.sha }} + run: | + set -euo pipefail + # Pin to the exact multi-arch index just pushed so subscribers resolve a + # single immutable artifact rather than chasing the moving `main` tag. + if [[ -z "${DIGEST}" ]]; then + echo "::error::publish-webapp produced no image digest; refusing to dispatch" + exit 1 + fi + image="${IMAGE_REPO}@${DIGEST}" + # jq --arg JSON-escapes every value, so the ref/commit can't break out of + # or inject into the client payload. + payload=$(jq -nc \ + --arg img "$image" \ + --arg c "$COMMIT" \ + '{image: $img, commit: $c}') + echo "client_payload=$payload" >> "$GITHUB_OUTPUT" + + - name: Send repository_dispatch + uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 + with: + token: ${{ secrets.CROSS_REPO_PAT }} + repository: ${{ vars.MAIN_IMAGE_DISPATCH_TARGET || 'triggerdotdev/cloud' }} + event-type: main-image-published + client-payload: ${{ steps.payload.outputs.client_payload }} diff --git a/.github/workflows/release-helm.yml b/.github/workflows/release-helm.yml new file mode 100644 index 00000000000..a5afd8b1b24 --- /dev/null +++ b/.github/workflows/release-helm.yml @@ -0,0 +1,163 @@ +name: ๐Ÿงญ Helm Chart Release + +on: + push: + tags: + - "helm-v*" + workflow_call: + inputs: + chart_version: + description: "Chart version to release" + required: true + type: string + workflow_dispatch: + inputs: + chart_version: + description: "Chart version to release" + required: true + type: string + +env: + REGISTRY: ghcr.io + CHART_NAME: trigger + +jobs: + lint-and-test: + runs-on: warp-ubuntu-latest-x64-2x + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up Helm + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + with: + version: "3.18.3" + + - name: Build dependencies + run: helm dependency build ./hosting/k8s/helm/ + + - name: Extract dependency charts + run: | + cd ./hosting/k8s/helm/ + for file in ./charts/*.tgz; do echo "Extracting $file"; tar -xzf "$file" -C ./charts; done + + - name: Lint Helm Chart + run: | + helm lint ./hosting/k8s/helm/ + + - name: Render templates + run: | + helm template test-release ./hosting/k8s/helm/ \ + --values ./hosting/k8s/helm/values.yaml \ + --output-dir ./helm-output + + - name: Validate manifests + uses: docker://ghcr.io/yannh/kubeconform:v0.7.0@sha256:85dbef6b4b312b99133decc9c6fc9495e9fc5f92293d4ff3b7e1b30f5611823c + with: + entrypoint: "/kubeconform" + args: "-summary -output json ./helm-output" + + release: + needs: lint-and-test + # Set the ENABLE_HELM_PRERELEASE repository variable to 'false' to turn off + # publishing the chart to GHCR โ€” e.g. forks/mirrors that lack write_package + # on the owner's charts namespace. Defaults to enabled; the lint-and-test + # job above always runs regardless. + if: ${{ vars.ENABLE_HELM_PRERELEASE != 'false' }} + runs-on: warp-ubuntu-latest-x64-2x + permissions: + contents: write # for gh-release + packages: write + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up Helm + uses: azure/setup-helm@9bc31f4ebc9c6b171d7bfbaa5d006ae7abdb4310 # v5.0.1 + with: + version: "3.18.3" + + - name: Build dependencies + run: helm dependency build ./hosting/k8s/helm/ + + - name: Extract dependency charts + run: | + cd ./hosting/k8s/helm/ + for file in ./charts/*.tgz; do echo "Extracting $file"; tar -xzf "$file" -C ./charts; done + + - name: Log in to Container Registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract version from tag or input + id: version + run: | + if [ -n "${INPUTS_CHART_VERSION}" ]; then + VERSION="${INPUTS_CHART_VERSION}" + else + VERSION="${GITHUB_REF_NAME}" + VERSION="${VERSION#helm-v}" + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "Releasing version: $VERSION" + env: + INPUTS_CHART_VERSION: ${{ inputs.chart_version }} + + - name: Check Chart.yaml version matches release version + run: | + VERSION="${STEPS_VERSION_OUTPUTS_VERSION}" + CHART_VERSION=$(grep '^version:' ./hosting/k8s/helm/Chart.yaml | awk '{print $2}') + echo "Chart.yaml version: $CHART_VERSION" + echo "Release version: $VERSION" + if [ "$CHART_VERSION" != "$VERSION" ]; then + echo "โŒ Chart.yaml version does not match release version!" + exit 1 + fi + echo "โœ… Chart.yaml version matches release version." + env: + STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} + + - name: Package Helm Chart + run: | + helm package ./hosting/k8s/helm/ --destination /tmp/ + + - name: Push Helm Chart to GHCR + run: | + VERSION="${STEPS_VERSION_OUTPUTS_VERSION}" + CHART_PACKAGE="/tmp/${{ env.CHART_NAME }}-${VERSION}.tgz" + + # Push to GHCR OCI registry + helm push "$CHART_PACKAGE" "oci://${{ env.REGISTRY }}/${{ github.repository_owner }}/charts" + env: + STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} + + - name: Create GitHub Release + id: release + uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3.0.1 + with: + tag_name: helm-v${{ steps.version.outputs.version }} + name: "Helm Chart ${{ steps.version.outputs.version }}" + body: | + ### Installation + ```bash + helm upgrade --install trigger \ + oci://${{ env.REGISTRY }}/${{ github.repository_owner }}/charts/${{ env.CHART_NAME }} \ + --version "${{ steps.version.outputs.version }}" + ``` + + ### Changes + See commit history for detailed changes in this release. + files: | + /tmp/${{ env.CHART_NAME }}-${{ steps.version.outputs.version }}.tgz + token: ${{ secrets.GITHUB_TOKEN }} + draft: true + prerelease: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000000..5292903562e --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,343 @@ +name: ๐Ÿฆ‹ Changesets Release + +on: + pull_request: + types: [closed] + branches: + - main + workflow_dispatch: + inputs: + type: + description: "Select release type" + required: true + type: choice + options: + - release + - prerelease + default: "prerelease" + ref: + description: "The ref (branch, tag, or SHA) to checkout and release from" + required: true + type: string + prerelease_tag: + description: "The npm dist-tag for the prerelease (e.g., 'v4-prerelease')" + required: false + type: string + default: "prerelease" + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + show-release-summary: + name: ๐Ÿ“‹ Release Summary + runs-on: ubuntu-latest + permissions: {} + if: | + github.repository == 'triggerdotdev/trigger.dev' && + github.event_name == 'pull_request' && + github.event.pull_request.merged == true && + github.event.pull_request.head.ref == 'changeset-release/main' + steps: + - name: Show release summary + env: + PR_BODY: ${{ github.event.pull_request.body }} + run: | + echo "$PR_BODY" | sed -n '/^# Releases/,$p' >> "$GITHUB_STEP_SUMMARY" + + release: + name: ๐Ÿš€ Release npm packages + runs-on: ubuntu-latest # this cannot run on non-GH runner + environment: npm-publish + permissions: + contents: write + packages: write + id-token: write + if: | + github.repository == 'triggerdotdev/trigger.dev' && + ( + (github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'release') || + (github.event_name == 'pull_request' && github.event.pull_request.merged == true && github.event.pull_request.head.ref == 'changeset-release/main') + ) + outputs: + published: ${{ steps.changesets.outputs.published }} + published_packages: ${{ steps.changesets.outputs.publishedPackages }} + published_package_version: ${{ steps.get_version.outputs.package_version }} + is_prerelease: ${{ steps.get_version.outputs.is_prerelease }} + steps: + - name: Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 # zizmor: ignore[artipacked] needs persisted git creds for tag push; no artifact upload here so no leak path + with: + fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.ref || github.sha }} + + - name: Verify ref is on main + if: github.event_name == 'workflow_dispatch' + run: | + if ! git merge-base --is-ancestor "${GITHUB_EVENT_INPUTS_REF}" origin/main; then + echo "Error: ref must be an ancestor of main (i.e., already merged)" + exit 1 + fi + env: + GITHUB_EVENT_INPUTS_REF: ${{ github.event.inputs.ref }} + + - 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: 24.18.0 + cache: "pnpm" + + # npm v11.5.1 or newer is required for OIDC support + # https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new + - name: Setup npm 11.x for OIDC + run: npm install -g npm@11.6.4 + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Generate Prisma client + run: pnpm run generate + + - name: Build + run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev" + + - name: Type check + run: pnpm run typecheck --filter "@trigger.dev/*" --filter "trigger.dev" + + - name: Publish + id: changesets + uses: changesets/action@a45c4d594aa4e2c509dc14a9f2b3b67ba3780d0d # v1.9.0 + with: + publish: pnpm run changeset:release + createGithubReleases: false + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Show package version + if: steps.changesets.outputs.published == 'true' + id: get_version + run: | + package_version=$(echo "${STEPS_CHANGESETS_OUTPUTS_PUBLISHEDPACKAGES}" | jq -r '.[0].version') + echo "package_version=${package_version}" >> "$GITHUB_OUTPUT" + # Any semver with a hyphen is a prerelease (e.g. 4.5.0-rc.0, 0.0.0-snapshot-...) + if [[ "${package_version}" == *-* ]]; then + echo "is_prerelease=true" >> "$GITHUB_OUTPUT" + else + echo "is_prerelease=false" >> "$GITHUB_OUTPUT" + fi + env: + STEPS_CHANGESETS_OUTPUTS_PUBLISHEDPACKAGES: ${{ steps.changesets.outputs.publishedPackages }} + + - name: Create unified GitHub release + if: steps.changesets.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_PR_BODY: ${{ github.event.pull_request.body }} + STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION: ${{ steps.get_version.outputs.package_version }} + STEPS_GET_VERSION_OUTPUTS_IS_PRERELEASE: ${{ steps.get_version.outputs.is_prerelease }} + run: | + VERSION="${STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION}" + + # On the pull_request merge event RELEASE_PR_BODY is the merged "Version Packages" + # PR body, which holds the changelog. On a workflow_dispatch re-run (the recovery + # path after a failed merge-triggered run) there is no pull_request context, so it's + # empty. Fall back to the body of the most recently merged changeset-release/main PR + # so the "What's changed" section is still populated. + if [ -z "${RELEASE_PR_BODY}" ]; then + echo "RELEASE_PR_BODY is empty (workflow_dispatch re-run); fetching merged changeset-release/main PR body" + RELEASE_PR_BODY="$(gh pr list --repo triggerdotdev/trigger.dev \ + --head changeset-release/main --state merged \ + --json body,mergedAt --limit 10 \ + -q 'sort_by(.mergedAt) | reverse | .[0].body')" + fi + export RELEASE_PR_BODY + + node scripts/generate-github-release.mjs "$VERSION" > /tmp/release-body.md + PRERELEASE_FLAG="" + if [ "${STEPS_GET_VERSION_OUTPUTS_IS_PRERELEASE}" = "true" ]; then + PRERELEASE_FLAG="--prerelease" + fi + gh release create "v${VERSION}" \ + --title "trigger.dev v${VERSION}" \ + --notes-file /tmp/release-body.md \ + --target main \ + $PRERELEASE_FLAG + + - name: Create and push Docker tag + if: steps.changesets.outputs.published == 'true' + run: | + set -e + git tag "v.docker.${STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION}" + git push origin "v.docker.${STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION}" + env: + STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION: ${{ steps.get_version.outputs.package_version }} + + - name: Create and push Helm chart tag + if: steps.changesets.outputs.published == 'true' + run: | + set -e + git tag "helm-v${STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION}" + git push origin "helm-v${STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION}" + env: + STEPS_GET_VERSION_OUTPUTS_PACKAGE_VERSION: ${{ steps.get_version.outputs.package_version }} + + # Trigger Docker builds directly via workflow_call since tags pushed with + # GITHUB_TOKEN don't trigger other workflows (GitHub Actions limitation). + publish-docker: + name: ๐Ÿณ Publish Docker images + needs: release + if: needs.release.outputs.published == 'true' + permissions: + contents: read + packages: write + id-token: write + attestations: write + uses: ./.github/workflows/publish.yml + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + with: + image_tag: v${{ needs.release.outputs.published_package_version }} + + # Trigger Helm chart release directly via workflow_call (same GITHUB_TOKEN + # limitation as the Docker path). Runs after Docker images are published so + # the chart never references images that don't exist yet. + publish-helm: + name: ๐Ÿงญ Publish Helm chart + needs: [release, publish-docker] + if: needs.release.outputs.published == 'true' + permissions: + contents: write + packages: write + uses: ./.github/workflows/release-helm.yml + with: + chart_version: ${{ needs.release.outputs.published_package_version }} + + # After Docker images are published, update the GitHub release with the exact GHCR tag URL. + # The GHCR package version ID is only known after the image is pushed, so we query for it here. + update-release: + name: ๐Ÿ”— Update release Docker link + needs: [release, publish-docker] + if: needs.release.outputs.published == 'true' + runs-on: warp-ubuntu-latest-x64-2x + permissions: + contents: write + packages: read + steps: + - name: Update GitHub release with Docker image link + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + NEEDS_RELEASE_OUTPUTS_PUBLISHED_PACKAGE_VERSION: ${{ needs.release.outputs.published_package_version }} + run: | + set -e + VERSION="${NEEDS_RELEASE_OUTPUTS_PUBLISHED_PACKAGE_VERSION}" + TAG="v${VERSION}" + + # Query GHCR for the version ID matching this tag + VERSION_ID=$(gh api --paginate -H "Accept: application/vnd.github+json" \ + /orgs/triggerdotdev/packages/container/trigger.dev/versions \ + --jq ".[] | select(.metadata.container.tags[] == \"${TAG}\") | .id" \ + | head -1) + + if [ -z "$VERSION_ID" ]; then + echo "Warning: Could not find GHCR version ID for tag ${TAG}, skipping update" + exit 0 + fi + + DOCKER_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev/${VERSION_ID}?tag=${TAG}" + GENERIC_URL="https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev" + + # Get current release body and replace the generic link with the tag-specific one. + # Use word boundary after GENERIC_URL (closing paren) to avoid matching URLs that + # already have a version ID appended (idempotent on re-runs). + gh release view "${TAG}" --repo triggerdotdev/trigger.dev --json body --jq '.body' > /tmp/release-body.md + sed -i "s|${GENERIC_URL})|${DOCKER_URL})|g" /tmp/release-body.md + + gh release edit "${TAG}" --repo triggerdotdev/trigger.dev --notes-file /tmp/release-body.md + + # Dispatch changelog entry creation to the marketing site repo. + # Runs after update-release so the GitHub release body already has the exact Docker image URL. + dispatch-changelog: + name: ๐Ÿ“ Dispatch changelog PR + needs: [release, update-release] + if: needs.release.outputs.published == 'true' && needs.release.outputs.is_prerelease != 'true' + runs-on: warp-ubuntu-latest-x64-2x + permissions: {} + steps: + - uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4.0.1 + with: + token: ${{ secrets.CROSS_REPO_PAT }} + repository: triggerdotdev/trigger.dev-site-v3 + event-type: new-release + client-payload: '{"version": "${{ needs.release.outputs.published_package_version }}"}' + + # The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims. + prerelease: + name: ๐Ÿงช Prerelease + runs-on: ubuntu-latest # this cannot run on non-GH runner + environment: npm-publish + permissions: + contents: read + id-token: write + if: github.repository == 'triggerdotdev/trigger.dev' && github.event_name == 'workflow_dispatch' && github.event.inputs.type == 'prerelease' + steps: + - name: Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + ref: ${{ github.event.inputs.ref }} + 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: 24.18.0 + cache: "pnpm" + + # npm v11.5.1 or newer is required for OIDC support + # https://github.blog/changelog/2025-07-31-npm-trusted-publishing-with-oidc-is-generally-available/#whats-new + - name: Setup npm 11.x for OIDC + run: npm install -g npm@11.6.4 + + - name: Download deps + run: pnpm install --frozen-lockfile + + - name: Generate Prisma Client + run: pnpm run generate + + - name: Exit changeset pre mode (if active) + run: | + if [ -f .changeset/pre.json ]; then + echo "Repo is in changeset pre mode; exiting so snapshot release can run" + pnpm exec changeset pre exit + fi + + - name: Snapshot version + run: pnpm exec changeset version --snapshot "${GITHUB_EVENT_INPUTS_PRERELEASE_TAG}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_EVENT_INPUTS_PRERELEASE_TAG: ${{ github.event.inputs.prerelease_tag }} + + - name: Clean + run: pnpm run clean --filter "@trigger.dev/*" --filter "trigger.dev" + + - name: Build + run: pnpm run build --filter "@trigger.dev/*" --filter "trigger.dev" + + - name: Publish prerelease + run: pnpm exec changeset publish --no-git-tag --snapshot --tag "${GITHUB_EVENT_INPUTS_PRERELEASE_TAG}" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_EVENT_INPUTS_PRERELEASE_TAG: ${{ github.event.inputs.prerelease_tag }} diff --git a/.github/workflows/runops-guard.yml b/.github/workflows/runops-guard.yml new file mode 100644 index 00000000000..e496db2a93d --- /dev/null +++ b/.github/workflows/runops-guard.yml @@ -0,0 +1,38 @@ +name: "๐Ÿ›ก๏ธ Run-ops Legacy Guard" + +on: + workflow_call: + +permissions: + contents: read + +jobs: + runops-guard: + runs-on: warp-ubuntu-latest-x64-16x + + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + 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: 24.18.0 + cache: "pnpm" + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿ›ก๏ธ Run-ops legacy guard + run: pnpm --filter webapp run guard:runops-legacy -- --check diff --git a/.github/workflows/sdk-compat.yml b/.github/workflows/sdk-compat.yml new file mode 100644 index 00000000000..ddfb4731db1 --- /dev/null +++ b/.github/workflows/sdk-compat.yml @@ -0,0 +1,182 @@ +name: "๐Ÿ”Œ SDK Compatibility Tests" + +permissions: + contents: read + +on: + workflow_call: + +jobs: + node-compat: + name: "Node.js ${{ matrix.node }} (${{ matrix.os }})" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [warp-ubuntu-latest-x64-4x] + node: ["20.20", "22.23", "24.18", "26.4"] + + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + 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: ${{ matrix.node }} + cache: "pnpm" + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿ”จ Build SDK dependencies + shell: bash + run: pnpm run build --filter '@trigger.dev/sdk^...' + + - name: ๐Ÿ”จ Build SDK + shell: bash + run: pnpm run build --filter '@trigger.dev/sdk' + + - name: ๐Ÿงช Run SDK Compatibility Tests + shell: bash + run: pnpm --filter @internal/sdk-compat-tests test + + bun-compat: + name: "Bun Runtime" + runs-on: warp-ubuntu-latest-x64-4x + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + 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: 24.18.0 + cache: "pnpm" + + - name: ๐ŸฅŸ Setup Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿ”จ Build SDK dependencies + run: pnpm run build --filter @trigger.dev/sdk^... + + - name: ๐Ÿ”จ Build SDK + run: pnpm run build --filter @trigger.dev/sdk + + - name: ๐Ÿงช Run Bun Compatibility Test + working-directory: internal-packages/sdk-compat-tests/src/fixtures/bun + run: bun run test.ts + + deno-compat: + name: "Deno Runtime" + runs-on: warp-ubuntu-latest-x64-4x + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + 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: 24.18.0 + cache: "pnpm" + + - name: ๐Ÿฆ• Setup Deno + uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 + with: + deno-version: v2.x + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿ”จ Build SDK dependencies + run: pnpm run build --filter @trigger.dev/sdk^... + + - name: ๐Ÿ”จ Build SDK + run: pnpm run build --filter @trigger.dev/sdk + + - name: ๐Ÿ”— Link node_modules for Deno fixture + working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno + run: ln -s ../../../../../node_modules node_modules + + - name: ๐Ÿงช Run Deno Compatibility Test + working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno + run: deno run --allow-read --allow-env --allow-sys test.ts + + cloudflare-compat: + name: "Cloudflare Workers" + runs-on: warp-ubuntu-latest-x64-4x + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + 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: 24.18.0 + cache: "pnpm" + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿ”จ Build SDK dependencies + run: pnpm run build --filter @trigger.dev/sdk^... + + - name: ๐Ÿ”จ Build SDK + run: pnpm run build --filter @trigger.dev/sdk + + - name: ๐Ÿ“ฅ Install Cloudflare fixture deps + working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker + run: pnpm install + + - name: ๐Ÿงช Run Cloudflare Workers Compatibility Test (dry-run) + working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker + run: npx wrangler deploy --dry-run --outdir dist diff --git a/.github/workflows/trivy-image-webapp.yml b/.github/workflows/trivy-image-webapp.yml new file mode 100644 index 00000000000..2307b5a6b4f --- /dev/null +++ b/.github/workflows/trivy-image-webapp.yml @@ -0,0 +1,75 @@ +name: Trivy Image Scan (webapp) + +# OS-level CVE scan of a published webapp image. Called by the publish pipeline +# (publish.yml) to scan each build right after it's pushed to GHCR โ€” so every +# main build and every release is scanned, not rebuilt. Also runnable ad-hoc +# via workflow_dispatch against any image ref. +# +# Report-only: writes a table to the run summary. No SARIF upload, no gate. +# Library/dependency CVEs are covered by Dependabot, so this is restricted to +# OS packages (`vuln-type: os`) to avoid double-reporting. + +on: + workflow_call: + inputs: + image-ref: + description: "Full image ref to scan (e.g. ghcr.io/triggerdotdev/trigger.dev:main)" + type: string + required: true + workflow_dispatch: + inputs: + image-ref: + description: "Full image ref to scan" + type: string + required: false + default: "ghcr.io/triggerdotdev/trigger.dev:main" + +permissions: {} + +concurrency: + group: trivy-image-webapp-${{ inputs.image-ref }} + cancel-in-progress: true + +jobs: + scan: + name: Scan + runs-on: warp-ubuntu-latest-x64-2x + permissions: + contents: read + packages: read # pull the image from GHCR + steps: + # Authenticate to GHCR so the scan also works for private images + # (GITHUB_TOKEN isn't forwarded to Docker automatically). Harmless for + # public images. Pairs with the packages: read permission above. + - name: Log in to GitHub Container Registry + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Run Trivy image scan + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: image + image-ref: ${{ inputs.image-ref }} + # vuln-type maps to --pkg-types: OS packages only (library deps are + # Dependabot's job). ignore-unfixed drops vulns with no patch yet. + vuln-type: os + ignore-unfixed: true + severity: HIGH,CRITICAL + format: table + output: trivy-image-webapp.txt + + - name: Job summary + if: always() + env: + IMAGE_REF: ${{ inputs.image-ref }} + run: | + { + echo "## Trivy Image Scan (webapp) โ€” \`${IMAGE_REF}\`" + echo '```' + # GitHub step summary is capped at 1 MiB; truncate large reports. + head -c 900000 trivy-image-webapp.txt 2>/dev/null || echo "(no report produced)" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 00000000000..d8521f3936a --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,43 @@ +name: "สฆ TypeScript" + +on: + workflow_call: + +permissions: + contents: read + +jobs: + typecheck: + runs-on: warp-ubuntu-latest-x64-16x + + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + 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: 24.18.0 + cache: "pnpm" + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿ”Ž Type check + run: pnpm run typecheck + env: + NODE_OPTIONS: --max-old-space-size=8192 + + - name: ๐Ÿ”Ž Check exports + run: pnpm run check-exports diff --git a/.github/workflows/unit-tests-internal.yml b/.github/workflows/unit-tests-internal.yml new file mode 100644 index 00000000000..1a398af9c44 --- /dev/null +++ b/.github/workflows/unit-tests-internal.yml @@ -0,0 +1,146 @@ +name: "๐Ÿงช Unit Tests: Internal" + +permissions: + contents: read + +on: + workflow_call: + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false + +jobs: + unitTests: + name: "๐Ÿงช Unit Tests: Internal" + # Single big machine instead of a 12-job matrix: the internal suites are serial + # (fileParallelism: false) and container-wait-bound, so 12 in-machine shard processes + # fit comfortably in 32 vCPUs while paying the setup cost (install, prisma generate, + # image pulls) once instead of 12 times. + runs-on: warp-ubuntu-latest-x64-32x + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + SHARD_TOTAL: 12 + 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 1 + 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: 24.18.0 + cache: "pnpm" + + # ..to avoid rate limits when pulling images + - name: ๐Ÿณ Login to DockerHub + if: ${{ env.DOCKERHUB_USERNAME }} + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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: | + # Retry each pull - DockerHub registry timeouts are a recurring transient CI flake. + pull() { + for attempt in 1 2 3; do + docker pull "$1" && return 0 + echo "::warning::docker pull $1 failed (attempt ${attempt}/3); retrying in 10s" + sleep 10 + done + echo "::error::docker pull $1 failed after 3 attempts" + return 1 + } + echo "Pre-pulling Docker images with authenticated session..." + pull postgres:14 + pull postgres:17 # for the heterogeneous PG14/17 test fixture gate (heteroPostgresTest) + pull clickhouse/clickhouse-server:26.2.19.43-alpine@sha256:c6ad6a7eb2fb5999df3adfb8b69a0c7222c68fa9b8f6b04a088564ebbc959251 + pull redis:7.2 + pull testcontainers/ryuk:0.14.0 + pull electricsql/electric:1.2.4 + echo "Image pre-pull complete" + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿ—๏ธ Build test dependencies + # Build once up-front so the parallel shard runs below (turbo --only) never race + # to build or cache-restore the same outputs concurrently. + run: pnpm exec turbo run build --filter "@internal/*..." + + - name: ๐Ÿงช Run Internal Unit Tests (${{ env.SHARD_TOTAL }} in-machine shards) + run: | + # Same shard partitioning as the old 12-job matrix (DurationShardingSequencer + # keys off --shard=i/N), but as parallel local processes. --only skips the + # ^build dependency handled by the step above. + status=0 + declare -a pids + for i in $(seq 1 "$SHARD_TOTAL"); do + pnpm exec turbo run test --only --concurrency=1 --filter "@internal/*" -- \ + --run --reporter=default --reporter=blob --shard="$i/$SHARD_TOTAL" --passWithNoTests \ + > "/tmp/internal-shard-$i.log" 2>&1 & + pids[i]=$! + done + for i in $(seq 1 "$SHARD_TOTAL"); do + if ! wait "${pids[i]}"; then + status=1 + echo "::error::internal unit test shard $i/$SHARD_TOTAL failed" + fi + echo "::group::๐Ÿงช shard $i/$SHARD_TOTAL" + cat "/tmp/internal-shard-$i.log" + echo "::endgroup::" + done + exit "$status" + + - name: Gather all reports + if: ${{ !cancelled() }} + run: | + mkdir -p .vitest-reports + find . -type f -path '*/.vitest-reports/blob-*.json' \ + -exec bash -c 'src="$1"; basename=$(basename "$src"); pkg=$(dirname "$src" | sed "s|^\./||;s|/\.vitest-reports$||;s|/|_|g"); cp "$src" ".vitest-reports/${pkg}-${basename}"' _ {} \; + + - name: ๐Ÿ“Š Merge reports + if: ${{ !cancelled() }} + run: pnpm dlx vitest@4.1.7 run --merge-reports --pass-with-no-tests diff --git a/.github/workflows/unit-tests-packages.yml b/.github/workflows/unit-tests-packages.yml new file mode 100644 index 00000000000..465174fb95d --- /dev/null +++ b/.github/workflows/unit-tests-packages.yml @@ -0,0 +1,160 @@ +name: "๐Ÿงช Unit Tests: Packages" + +permissions: + contents: read + +on: + workflow_call: + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false + +jobs: + unitTests: + name: "๐Ÿงช Unit Tests: Packages" + runs-on: warp-ubuntu-latest-x64-4x + strategy: + # one flaky shard shouldn't cancel its siblings - lets us re-run only the failed shard + fail-fast: false + matrix: + shardIndex: [1, 2, 3] + shardTotal: [3] + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + SHARD_INDEX: ${{ matrix.shardIndex }} + SHARD_TOTAL: ${{ matrix.shardTotal }} + 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 1 + 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: 24.18.0 + cache: "pnpm" + + # ..to avoid rate limits when pulling images + - name: ๐Ÿณ Login to DockerHub + if: ${{ env.DOCKERHUB_USERNAME }} + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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: | + # Retry each pull - DockerHub registry timeouts are a recurring transient CI flake. + pull() { + for attempt in 1 2 3; do + docker pull "$1" && return 0 + echo "::warning::docker pull $1 failed (attempt ${attempt}/3); retrying in 10s" + sleep 10 + done + echo "::error::docker pull $1 failed after 3 attempts" + return 1 + } + echo "Pre-pulling Docker images with authenticated session..." + pull postgres:14 + pull clickhouse/clickhouse-server:26.2.19.43-alpine@sha256:c6ad6a7eb2fb5999df3adfb8b69a0c7222c68fa9b8f6b04a088564ebbc959251 + pull redis:7.2 + pull testcontainers/ryuk:0.14.0 + pull electricsql/electric:1.2.4 + echo "Image pre-pull complete" + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿงช Run Package Unit Tests + run: pnpm run test:packages --reporter=default --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --passWithNoTests + + - name: Gather all reports + if: ${{ !cancelled() }} + run: | + mkdir -p .vitest-reports + find . -type f -path '*/.vitest-reports/blob-*.json' \ + -exec bash -c 'src="$1"; basename=$(basename "$src"); pkg=$(dirname "$src" | sed "s|^\./||;s|/\.vitest-reports$||;s|/|_|g"); cp "$src" ".vitest-reports/${pkg}-${basename}"' _ {} \; + + - name: Upload blob reports to GitHub Actions Artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: packages-blob-report-${{ matrix.shardIndex }} + path: .vitest-reports/* + include-hidden-files: true + retention-days: 1 + + merge-reports: + name: "๐Ÿ“Š Merge Reports" + if: ${{ !cancelled() }} + needs: [unitTests] + runs-on: warp-ubuntu-latest-x64-2x + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 1 + 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: 24.18.0 + # no cache enabled, we're not installing deps + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: .vitest-reports + pattern: packages-blob-report-* + merge-multiple: true + + - name: Merge reports + run: pnpm dlx vitest@4.1.7 run --merge-reports --pass-with-no-tests diff --git a/.github/workflows/unit-tests-webapp.yml b/.github/workflows/unit-tests-webapp.yml new file mode 100644 index 00000000000..680eafd9aa1 --- /dev/null +++ b/.github/workflows/unit-tests-webapp.yml @@ -0,0 +1,174 @@ +name: "๐Ÿงช Unit Tests: Webapp" + +permissions: + contents: read + +on: + workflow_call: + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false + +jobs: + unitTests: + name: "๐Ÿงช Unit Tests: Webapp" + # 10 shards on 16x machines: webapp test throughput is limited per-machine (one + # docker daemon + disk absorbing all the per-file Postgres/ClickHouse container + # spin-up), so many machines beats few big ones - fewer/bigger (3x32) measured + # SLOWER than 10x8. The 16x (vs 8x) gives the fork pool the CPU headroom the 8x + # runners lacked. Setup overhead per machine is ~1 min on warm runners. + runs-on: warp-ubuntu-latest-x64-16x + strategy: + # one flaky shard shouldn't cancel its siblings - lets us re-run only the failed shard + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] + shardTotal: [12] + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + SHARD_INDEX: ${{ matrix.shardIndex }} + SHARD_TOTAL: ${{ matrix.shardTotal }} + 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 1 + 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: 24.18.0 + cache: "pnpm" + + # ..to avoid rate limits when pulling images + - name: ๐Ÿณ Login to DockerHub + if: ${{ env.DOCKERHUB_USERNAME }} + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.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: | + # Retry each pull - DockerHub registry timeouts are a recurring transient CI flake. + pull() { + for attempt in 1 2 3; do + docker pull "$1" && return 0 + echo "::warning::docker pull $1 failed (attempt ${attempt}/3); retrying in 10s" + sleep 10 + done + echo "::error::docker pull $1 failed after 3 attempts" + return 1 + } + echo "Pre-pulling Docker images with authenticated session..." + pull postgres:14 + pull clickhouse/clickhouse-server:26.2.19.43-alpine@sha256:c6ad6a7eb2fb5999df3adfb8b69a0c7222c68fa9b8f6b04a088564ebbc959251 + pull redis:7.2 + pull testcontainers/ryuk:0.14.0 + pull electricsql/electric:1.2.4 + pull minio/minio:latest + echo "Image pre-pull complete" + + - name: ๐Ÿ“ฅ Download deps + run: pnpm install --frozen-lockfile + + - name: ๐Ÿ“€ Generate Prisma Client + run: pnpm run generate + + - name: ๐Ÿงช Run Webapp Unit Tests + run: pnpm run test:webapp --reporter=default --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --passWithNoTests + env: + DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres + DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres + SESSION_SECRET: "secret" + MAGIC_LINK_SECRET: "secret" + ENCRYPTION_KEY: "dummy-encryption-keeeey-32-bytes" + DEPLOY_REGISTRY_HOST: "docker.io" + CLICKHOUSE_URL: "http://default:password@localhost:8123" + + - name: Gather all reports + if: ${{ !cancelled() }} + run: | + mkdir -p .vitest-reports + find . -type f -path '*/.vitest-reports/blob-*.json' \ + -exec bash -c 'src="$1"; basename=$(basename "$src"); pkg=$(dirname "$src" | sed "s|^\./||;s|/\.vitest-reports$||;s|/|_|g"); cp "$src" ".vitest-reports/${pkg}-${basename}"' _ {} \; + + - name: Upload blob reports to GitHub Actions Artifacts + if: ${{ !cancelled() }} + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: webapp-blob-report-${{ matrix.shardIndex }} + path: .vitest-reports/* + include-hidden-files: true + retention-days: 1 + + merge-reports: + name: "๐Ÿ“Š Merge Reports" + if: ${{ !cancelled() }} + needs: [unitTests] + runs-on: warp-ubuntu-latest-x64-2x + steps: + - name: โฌ‡๏ธ Checkout repo + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 1 + 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: 24.18.0 + # no cache enabled, we're not installing deps + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: .vitest-reports + pattern: webapp-blob-report-* + merge-multiple: true + + - name: Merge reports + run: pnpm dlx vitest@4.1.7 run --merge-reports --pass-with-no-tests diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml new file mode 100644 index 00000000000..96e76279c82 --- /dev/null +++ b/.github/workflows/unit-tests.yml @@ -0,0 +1,34 @@ +name: "๐Ÿงช Unit Tests" + +permissions: + contents: read + +on: + workflow_call: + secrets: + DOCKERHUB_USERNAME: + required: false + DOCKERHUB_TOKEN: + required: false + +jobs: + webapp: + uses: ./.github/workflows/unit-tests-webapp.yml + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + e2e-webapp: + uses: ./.github/workflows/e2e-webapp.yml + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + packages: + uses: ./.github/workflows/unit-tests-packages.yml + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + internal: + uses: ./.github/workflows/unit-tests-internal.yml + secrets: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml new file mode 100644 index 00000000000..fab876451b6 --- /dev/null +++ b/.github/workflows/vouch-check-pr.yml @@ -0,0 +1,50 @@ +name: Vouch - Check PR + +on: + pull_request_target: # zizmor: ignore[dangerous-triggers] needed to comment/close fork PRs; safe because we never check out PR HEAD ref so no fork-controlled code runs + types: [opened, reopened] + +permissions: {} + +jobs: + check-vouch: + runs-on: warp-ubuntu-latest-x64-2x + permissions: + contents: read + pull-requests: write # auto-close unvouched PRs + issues: read + steps: + - uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + with: + pr-number: ${{ github.event.pull_request.number }} + auto-close: true + require-vouch: true + env: + GH_TOKEN: ${{ github.token }} + + require-draft: + needs: check-vouch + permissions: + pull-requests: write # close non-draft PRs with a comment + if: > + github.event.pull_request.draft == false && + github.event.pull_request.author_association != 'MEMBER' && + github.event.pull_request.author_association != 'OWNER' && + github.event.pull_request.author_association != 'COLLABORATOR' && + github.event.pull_request.user.login != 'devin-ai-integration[bot]' && + github.event.pull_request.user.login != 'dependabot[bot]' && + github.event.pull_request.user.login != 'github-actions[bot]' + runs-on: warp-ubuntu-latest-x64-2x + steps: + - name: Close non-draft PR + env: + GH_TOKEN: ${{ github.token }} + run: | + STATE=$(gh pr view ${{ github.event.pull_request.number }} --repo ${{ github.repository }} --json state -q '.state') + if [ "$STATE" != "OPEN" ]; then + echo "PR is already closed, skipping." + exit 0 + fi + gh pr close ${{ github.event.pull_request.number }} \ + --repo ${{ github.repository }} \ + --comment "Thanks for your contribution! We require all external PRs to be opened in **draft** status first so you can address CodeRabbit review comments and ensure CI passes before requesting a review. Please re-open this PR as a draft. See [CONTRIBUTING.md](https://github.com/${{ github.repository }}/blob/main/CONTRIBUTING.md#pr-workflow) for details." diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml new file mode 100644 index 00000000000..29d9584cabd --- /dev/null +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -0,0 +1,24 @@ +name: Vouch - Manage by Issue + +on: + issue_comment: + types: [created] + +permissions: + contents: write + issues: write + +jobs: + manage: + runs-on: warp-ubuntu-latest-x64-2x + if: >- + contains(github.event.comment.body, 'vouch') || + contains(github.event.comment.body, 'denounce') || + contains(github.event.comment.body, 'unvouch') + steps: + - uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 + with: + comment-id: ${{ github.event.comment.id }} + issue-id: ${{ github.event.issue.number }} + env: + GH_TOKEN: ${{ github.token }} diff --git a/.github/workflows/workflow-checks.yml b/.github/workflows/workflow-checks.yml new file mode 100644 index 00000000000..40843deb2ea --- /dev/null +++ b/.github/workflows/workflow-checks.yml @@ -0,0 +1,58 @@ +name: Workflow Checks + +on: + push: + branches: [main] + paths: + - ".github/workflows/**" + - ".github/actions/**" + - ".github/zizmor.yml" + - ".github/actionlint.yaml" + pull_request: + paths: + - ".github/workflows/**" + - ".github/actions/**" + - ".github/zizmor.yml" + - ".github/actionlint.yaml" + +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + actionlint: + name: Actionlint + runs-on: warp-ubuntu-latest-x64-2x + permissions: + contents: read + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Run actionlint + uses: docker://rhysd/actionlint:1.7.12@sha256:b1934ee5f1c509618f2508e6eb47ee0d3520686341fec936f3b79331f9315667 + + zizmor: + name: Zizmor + # Uploads SARIF to the Security tab, which requires GitHub code scanning to be + # enabled on the repository. Set the ENABLE_WORKFLOW_SECURITY_SCAN repository + # variable to 'false' to skip this job where code scanning isn't available; + # leave it unset (the default) to run the scan. + if: ${{ vars.ENABLE_WORKFLOW_SECURITY_SCAN != 'false' }} + runs-on: warp-ubuntu-latest-x64-2x + permissions: + security-events: write # Upload SARIF to GitHub Security tab + contents: read # Read workflow files for analysis + actions: read # Read workflow run metadata + steps: + - name: Checkout repository + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Run zizmor + uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 diff --git a/.github/zizmor.yml b/.github/zizmor.yml new file mode 100644 index 00000000000..e7920e8cf17 --- /dev/null +++ b/.github/zizmor.yml @@ -0,0 +1,5 @@ +rules: + unpinned-uses: + config: + policies: + "*": hash-pin diff --git a/.gitignore b/.gitignore index 8a5b197813b..c1fe3103332 100644 --- a/.gitignore +++ b/.gitignore @@ -12,10 +12,12 @@ coverage # next.js .next/ out/ -build dist packages/**/dist +# vendored bundles (generated during build) +packages/**/src/**/vendor + # Tailwind apps/**/styles/tailwind.css packages/**/styles/tailwind.css @@ -30,12 +32,10 @@ yarn-debug.log* yarn-error.log* # local env files -.env.docker +.env +.env.* .docker/*.env -.env.local -.env.development.local -.env.test.local -.env.production.local +!.env.example # turbo .turbo @@ -45,4 +45,34 @@ yarn-error.log* .output apps/**/public/build .tests-container-id.txt -.sentryclirc \ No newline at end of file +.sentryclirc +.buildt + +**/tmp/ +/test-results/ +/playwright-report/ +/playwright/.cache/ + +.cosine +.trigger +.tshy* +.yarn +*.tsbuildinfo +/packages/cli-v3/src/package.json +.husky +/packages/react-hooks/src/package.json +/packages/core/src/package.json +/packages/trigger-sdk/src/package.json +/packages/python/src/package.json +**/.claude/settings.local.json +.claude/architecture/ +.claude/docs-plans/ +.claude/plans/ +.claude/review-guides/ +.claude/scheduled_tasks.lock +.mcp.log +.mcp.json +.cursor/debug.log +ailogger-output.log +# per-package vitest timing capture (transient; merged into root test-timings.json) +.vitest-timing.json diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000000..ecf08cb1a4d --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "internal-packages/otlp-importer/protos"] + path = internal-packages/otlp-importer/protos + url = https://github.com/open-telemetry/opentelemetry-proto.git diff --git a/.infisical.json b/.infisical.json new file mode 100755 index 00000000000..7668c8c4c8e --- /dev/null +++ b/.infisical.json @@ -0,0 +1 @@ +{ "workspaceId": "63e5e42daf9a537ba8d9503c" } diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 4c679147a28..00000000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -link-workspace-packages=false \ No newline at end of file diff --git a/.nvmrc b/.nvmrc index fb457f39d53..5bcf9c6e6ac 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16.19.0 +v24.18.0 diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000000..5ebb5a4638a --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,32 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "semi": true, + "singleQuote": false, + "jsxSingleQuote": false, + "trailingComma": "es5", + "bracketSpacing": true, + "bracketSameLine": false, + "printWidth": 100, + "tabWidth": 2, + "useTabs": false, + "sortPackageJson": false, + "ignorePatterns": [ + "node_modules", + ".env", + ".env.local", + "pnpm-lock.yaml", + "tailwind.css", + ".babelrc.json", + "**/.react-email/", + "**/storybook-static/", + "**/.changeset/", + "**/dist/", + "internal-packages/tsql/src/grammar/", + "internal-packages/llm-model-catalog/src/defaultPrices.ts", + "internal-packages/llm-model-catalog/src/modelCatalog.ts", + // Maybe turn these on in the future + "**/*.yaml", + "**/*.mdx", + "**/*.md" + ] +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000000..d9b4cb2171f --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,57 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "import", "react"], + "jsPlugins": [ + "./oxlint-plugins/no-thrown-unawaited-redirect.mjs", + "./oxlint-plugins/runops-residency.mjs" + ], + "ignorePatterns": [ + "**/dist/**", + "**/build/**", + "**/*.d.ts", + "**/seed.js", + "**/seedCloud.ts", + "**/populate.js", + "internal-packages/tsql/src/grammar/" + ], + "rules": { + "no-unused-vars": [ + "error", + { + "args": "none", + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_", + "caughtErrors": "all", + "caughtErrorsIgnorePattern": "^_", + "destructuredArrayIgnorePattern": "^_", + "ignoreRestSiblings": true + } + ], + "no-empty-pattern": "off", + "no-control-regex": "off", + "typescript/no-non-null-asserted-optional-chain": "off", + "no-unused-expressions": ["warn", { "allowShortCircuit": true, "allowTernary": true }], + "typescript/consistent-type-imports": "error", + "import/no-duplicates": "error", + "import/namespace": "off", + "react-hooks/exhaustive-deps": "off", + "react-hooks/rules-of-hooks": "off", + "trigger/no-thrown-unawaited-redirect": "error" + }, + "overrides": [ + { + "files": ["apps/webapp/app/**/*.ts", "apps/webapp/app/**/*.tsx"], + "rules": { + "trigger-runops/no-control-plane-run-graph-access": "error", + "trigger-runops/no-control-plane-in-runops-slot": "error" + } + }, + { + "files": ["apps/webapp/app/**/*.test.ts", "apps/webapp/app/**/*.test.tsx"], + "rules": { + "trigger-runops/no-control-plane-run-graph-access": "off", + "trigger-runops/no-control-plane-in-runops-slot": "off" + } + } + ] +} diff --git a/integrations/github/src/types.ts b/.server-changes/.gitkeep similarity index 100% rename from integrations/github/src/types.ts rename to .server-changes/.gitkeep diff --git a/.server-changes/README.md b/.server-changes/README.md new file mode 100644 index 00000000000..12815039aca --- /dev/null +++ b/.server-changes/README.md @@ -0,0 +1,95 @@ +# Server Changes + +This directory tracks changes to server-only components (webapp, supervisor, etc.) that are not captured by changesets. Changesets only track published npm packages โ€” server changes would otherwise go undocumented. + +## When to add a file + +**Server-only PRs**: If your PR only changes `apps/webapp/`, `apps/supervisor/`, or other server components (and does NOT change anything in `packages/`), add a `.server-changes/` file. + +**Mixed PRs** (both packages and server): Just add a changeset as usual. No `.server-changes/` file needed โ€” the changeset covers it. + +**Package-only PRs**: Just add a changeset as usual. + +## File format + +Create a markdown file with a descriptive name: + +``` +.server-changes/fix-batch-queue-stalls.md +``` + +With this format: + +```markdown +--- +area: webapp +type: fix +--- + +Speed up batch queue processing by removing stalls and fixing retry race +``` + +### Fields + +- **area** (required): `webapp` | `supervisor` +- **type** (required): `feature` | `fix` | `improvement` | `breaking` + +### Description + +The body text (below the frontmatter) is a one-line description of the change. Keep it concise โ€” it will appear in release notes. + +### Writing guidance + +These entries are public-facing - they ship verbatim in user-visible release notes. A few rules to keep them clean: + +- **Write for the user, not the reviewer.** Lead with what the user notices or has to do. If a reader who doesn't know the codebase can't tell what changed for them, rewrite it. +- **One sentence is usually enough.** The body is the bullet in the changelog. If you need a paragraph, you're probably describing the implementation rather than the change. +- **Describe behavior, not implementation.** Skip internal scopes, middleware names, library specifics, framework internals. Users care about what's different for them, not how it's wired. +- **Never name internal tools or infra.** Observability stacks, internal services, infra components, monitoring backends, CI surfaces, AWS specifics - none of these belong in user-facing notes. + +Before / after: + +- โŒ _"The image verification step now parses the manifest's layer media types and returns a new result the finalizer rejects."_ (describes the wiring; a user can't act on it) +- โœ… _"Deploying with an outdated CLI could produce an image that fails to start on every run. These deploys are now stopped before going live, with a message asking you to upgrade the CLI and re-deploy."_ (what the user sees and does) + +## Lifecycle + +1. Engineer adds a `.server-changes/` file in their PR +2. Files accumulate on `main` as PRs merge +3. The changeset release PR includes these in its summary +4. After the release merges, CI cleans up the consumed files + +## Examples + +**New feature:** + +```markdown +--- +area: webapp +type: feature +--- + +TRQL query language and the Query page +``` + +**Bug fix:** + +```markdown +--- +area: webapp +type: fix +--- + +Fix schedule limit counting for orgs with custom limits +``` + +**Improvement:** + +```markdown +--- +area: webapp +type: improvement +--- + +Use the replica for API auth queries to reduce primary load +``` diff --git a/.server-changes/attio-sync-transient-retry.md b/.server-changes/attio-sync-transient-retry.md new file mode 100644 index 00000000000..9c54416a467 --- /dev/null +++ b/.server-changes/attio-sync-transient-retry.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Transient internal sync failures are now retried quietly instead of surfacing as errors. diff --git a/.server-changes/clickhouse-read-replica.md b/.server-changes/clickhouse-read-replica.md new file mode 100644 index 00000000000..334b10d3cc1 --- /dev/null +++ b/.server-changes/clickhouse-read-replica.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Optionally route ClickHouse read traffic to a read replica while writes stay on the primary. Set `CLICKHOUSE_READER_URL` to move all reads, or target the busiest paths with `RUNS_LIST_CLICKHOUSE_URL` (runs list) and `EVENTS_READER_CLICKHOUSE_URL` (traces, spans, logs). All optional; unset keeps current behavior. diff --git a/.server-changes/fix-batch-idempotency-per-item-keys.md b/.server-changes/fix-batch-idempotency-per-item-keys.md new file mode 100644 index 00000000000..9e84d474c3c --- /dev/null +++ b/.server-changes/fix-batch-idempotency-per-item-keys.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fix batchTrigger requests that set a per-item idempotency key failing with an error instead of creating and deduplicating the runs diff --git a/.server-changes/fix-batch-idempotency-point-lookups.md b/.server-changes/fix-batch-idempotency-point-lookups.md new file mode 100644 index 00000000000..1c3a4aab7d7 --- /dev/null +++ b/.server-changes/fix-batch-idempotency-point-lookups.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Speed up idempotency checks on `batchTrigger` calls that use idempotency keys. Large batches against a task with a big run history no longer degrade to multi-second lookups. diff --git a/.server-changes/fix-preview-branch-limit-count.md b/.server-changes/fix-preview-branch-limit-count.md new file mode 100644 index 00000000000..d5f5b26d74d --- /dev/null +++ b/.server-changes/fix-preview-branch-limit-count.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +The "Preview branches" usage on the Limits page now counts only preview branches. diff --git a/.server-changes/remove-deprecated-realtime-stream-action.md b/.server-changes/remove-deprecated-realtime-stream-action.md new file mode 100644 index 00000000000..3ad4f19d7f2 --- /dev/null +++ b/.server-changes/remove-deprecated-realtime-stream-action.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: improvement +--- + +Remove the deprecated realtime stream write endpoint used by retired v3 task clients. diff --git a/.server-changes/run-ops-legacy-shared-pool.md b/.server-changes/run-ops-legacy-shared-pool.md new file mode 100644 index 00000000000..dca8d5192b9 --- /dev/null +++ b/.server-changes/run-ops-legacy-shared-pool.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Avoid opening a redundant database connection pool when the legacy and primary databases are the same server, preventing connection usage from doubling. diff --git a/.server-changes/stale-deploy-asset-recovery.md b/.server-changes/stale-deploy-asset-recovery.md new file mode 100644 index 00000000000..ae34351fbce --- /dev/null +++ b/.server-changes/stale-deploy-asset-recovery.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Fix pages occasionally loading unstyled or failing to load during a deploy. The dashboard now reloads automatically to recover. diff --git a/.vouch.yml b/.vouch.yml new file mode 100644 index 00000000000..ec6e85aa705 --- /dev/null +++ b/.vouch.yml @@ -0,0 +1,4 @@ +vouch: + - github: edosrecki + - github: GautamBytes + - github: ConProgramming diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000000..ec85d436e9a --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["bierner.comment-tagged-templates"], + "unwantedRecommendations": [] +} diff --git a/.vscode/launch.json b/.vscode/launch.json index 5578de126bb..1044443e197 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -5,18 +5,79 @@ "version": "0.2.0", "configurations": [ { + "type": "node-terminal", + "request": "launch", + "name": "Debug WebApp", "command": "pnpm run dev --filter webapp", - "name": "Run webapp", + "envFile": "${workspaceFolder}/.env", + "cwd": "${workspaceFolder}", + "sourceMaps": true + }, + { + "type": "node-terminal", "request": "launch", + "name": "Debug realtimeStreams.test.ts", + "command": "pnpm run test -t RealtimeStreams", + "envFile": "${workspaceFolder}/.env", + "cwd": "${workspaceFolder}/apps/webapp", + "sourceMaps": true + }, + { "type": "node-terminal", - "cwd": "${workspaceFolder}" + "request": "launch", + "name": "Debug triggerTask.test.ts", + "command": "pnpm run test --run ./test/engine/triggerTask.test.ts", + "envFile": "${workspaceFolder}/.env", + "cwd": "${workspaceFolder}/apps/webapp", + "sourceMaps": true + }, + { + "type": "node-terminal", + "request": "launch", + "name": "Debug opened test file", + "command": "pnpm run test -- ./${relativeFile}", + "envFile": "${workspaceFolder}/.env", + "cwd": "${workspaceFolder}", + "sourceMaps": true }, { "type": "chrome", "request": "launch", "name": "Chrome webapp", - "url": "http://localhost:3000", + "url": "http://localhost:3030", "webRoot": "${workspaceFolder}/apps/webapp/app" + }, + { + "type": "node", + "request": "attach", + "name": "Attach to Trigger.dev CLI (v3)", + "port": 9229, + "restart": true, + "skipFiles": ["/**"] + }, + { + "type": "node-terminal", + "request": "launch", + "name": "Debug CLI e2e tests", + "command": "MOD=otel-telemetry-loader pnpm run test:e2e", + "cwd": "${workspaceFolder}/packages/cli-v3", + "sourceMaps": true + }, + { + "type": "node-terminal", + "request": "launch", + "name": "Debug RunEngine tests", + "command": "pnpm run test ./src/engine/tests/releaseConcurrencyTokenBucketQueue.test.ts -t 'Should retrieve metrics for all queues via getQueueMetrics'", + "cwd": "${workspaceFolder}/internal-packages/run-engine", + "sourceMaps": true + }, + { + "type": "node-terminal", + "request": "launch", + "name": "Debug RunQueue tests", + "command": "pnpm run test ./src/run-queue/index.test.ts --run", + "cwd": "${workspaceFolder}/internal-packages/run-engine", + "sourceMaps": true } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000000..f969bb6d5de --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "deno.enablePaths": ["runtime_tests/tests/deno"], + "debug.toolBarLocation": "commandCenter", + "typescript.tsdk": "node_modules/typescript/lib", + "search.exclude": { + "**/node_modules/**": true, + "packages/cli-v3/e2e": true + }, + "vitest.disableWorkspaceWarning": true, + "chat.agent.maxRequests": 10000 +} diff --git a/.warp/triggerdotdev.yaml.example b/.warp/triggerdotdev.yaml.example deleted file mode 100644 index b2bd5aea7f3..00000000000 --- a/.warp/triggerdotdev.yaml.example +++ /dev/null @@ -1,61 +0,0 @@ -# Warp Launch Configuration -# -# -# Use this to start a certain configuration of windows, tabs, and panes -# Open the launch configuration palette to access and open any launch configuration -# -# This file defines your launch configuration -# More on how to do so here: -# https://docs.warp.dev/features/session-management/launch-configurations -# -# All launch configurations are stored under ~/.warp/launch_configurations/ -# Edit them anytime! -# -# You can also add commands that run on-start for your launch configurations like so: -# --- -# name: Example with Command -# windows: -# - tabs: -# - layout: -# cwd: /Users/warp-user/project -# commands: -# - exec: code . - ---- -name: Triggerdotdev -windows: - - tabs: - - title: webapp - layout: - split_direction: vertical - panes: - - cwd: /Users/eric/code/triggerdotdev/trigger.dev - commands: - - exec: pnpm run dev --filter webapp - - cwd: /Users/eric/code/triggerdotdev/trigger.dev/apps/webapp - - title: ngrok - layout: - split_direction: horizontal - panes: - - cwd: /Users/eric/code/triggerdotdev/trigger.dev - commands: - - exec: ./scripts/proxy-webapp.sh - - cwd: /Users/eric/code/triggerdotdev/trigger.dev - commands: - - exec: ./scripts/proxy-pizzly.sh - - title: wss and examples - layout: - split_direction: horizontal - panes: - - cwd: /Users/eric/code/triggerdotdev/trigger.dev/apps/wss - commands: - - exec: LOG_LEVEL=debug pnpm run dev - - cwd: /Users/eric/code/triggerdotdev/trigger.dev/examples - - title: emails - layout: - split_direction: horizontal - panes: - - cwd: /Users/eric/Work/Git/APIHero/trigger.dev/packages/emails - commands: - - exec: pnpm run dev - - cwd: /Users/eric/Work/Git/APIHero/trigger.dev/packages/emails diff --git a/.zed/tasks.json b/.zed/tasks.json new file mode 100644 index 00000000000..8612e16bfb1 --- /dev/null +++ b/.zed/tasks.json @@ -0,0 +1,45 @@ +[ + { + "label": "Build packages", + "command": "pnpm run build --filter \"@trigger.dev/*\" --filter trigger.dev", + //"args": [], + // Env overrides for the command, will be appended to the terminal's environment from the settings. + "env": { "foo": "bar" }, + // Current working directory to spawn the command into, defaults to current project root. + //"cwd": "/path/to/working/directory", + // Whether to use a new terminal tab or reuse the existing one to spawn the process, defaults to `false`. + "use_new_terminal": false, + // Whether to allow multiple instances of the same task to be run, or rather wait for the existing ones to finish, defaults to `false`. + "allow_concurrent_runs": false, + // What to do with the terminal pane and tab, after the command was started: + // * `always` โ€” always show the task's pane, and focus the corresponding tab in it (default) + // * `no_focus` โ€” always show the task's pane, add the task's tab in it, but don't focus it + // * `never` โ€” do not alter focus, but still add/reuse the task's tab in its pane + "reveal": "always", + // What to do with the terminal pane and tab, after the command has finished: + // * `never` โ€” Do nothing when the command finishes (default) + // * `always` โ€” always hide the terminal tab, hide the pane also if it was the last tab in it + // * `on_success` โ€” hide the terminal tab on task success only, otherwise behaves similar to `always` + "hide": "never", + // Which shell to use when running a task inside the terminal. + // May take 3 values: + // 1. (default) Use the system's default terminal configuration in /etc/passwd + // "shell": "system" + // 2. A program: + // "shell": { + // "program": "sh" + // } + // 3. A program with arguments: + // "shell": { + // "with_arguments": { + // "program": "/bin/bash", + // "args": ["--login"] + // } + // } + "shell": "system", + // Whether to show the task line in the output of the spawned task, defaults to `true`. + "show_summary": true, + // Whether to show the command line in the output of the spawned task, defaults to `true`. + "show_output": true + } +] diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..cc947c7a57d --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,275 @@ +# AGENTS.md + +This file provides guidance to Claude Code when working with this repository. Subdirectory CLAUDE.md files provide deeper context when you navigate into specific areas. + +## Build and Development Commands + +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 # Core dev services (Postgres, Redis, Electric, MinIO, ClickHouse, s2-lite) +# pnpm run docker:full # Same + observability stack (Prometheus, Grafana, OTEL) and chaos tooling +pnpm run db:migrate # Run database migrations +pnpm run db:seed # Seed the database (required for reference projects) + +# Build packages (required before running) +pnpm run build --filter webapp && pnpm run build --filter trigger.dev && pnpm run build --filter @trigger.dev/sdk + +pnpm run dev --filter webapp # Run webapp (http://localhost:3030) +pnpm run dev --filter trigger.dev --filter "@trigger.dev/*" # Watch CLI and packages +``` + +### Verifying Changes + +The verification command depends on where the change lives: + +- **Apps and internal packages** (`apps/*`, `internal-packages/*`): Use `typecheck`. **Never use `build`** for these โ€” building proves almost nothing about correctness. +- **Public packages** (`packages/*`): Use `build`. + +```bash +# Apps and internal packages โ€” use typecheck +pnpm run typecheck --filter webapp # ~1-2 minutes +pnpm run typecheck --filter @internal/run-engine + +# Public packages โ€” use build +pnpm run build --filter @trigger.dev/sdk +pnpm run build --filter @trigger.dev/core +``` + +Only run typecheck/build after major changes (new files, significant refactors, schema changes). For small edits, trust the types and let CI catch issues. + +## Testing + +We use vitest exclusively. **Never mock anything** - use testcontainers instead. + +```bash +pnpm run test --filter webapp # All tests for a package +cd internal-packages/run-engine +pnpm run test ./src/engine/tests/ttl.test.ts --run # Single test file +pnpm run build --filter @internal/run-engine # May need to build deps first +``` + +Test files go next to source files (e.g., `MyService.ts` -> `MyService.test.ts`). + +### Testcontainers for Redis/PostgreSQL + +```typescript +import { redisTest, postgresTest, containerTest } from "@internal/testcontainers"; + +redisTest("should use redis", async ({ redisOptions }) => { + /* ... */ +}); +postgresTest("should use postgres", async ({ prisma }) => { + /* ... */ +}); +containerTest("should use both", async ({ prisma, redisOptions }) => { + /* ... */ +}); +``` + +## Code Style + +### Formatting and linting + +Format and lint are enforced by CI (`code-quality` check). Run before committing: + +```bash +pnpm run format # oxfmt โ€” auto-fixes formatting +pnpm run lint:fix # oxlint โ€” auto-fixes lint violations +pnpm run lint # oxlint โ€” check only (no fixes) +``` + +### Imports + +**Prefer static imports over dynamic imports.** Only use dynamic `import()` when: +- Circular dependencies cannot be resolved otherwise +- Code splitting is genuinely needed for performance +- The module must be loaded conditionally at runtime + +Dynamic imports add unnecessary overhead in hot paths and make code harder to analyze. If you find yourself using `await import()`, ask if a regular `import` statement would work instead. + +## Changesets and Server Changes + +When modifying any public package (`packages/*` or `integrations/*`), add a changeset: + +```bash +pnpm run changeset:add +``` + +- Default to **patch** for bug fixes and minor changes +- Confirm with maintainers before selecting **minor** (new features) +- **Never** select major without explicit approval + +When modifying only server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes, add a `.server-changes/` file instead. See `.server-changes/README.md` for format and documentation. + +**Write the description for users, not maintainers.** Both changesets and `.server-changes/` notes ship verbatim in user-visible release notes. Lead with what changed *for the user* - one plain sentence describing behavior, not implementation, and never naming internal tools or infra. The full writing guidance in `.server-changes/README.md` applies to changesets too. + +## Dependency Pinning + +Zod is pinned to a single version across the entire monorepo (currently `3.25.76`). When adding zod to a new or existing package, use the **exact same version** as the rest of the repo - never a different version or a range. Mismatched zod versions cause runtime type incompatibilities (e.g., schemas from one package can't be used as body validators in another). + +## Architecture Overview + +### Request Flow + +User API call -> Webapp routes -> Services -> RunEngine -> Redis Queue -> Supervisor -> Container execution -> Results back through RunEngine -> ClickHouse (analytics) + PostgreSQL (state) + +### Apps + +- **apps/webapp**: Remix 2.17.4 app - main API, dashboard, orchestration. Uses Express server. +- **apps/supervisor**: Manages task execution containers (Docker/Kubernetes). + +### Public Packages + +- **packages/trigger-sdk** (`@trigger.dev/sdk`): Main SDK for writing tasks +- **packages/cli-v3** (`trigger.dev`): CLI - also bundles code that goes into customer task images +- **packages/core** (`@trigger.dev/core`): Shared types. **Import subpaths only** (never root). +- **packages/build** (`@trigger.dev/build`): Build extensions and types +- **packages/react-hooks**: React hooks for realtime and triggering +- **packages/redis-worker** (`@trigger.dev/redis-worker`): Redis-based background job system + +### Internal Packages + +- **internal-packages/database**: Prisma 6.14.0 client and schema (PostgreSQL) +- **internal-packages/clickhouse**: ClickHouse client, schema migrations, analytics queries +- **internal-packages/run-engine**: "Run Engine 2.0" - core run lifecycle management +- **internal-packages/redis**: Redis client creation utilities (ioredis) +- **internal-packages/testcontainers**: Test helpers for Redis/PostgreSQL containers +- **internal-packages/schedule-engine**: Durable cron scheduling + +### v3 (engine V1) removed + +v3 (engine V1: MarQS + Graphile worker) is end-of-life and its execution code has been removed. The `apps/webapp/app/v3/` directory name is historical - everything there now serves V2 (Run Engine 2.0, `@internal/run-engine` + redis-worker). There is no V1 execution path: a `RunEngineVersion` `V1` branch only rejects or finalizes gracefully so v3 clients get a clean 4xx, never a 5xx. Do not reintroduce V1. See `apps/webapp/CLAUDE.md` and `.claude/rules/legacy-v3-code.md`. + +### Documentation + +Docs live in `docs/` as a Mintlify site (MDX format). See `docs/CLAUDE.md` for conventions. + +### Reference Projects + +Reference/example projects for testing SDK and platform features live in a separate repo: [`triggerdotdev/references`](https://github.com/triggerdotdev/references). Clone it alongside this repo and use its `projects/hello-world` to manually test changes before submitting PRs. See that repo's README for setup and linking to a local monorepo build. + +## Docker Image Guidelines + +When updating Docker image references: + +- **Always use multiplatform/index digests**, not architecture-specific digests +- Architecture-specific digests cause CI failures on different build environments +- Use the digest from the main Docker Hub page, not from a specific OS/ARCH variant + +## Writing Trigger.dev Tasks + +Always import from `@trigger.dev/sdk`. Never use `@trigger.dev/sdk/v3` or deprecated `client.defineJob`. + +```typescript +import { task } from "@trigger.dev/sdk"; + +export const myTask = task({ + id: "my-task", + run: async (payload: { message: string }) => { + // Task logic + }, +}); +``` + +### SDK Documentation Rules + +The `rules/` directory contains versioned SDK documentation distributed via the SDK installer. Current version: `rules/manifest.json`. Do NOT update `rules/` or `.claude/skills/trigger-dev-tasks/` unless explicitly asked - these are maintained in separate dedicated passes. + +## Testing with the hello-world Reference Project + +The reference projects live in the separate [`triggerdotdev/references`](https://github.com/triggerdotdev/references) repo - clone it alongside this repo. + +First-time setup: + +1. `pnpm run db:seed` to seed the database (creates the References org + hello-world project) +2. Build the CLI/packages you want to test: `pnpm run build --filter trigger.dev` +3. In your `references` clone, follow its README to link to your local monorepo build, then authorize: `cd projects/hello-world && pnpm exec trigger login -a http://localhost:3030` + +Running (from your `references` clone): `cd projects/hello-world && pnpm exec trigger dev` + +## Local Task Testing Workflow + +### Step 1: Start Webapp in Background + +```bash +# Run from repo root with run_in_background: true +pnpm run dev --filter webapp +curl -s http://localhost:3030/healthcheck # Verify running +``` + +### Step 2: Start Trigger Dev in Background + +```bash +# in your triggerdotdev/references clone +cd projects/hello-world && pnpm exec trigger dev +# Wait for "Local worker ready [node]" +``` + +### Step 3: Trigger and Monitor Tasks via MCP + +``` +mcp__trigger__get_current_worker(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev") +mcp__trigger__trigger_task(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev", taskId: "hello-world", payload: {"message": "Hello"}) +mcp__trigger__list_runs(projectRef: "proj_rrkpdguyagvsoktglnod", environment: "dev", taskIdentifier: "hello-world", limit: 5) +``` + +Dashboard: http://localhost:3030/orgs/references-9dfd/projects/hello-world-97DT/env/dev/runs + + + +# Skill mappings โ€” when working in these areas, load the linked skill file into context. + +skills: + +- task: "Using agentcrumbs for debug tracing, adding crumbs, trails, markers, querying traces, or stripping debug code before merge" + load: "node_modules/agentcrumbs/skills/agentcrumbs/SKILL.md" +- task: "Setting up agentcrumbs in the project, initializing namespace catalog, running crumbs init" + load: "node_modules/agentcrumbs/skills/agentcrumbs/init/SKILL.md" + + +## agentcrumbs + +Add crumbs as you write code โ€” not just when debugging. Mark lines with +`// @crumbs` or wrap blocks in `// #region @crumbs`. They stay on the +branch throughout development and are stripped by `agentcrumbs strip` +before merge. + +### Namespaces + +| Namespace | Description | Path | +| ----------------- | ---------------------------------------------- | ----------------------------------- | +| `webapp` | Main Remix app โ€” API, dashboard, orchestration | `apps/webapp` | +| `supervisor` | Task execution container manager | `apps/supervisor` | +| `sdk` | Main SDK for writing tasks | `packages/trigger-sdk` | +| `cli` | CLI and task image bundling | `packages/cli-v3` | +| `core` | Shared types and subpath exports | `packages/core` | +| `build` | Build extensions and types | `packages/build` | +| `react-hooks` | React hooks for realtime/triggering | `packages/react-hooks` | +| `redis-worker` | Redis-based background job system | `packages/redis-worker` | +| `run-engine` | Run Engine 2.0 โ€” core run lifecycle | `internal-packages/run-engine` | +| `database` | Prisma client and schema | `internal-packages/database` | +| `clickhouse` | ClickHouse client, migrations, analytics | `internal-packages/clickhouse` | +| `schedule-engine` | Durable cron scheduling | `internal-packages/schedule-engine` | +| `run-queue` | Run queue management | `internal-packages/run-queue` | + +Do not invent new namespaces โ€” pick from this table or ask first. + +### For PR reviewers + +Do not flag `// @crumbs` markers, `#region @crumbs` blocks, or agentcrumbs +imports in reviews. These are temporary debug instrumentation stripped +before merge. Data logged in crumbs (IDs, names, values) never reaches +production. + +### CLI + +```bash +pnpm exec agentcrumbs collect # start collector (multi-service) +pnpm exec agentcrumbs tail --app trigger # live tail +pnpm exec agentcrumbs clear --app trigger # remove crumbs before merge +``` + +The preferred way to query for crumbs is to use `pnpm exec agentcrumbs query --app trigger` with the `--limit` option and cursor pagination, and clear existing crumbs before reproducing a bug via `pnpm exec agentcrumbs clear --app trigger`. diff --git a/CHANGESETS.md b/CHANGESETS.md index d1d9a6dc5d4..db9d5719d98 100644 --- a/CHANGESETS.md +++ b/CHANGESETS.md @@ -1,26 +1,70 @@ -# Changesets +# Changesets and Server Changes -Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage updated our packages and releasing them to npm. +Trigger.dev uses [changesets](https://github.com/changesets/changesets) to manage package versions and releasing them to npm. For server-only changes, we use a lightweight `.server-changes/` convention. -## Adding a changeset +## Adding a changeset (package changes) -To add a changeset, use `pnpm run changeset:add` and follow the instructions [here](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). Please only ever select one of our public packages when adding a changeset, which currently are: +To add a changeset, use `pnpm run changeset:add` and follow the instructions [here](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). Please only ever select one of our public packages when adding a changeset. -- `@trigger.dev/sdk` -- `@trigger.dev/integration-sdk` -- `@trigger.dev/github` -- `@trigger.dev/slack` -- `@trigger.dev/shopify` -- `@trigger.dev/resend` +## Adding a server change (server-only changes) -## Release instructions +If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) and does NOT change any published packages, add a `.server-changes/` file instead of a changeset: -Based on the instructions [here](https://github.com/changesets/changesets/blob/main/docs/intro-to-using-changesets.md) +```sh +cat > .server-changes/fix-batch-queue-stalls.md << 'EOF' +--- +area: webapp +type: fix +--- -1. Run `pnpm run changeset:version` -2. Run `pnpm run changeset:release` +Speed up batch queue processing by removing stalls and fixing retry race +EOF +``` + +- `area`: `webapp` | `supervisor` +- `type`: `feature` | `fix` | `improvement` | `breaking` + +For **mixed PRs** (both packages and server): just add a changeset. No `.server-changes/` file needed. + +See `.server-changes/README.md` for full documentation. + +## When to add which + +| PR changes | What to add | +|---|---| +| Only packages (`packages/`) | Changeset (`pnpm run changeset:add`) | +| Only server (`apps/`) | `.server-changes/` file | +| Both packages and server | Just the changeset | + +## Release instructions (CI) + +Please follow the best-practice of adding changesets in the same commit as the code making the change with `pnpm run changeset:add`, as it will allow our release.yml CI workflow to function properly: + +- Anytime new changesets are added in a commit in the `main` branch, the [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow will run and will automatically create/update a PR with a fresh run of `pnpm run changeset:version`. +- The release PR body is automatically enhanced with a clean, deduplicated summary that includes both package changes and `.server-changes/` entries. +- Consumed `.server-changes/` files are removed on the `changeset-release/main` branch โ€” the same way changesets deletes `.changeset/*.md` files. When the release PR merges, they're gone from main. +- When the version PR is merged into `main`, the [release.yml](./.github/workflows/release.yml) workflow will automatically build, release packages to npm, and create a single unified GitHub release. ## Pre-release instructions -- Switch into pre-release mode by running `pnpm run changeset:next`. -- Switch back into normal mode by running `pnpm run changeset:normal`. +1. Add changesets as usual `pnpm run changeset:add` +2. Switch to pre-release mode by running `pnpm run changeset:next` +3. Create version `pnpm run changeset:version` +4. Release `pnpm run changeset:release` +5. Switch back to normal mode by running `pnpm run changeset:normal` + +## Snapshot instructions + +1. Update the `.changeset/config.json` file to set the `"changelog"` field to this: + +```json +"changelog": "@changesets/cli/changelog", +``` + +2. Do a temporary commit (do NOT push this, you should undo it after) + +3. Run `./scripts/publish-prerelease.sh prerelease` + +You can choose a different tag if you want, but usually `prerelease` is fine. + +5. Undo the commit where you updated the config.json file. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..43c994c2d36 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000000..32c9b3a9b11 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,43 @@ +# Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment include: + +- Using welcoming and inclusive language +- Being respectful of differing viewpoints and experiences +- Gracefully accepting constructive criticism +- Focusing on what is best for the community +- Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +- The use of sexualized language or imagery and unwelcome sexual attention or advances +- Trolling, insulting/derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or electronic address, without explicit permission +- Other conduct that could reasonably be considered inappropriate in a professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned with this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project email address, posting via an official social media account, or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. + +Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 2.0, available at [https://www.contributor-covenant.org/version/2/0/code_of_conduct.html](https://www.contributor-covenant.org/version/2/0/code_of_conduct.html). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000000..fb41c308fdb --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,322 @@ +# Contributing to Trigger.dev + +Thank you for taking the time to contribute to Trigger.dev. Your involvement is not just welcomed, but we encourage it! ๐Ÿš€ + +Please take some time to read this guide to understand contributing best practices for Trigger.dev. Note that we use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust, so you'll need to be vouched before opening a PR. + +Thank you for helping us make Trigger.dev even better! ๐Ÿคฉ + +> **Important:** We only accept PRs that address a single issue. Please do not submit PRs containing multiple unrelated fixes or features. If you have multiple contributions, open a separate PR for each one. + +## Getting vouched (required before opening a PR) + +We use [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. **PRs from unvouched users are automatically closed.** + +Before you open your first pull request, you need to be vouched by a maintainer. Here's how: + +1. Open a [Vouch Request](https://github.com/triggerdotdev/trigger.dev/issues/new?template=vouch-request.yml) issue. +2. Tell us what you'd like to work on and share any relevant background. +3. A maintainer will review your request and vouch for you by commenting on the issue. +4. Once vouched, your PRs will be accepted normally. + +If you're unsure whether you're already vouched, go ahead and open a PR โ€” the check will tell you. + +## Developing + +The development branch is `main`. This is the branch that all pull +requests should be made against. The changes on the `main` +branch are tagged into a release periodically. + +### Prerequisites + +- [Node.js](https://nodejs.org/en) version 24.18.0 +- [pnpm package manager](https://pnpm.io/installation) version 10.33.2 +- [Docker](https://www.docker.com/get-started/) +- [protobuf](https://github.com/protocolbuffers/protobuf) + +### Setup + +1. Clone the repo into a public GitHub repository or [fork the repo](https://github.com/triggerdotdev/trigger.dev/fork). If you plan to distribute the code, keep the source code public to comply with the [Apache Licence 2.0](https://github.com/triggerdotdev/trigger.dev/blob/main/LICENSE). + + ``` + git clone https://github.com//trigger.dev.git + ``` + + > If you are on windows, run the following command on gitbash with admin privileges: + > `git clone -c core.symlinks=true https://github.com//trigger.dev.git` + +2. Navigate to the project folder + ``` + cd trigger.dev + ``` +3. Ensure you are on the correct version of Node.js (24.18.0). If you are using `nvm`, there is an `.nvmrc` file that will automatically select the correct version of Node.js when you navigate to the repository. + +4. Run `corepack enable` to use the correct version of pnpm (`10.33.2`) as specified in the root `package.json` file. + +5. Install the required packages using pnpm. + ``` + pnpm i + ``` +6. Create your `.env` file + ``` + cp .env.example .env + ``` +7. Open it and generate a new value for `ENCRYPTION_KEY`: + + `ENCRYPTION_KEY` is used to two-way encrypt OAuth access tokens and so you'll probably want to actually generate a unique value, and it must be a random 16 byte hex string. You can generate one with the following command: + + ```sh + openssl rand -hex 16 + ``` + + Feel free to update `SESSION_SECRET` and `MAGIC_LINK_SECRET` as well using the same method. + +8. Start Docker. This starts the core dev services (Postgres, Redis, Electric, MinIO, ClickHouse, s2-lite) and runs the ClickHouse migrator once on first start. If this is your first time using Docker, consider going through this [guide](DOCKER_INSTALLATION.md). + + ``` + pnpm run docker + ``` + + For the observability stack (Prometheus, Grafana, OTEL collector) and other optional tooling (Toxiproxy, nginx-h2, ch-ui, extra electric shard), use `pnpm run docker:full` instead. See `docker/docker-compose.extras.yml` for the full list. + +9. Migrate the database + ``` + pnpm run db:migrate + ``` +10. Build the webapp, CLI, and SDK + ``` + pnpm run build --filter webapp --filter trigger.dev --filter @trigger.dev/sdk + ``` +11. Seed the database. This creates a local user, a `References` org, and the reference projects (including `hello-world`) with stable IDs. + ``` + pnpm run db:seed + ``` +12. Run the app. See the section below. + +## Running + +1. You can run the app with: + + ``` + pnpm run dev --filter webapp + ``` + + It should run on port `3030`: [http://localhost:3030](http://localhost:3030/) + +2. Once the app is running click the magic link button and enter your email. You will automatically be logged in, since you are running locally. Create an Org and your first project in the dashboard. + +## Manual testing using hello-world + +The `hello-world` reference project (and the others) live in a separate repo: +[`triggerdotdev/references`](https://github.com/triggerdotdev/references). Clone it +alongside this repo. It's the staging ground for testing changes to the SDK +(`@trigger.dev/sdk` at `/packages/trigger-sdk`), the Core package +(`@trigger.dev/core` at `/packages/core`), the CLI (`trigger.dev` at +`/packages/cli-v3`) and the platform (the Remix app at `/apps/webapp`). +To exercise your local monorepo changes, the reference project links to your local +build โ€” see the references repo's README for the `pnpm run link` flow. + +> Paths below such as `projects/hello-world` are relative to your `references` +> clone, not this repo. + +### First-time setup + +First, make sure you are running the webapp according to the instructions above. The seed step from setup already created a `hello-world` project under the `References` org with the stable ref `proj_rrkpdguyagvsoktglnod` โ€” log in at http://localhost:3030 with any email to access it. Then: + +1. Build the CLI and packages (skip if you already ran the build step in setup) + +```sh +pnpm run build --filter trigger.dev --filter "@trigger.dev/*" +``` + +2. In your `references` clone, link to your local monorepo build (see its README), then change into `projects/hello-world` and authorize the CLI to the local server: + +```sh +cd projects/hello-world +cp .env.example .env +pnpm exec trigger login -a http://localhost:3030 +``` + +This will open a new browser window and authorize the CLI against your local user account. + +You can optionally pass a `--profile` flag to the `login` command, which will allow you to use the CLI with separate accounts/servers. We suggest using a profile called `local` for your local development: + +```sh +cd projects/hello-world +pnpm exec trigger login -a http://localhost:3030 --profile local +# later when you run the dev or deploy command: +pnpm exec trigger dev --profile local +pnpm exec trigger deploy --profile local +``` + +### Running + +The following steps should be followed any time you start working on a new feature you want to test: + +1. Make sure the webapp is running on localhost:3030 + +2. In this repo, open a terminal window and build the CLI and packages and watch for changes (the reference project links against this build) + +```sh +pnpm run dev --filter trigger.dev --filter "@trigger.dev/*" +``` + +3. Open another terminal window, and change into `projects/hello-world` in your `references` clone. + +4. Run the `dev` command, which will register all the local tasks with the platform and allow you to start testing task execution: + +```sh +# in /projects/hello-world +pnpm exec trigger dev +``` + +If you want additional debug logging, you can use the `--log-level debug` flag: + +```sh +# in /projects/hello-world +pnpm exec trigger dev --log-level debug +``` + +5. If you make any changes in the CLI/Core/SDK, you'll need to `CTRL+C` to exit the `dev` command and restart it to pickup changes. Any changes to the files inside the reference project's `src/trigger` dir will automatically be rebuilt by the `dev` command. + +6. Navigate to the `hello-world` project in your local dashboard at localhost:3030 and you should see the list of tasks. + +7. On the Tasks page, open a task and press the "Test" button to open its test page. Then enter a payload and click "Run test". You can tell what the payloads should be by looking at the relevant task file inside the reference project's `src/trigger` folder. Many of them accept an empty payload. + +8. Feel free to add additional files in the reference project's `src/trigger` dir to test out specific aspects of the system, or add in edge cases. + +## Adding and running migrations + +1. Modify `internal-packages/database/prisma/schema.prisma`. +2. Change directory to the database package: + + ```sh + cd internal-packages/database + ``` + +3. Create a migration: + + ``` + pnpm run db:migrate:dev:create + ``` + + This creates a migration file. Check the migration file does only what you want. If you're adding any database indexes they must use `CONCURRENTLY`, otherwise they'll lock the table when executed. + +4. Run the migration: + + ``` + pnpm run db:migrate:deploy + pnpm run generate + ``` + + This executes the migrations against your database and applies changes to the database schema(s), and then regenerates the Prisma client. + +5. Commit the generated migration files as well as the changes to `schema.prisma`. +6. If you're using VSCode you may need to restart the TypeScript server in the webapp to get updated type inference. Open a TypeScript file, then open the Command Palette (View > Command Palette) and run `TypeScript: Restart TS server`. + +## Making a pull request + +**If you get errors, be sure to fix them before committing.** + +> **Note:** We may close PRs if we decide that the cost of integrating the change outweighs the benefits. To improve the chances of your PR getting accepted, follow the guidelines below. + +### PR workflow + +1. **Always open your PR in draft status first.** Do not mark it as "Ready for Review" until the steps below are complete. +2. **Run format and lint locally before pushing:** + ```bash + pnpm run format # auto-fixes formatting (oxfmt) + pnpm run lint:fix # auto-fixes lint violations (oxlint) + ``` + Both are enforced by CI โ€” the `code-quality` check will fail if either produces a diff or errors. +3. **Address all CodeRabbit code review comments.** Our CI runs an automated code review via CodeRabbit. Go through each comment and either fix the issue or resolve it with a comment explaining why no change is needed. +4. **Wait for all CI checks to pass.** Do not mark the PR as "Ready for Review" until every check is green. +5. **Then mark the PR as "Ready for Review"** so a maintainer can take a look. + +### Cost/benefit analysis for risky changes + +If your change touches core infrastructure, modifies widely-used code paths, or could introduce regressions, consider doing a brief cost/benefit analysis and including it in the PR description. Explain what the benefit is to users and why the risk is worth it. This goes a long way toward helping maintainers evaluate your contribution. + +### General guidelines + +- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating your PR. +- If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. See more about [Linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). +- Be sure to fill the PR Template accordingly. + +## Adding changesets + +We use [changesets](https://github.com/changesets/changesets) to manage our package versions and changelogs. If you've never used changesets before, first read [their guide here](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md). + +If you are contributing a change to any packages in this monorepo (anything in either the `/packages` or `/integrations` directories), then you will need to add a changeset to your Pull Requests before they can be merged. + +To add a changeset, run the following command in the root of the repo + +```sh +pnpm run changeset:add +``` + +Here's an example of creating a `patch` changeset for the `@trigger.dev/github` and `@trigger.dev/slack` packages (click to view): + +[![asciicast](https://asciinema.org/a/599228.svg)](https://asciinema.org/a/599228) + +You will be prompted to select which packages to include in the changeset. Only select the packages that you have made changes for. + +Most of the time the changes you'll make are likely to be categorized as patch releases. If you feel like there is the need for a minor or major release of the package based on the changes being made, add the changeset as such and it will be discussed during PR review. + +## Adding server changes + +Changesets only track published npm packages. If your PR only changes server components (`apps/webapp/`, `apps/supervisor/`, etc.) with no package changes, add a `.server-changes/` file so the change appears in release notes. + +Create a markdown file with a descriptive name: + +```sh +cat > .server-changes/fix-batch-queue-stalls.md << 'EOF' +--- +area: webapp +type: fix +--- + +Speed up batch queue processing by removing stalls and fixing retry race +EOF +``` + +**Fields:** +- `area` (required): `webapp` | `supervisor` +- `type` (required): `feature` | `fix` | `improvement` | `breaking` + +The body text (below the frontmatter) is a one-line description of the change. Keep it concise โ€” it will appear in release notes. + +**When to add which:** + +| PR changes | What to add | +|---|---| +| Only packages (`packages/`) | Changeset | +| Only server (`apps/`) | `.server-changes/` file | +| Both packages and server | Just the changeset | + +See `.server-changes/README.md` for more details. + +## Troubleshooting + +### EADDRINUSE: address already in use :::3030 + +When receiving the following error message: + +```sh +webapp:dev: Error: listen EADDRINUSE: address already in use :::3030 +``` + +The process running on port `3030` should be destroyed. + +1. Get the `PID` of the process running on PORT `3030` + ```sh + lsof -i :3030 + ``` +2. Kill the process + ```sh + sudo kill -9 + ``` + +### Running two clones side by side (worktree, branch experiment) + +The default `pnpm run docker` uses the project name `triggerdotdev-docker` and the standard host ports (5432, 6379, 3060, 4566, 8123, 9000, 9005, 9006). To stand up a second instance in another clone without clashing, set a different `COMPOSE_PROJECT_NAME` and the offset host ports in that clone's `.env`. The "Running multiple instances side by side" block in `.env.example` lists every overridable env var with its default for reference; uncomment the lines you need and update `DATABASE_URL` / `CLICKHOUSE_URL` / `REDIS_PORT` / `APP_ORIGIN` / `LOGIN_ORIGIN` / `ELECTRIC_ORIGIN` / `REALTIME_STREAMS_S2_ENDPOINT` to match. diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md deleted file mode 100644 index 573e4615317..00000000000 --- a/DEPLOYMENT.md +++ /dev/null @@ -1,32 +0,0 @@ -# Trigger.dev Deployment Guide - -## StreamNative Cloud - Hosted Pulsar - -### Generic client credentials - -```sh -snctl auth export-service-account webapp --key-file webapp-credentials.json -snctl auth export-service-account websocketserver --key-file wss-credentials.json -``` - -### Topics - -#### Triggers (`persistent://triggerdotdev/workflows/triggers`) - -Events that trigger workflows to run. These are sent by the "platform" and read by the Web Socket Servers, which then coordinate with the hosts for running the workflows - -#### Run Commands (`persistent://triggerdotdev/workflows/run-commands`) - -These are events that come from hosts and are published by the Web Socket Servers, e.g. Sending Integration Requests, Sending Logs, Initializing a Delay - -#### Run Command Responses (`persistent://triggerdotdev/workflows/run-command-responses`) - -These are events that come from the platform and are read by the Web Socket Servers, to resolve or reject a previous Run Command - -#### Integration Requests (`persistent://triggerdotdev/queues/integration-requests`) - -This is an internal queue used by the platform to perform integration requests, and retry them. - -#### App Task Queue (`persistent://triggerdotdev/queues/background-tasks`) - -This is an internal queue used by the platform to do tasks in a queue. Basically a background job system. diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md deleted file mode 100644 index fdf3ca4d4cb..00000000000 --- a/DEVELOPMENT.md +++ /dev/null @@ -1,229 +0,0 @@ -# Initial setup - -## Prerequisites - -### Pulsar requirements - -1. Ensure you have Homebrew installed by running `which brew` in terminal. If it's not found then you should install it: https://brew.sh/. Run `which brew` again to check it's found. If it's not you may need to [add it your path](https://stackoverflow.com/questions/36657321/after-installing-homebrew-i-get-zsh-command-not-found-brew) - -2. Run `brew install libpulsar` to install the C++ libraries that the pulsar-client depends on - -3. Make sure you have Python installed on your machine by running `which python3` in terminal. - -4. If python isn't found then you should install it: https://www.python.org/downloads/. In a new terminal window run `which python3` again. - -5. Run `npm config set python /the/path/from/the/which/python3/command` inserting the path from step 2 or 3 - -6. Install node-gyp: `npm install -g node-gyp` - -7. Make sure you have the Xcode command line tools installed by running `xcode-select --install` from the terminal. If it says they're already installed then you're set. - -8. Run this in the terminal: - -```sh -export CPLUS_INCLUDE_PATH="$CPLUS_INCLUDE_PATH:$(brew --prefix)/include" -export LIBRARY_PATH="$LIBRARY_PATH:$(brew --prefix)/lib" -export PULSAR_CPP_DIR=/opt/homebrew/Cellar/libpulsar/3.1.0 -``` - -9. Run `pnpm install` in the same terminal window. - -## Building and running the webapp - -> **Warning** -> All the following commands should be launched from the **monorepo root directory** - -1. Install the dependencies. - ```bash - pnpm install - ``` -2. Optionally, if you are testing auth (pizzly) or webhooks (webapp) then you'll need to use ngrok to proxy internet traffic to your local machine - - Get access to ngrok and then follow the instructions here to get it setup: https://ngrok.com/download (use homebrew) and make sure to authenticate. - - Then run the following scripts to start proxying: - - ```sh - ./scripts/proxy-pizzly.sh dan-pizzly-dev - ./scripts/proxy-webapp.sh dan-trigger-dev - ``` - -3. Environment variables. You will need to create copies of the `.env.example` files in `app/webapp` - - ```sh - cp ./apps/webapp/.env.example ./apps/webapp/.env - ``` - - Then you will need to fill in the fields with real values. - - You also need to create the `pizzly-server.env` files under the `.docker` directory: - - ```sh - cp ./.docker/pizzly-server.env.example ./.docker/pizzly-server.env - ``` - - Next, update the `AUTH_CALLBACK_URL` env var in the `pizzly-server.env` env file with the value provided to the `./scripts/proxy-pizzly.sh` command. Using the example above the `AUTH_CALLBACK_URL` would be `AUTH_CALLBACK_URL=https://dan-pizzly-dev.eu.ngrok.io/oauth/callback`. - - If you aren't proxying pizzly according to step 2, then leave the `pizzly-server.env` file empty. - - If you are proxying the webapp according to step 2 then in `webapp/.env`, set the `APP_ORIGIN` to the `NGROK_SUBDOMAIN` provided to the `./scripts/proxy-webapp.sh` command, e.g. `APP_ORIGIN=https://dan-trigger-dev.eu.ngrok.io` - -4. Start postgresql, pulsar, and pizzly server - - ```bash - pnpm run docker:db - ``` - - > **Note:** The npm script will complete while Docker sets up the container in the background. Ensure that Docker has finished and your container is running before proceeding. - -5. Generate prisma schema - ```bash - pnpm run generate - ``` -6. Run the Prisma migration to the database - - ```bash - pnpm run db:migrate:deploy - ``` - -7. Run the first build (with dependencies via the `...` option) - - ```bash - pnpm run build --filter=webapp... - ``` - - **Running simply `pnpm run build` will build everything, including the Remix app.** - -8. Run the Remix dev server - -```bash -pnpm run dev --filter=webapp -``` - -## Attaching an API integration using Pizzly - -```bash -PIZZLY_HOSTPORT=http://localhost:3004 npx pizzly config:create github github "repo,user" -``` - -## Tests, Typechecks, Lint, Install packages... - -Check the `turbo.json` file to see the available pipelines. - -- Run the Cypress tests and Dev - ```bash - pnpm run test:e2e:dev --filter=webapp - ``` -- Lint everything - ```bash - pnpm run lint - ``` -- Typecheck the whole monorepo - ```bash - pnpm run typecheck - ``` -- Test the whole monorepo - ```bash - pnpm run test - or - pnpm run test:dev - ``` -- How to install an npm package in the Remix app ? - ```bash - pnpm add dayjs --filter webapp - ``` -- Tweak the tsconfigs, eslint configs in the `config-package` folder. Any package or app will then extend from these configs. - -# Running a workflow locally - -## After pulling a change - -1. Ensure there are no database migrations to run - -```bash -pnpm run db:migrate:dev -``` - -2. Generate the Prisma database client - -```bash -pnpm run generate -``` - -3. Install packages - -```bash -pnpm install -``` - -4. Build everything - -```bash -pnpm run build -``` - -5. Install packages again, this makes sure the local packages are linked - -```bash -pnpm install -``` - -## Running the servers - -1. Ensure the docker containers are running - -```bash -pnpm run docker:db -``` - -2. Run the webapp - -```bash -pnpm run dev --filter=webapp -``` - -3. Run the Web Socket Server - -```bash -pnpm run dev --filter=wss -``` - -4. Build all the @trigger.dev/\* packages - -```bash -pnpm run dev --filter="@trigger.dev/*" -``` - -## Running the smoke test - -1. Run the smoke test workflow - -```bash -cd ./examples/smoke-test -pnpm run dev -``` - -2. Running the workflow requires you to send data to the local API. - -You can use this cURL command to send a `user.created` event. This will run the workflow and generate the corresponding logs. - -```bash -curl --request POST \ - --url http://localhost:3000/api/v1/events \ - --header 'Authorization: Bearer trigger_dev_zC25mKNn6c0q' \ - --header 'Content-Type: application/json' \ - --data '{ - "name": "user.created", - "payload": { - "id": "123" - } -}' -``` - -## Dependency & Package graph - -![Dependency Graph](assets/dependencyGraph.png) - -## Warp Launch Configuration - -Setup a custom launch configuration for the Warp terminal ([docs here](https://docs.warp.dev/features/sessions/launch-configurations)) by copying the `.warp/triggerdotdev.yaml.example` file to `~/.warp/launch_configurations/triggerdotdev.yaml`. Make sure you edit the file and replace `` and `` with your custom ngrok subdomains. diff --git a/DOCKER_INSTALLATION.md b/DOCKER_INSTALLATION.md new file mode 100644 index 00000000000..7e135bd6f84 --- /dev/null +++ b/DOCKER_INSTALLATION.md @@ -0,0 +1,135 @@ +This guide covers installing Docker and Docker Compose. If you're looking for instructions for running Trigger.dev in docker, [see here](https://github.com/triggerdotdev/docker). + +## Setting up Docker for the first time. + +In the contributing guide of Trigger.dev, there's a section that requires you to start Docker. + +If you don't have Docker installed on your machine, you'll run into some complications (errors). + +Below are the steps on how you can avoid that. + +First you need to setup docker compose as it is an underlying tool that this command: `pnpm run docker` fires behind the scene. + +## Linux + +To install Docker Compose on Linux Ubuntu, you can follow these steps: + +1. Create the Docker config directory and cli-plugins subdirectory: + + ```shell + DOCKER_CONFIG=${DOCKER_CONFIG:-$HOME/.docker} + mkdir -p $DOCKER_CONFIG/cli-plugins + ``` + +2. Download the Docker Compose plugin: + + ```shell + curl -SL "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o $DOCKER_CONFIG/cli-plugins/docker-compose + ``` + + Note: + + - To install for all users, replace `$DOCKER_CONFIG/cli-plugins` with `/usr/local/lib/docker/cli-plugins` + +3. Set the appropriate permissions to make the Docker Compose plugin executable: + + ```shell + chmod +x $DOCKER_CONFIG/cli-plugins/docker-compose + ``` + + If you installed for all users: + + ```shell + sudo chmod +x /usr/local/lib/docker/cli-plugins/docker-compose + ``` + +4. Verify that Docker Compose has been successfully installed: + + ```shell + docker compose version + ``` + + You should see output similar to: + + ``` + Docker Compose version vX.Y.Z + ``` + +After following these steps, you should have Docker Compose installed on your Ubuntu system, and you can use it by running `docker compose` commands in the terminal. + +When you've verified that the `docker compose` package is installed and you proceed to start Docker with `pnpm run docker`. + +You'll probably get an error similar to the one below: + +```shell +Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running? +โ€‰ELIFECYCLEโ€‰ Command failed with exit code 1. +``` + +The error message suggests that the Docker daemon is not running on your system. The Docker daemon is responsible for managing and running Docker containers. + +To resolve this issue, you may need to install Docker properly on your Ubuntu system. Here are the steps to install Docker on Ubuntu: + +1. Update the package index on your system by running the following command: + + ```shell + sudo apt update + ``` + +2. Install the necessary packages to allow apt to use repositories over HTTPS: + + ```shell + sudo apt install apt-transport-https ca-certificates curl software-properties-common + ``` + +3. Add the official Docker GPG key to your system by running the following command: + + ```shell + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg + ``` + +4. Add the Docker repository to the APT sources list: + + ```shell + echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + ``` + +5. Update the package index again: + + ```shell + sudo apt update + ``` + +6. Install Docker by running the following command: + + ```shell + sudo apt install docker-ce docker-ce-cli containerd.io + ``` + +7. After the installation is complete, verify that Docker is installed correctly by running the following command: + + ```shell + docker --version + ``` + + This command should display the version information of Docker without any errors. + +Once Docker is installed and verified, you should be able to start the Docker daemon and run the `pnpm run docker` command without encountering any issues. + +## Windows + +1. Download the Docker Desktop installer from the Docker website: [Docker Desktop for Windows](https://www.docker.com/products/docker-desktop) + +2. Run the installer and follow the instructions to install Docker Desktop. + +3. After installation, Docker Desktop should be running automatically. + +## macOS + +1. Download the Docker Desktop installer from the Docker website: [Docker Desktop for Mac](https://www.docker.com/products/docker-desktop) + +2. Run the installer and follow the instructions to install Docker Desktop. + +3. After installation, Docker Desktop should be running automatically. + +Please note that the instructions provided above are for the most common scenarios. For specific versions or different distributions, it's always a good idea to consult the official Docker documentation for the respective operating systems. diff --git a/LICENSE b/LICENSE index a1e6cd1e3f8..5e468e50785 100644 --- a/LICENSE +++ b/LICENSE @@ -1,25 +1,201 @@ -Copyright (c) 2022 API Hero Inc. - -Portions of this software are licensed as follows: - -- All content that resides under any "ee/" directory of this repository, if such directories exists, are licensed under the license defined in "ee/LICENSE". -- All third party components incorporated into the Trigger.dev Software are licensed under the original license provided by the owner of the applicable component. -- Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [2023] [Trigger.dev] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md index c4b76a6a5d5..0d7f1ca2930 100644 --- a/README.md +++ b/README.md @@ -1,205 +1,130 @@
-![Hero](https://raw.githubusercontent.com/triggerdotdev/trigger.dev/eebe37109e33beae6390ee19029fce8a5934c84b/apps/webapp/public/images/logo-banner.png) +![Trigger.dev logo](https://content.trigger.dev/github-header-banner.jpg) -[![Twitter](https://img.shields.io/twitter/url/https/twitter.com/triggerdotdev.svg?style=social&label=Follow%20%40trigger.dev)](https://twitter.com/triggerdotdev) [![YouTube Channel Subscribers](https://img.shields.io/youtube/channel/subscribers/UCu-PdxpWtIrrd7vW0N5T6ZA?style=social)](https://www.youtube.com/@triggerdotdev) -[![GitHub Repo stars](https://img.shields.io/github/stars/triggerdotdev/trigger.dev?style=social)](https://github.com/triggerdotdev/trigger.dev) +### Build and deploy fullyโ€‘managed AI agents and workflows -[Website](https://trigger.dev) | [Community](https://discord.gg/JtBAxBr2m3) | [Docs](https://docs.trigger.dev) -
+[Website](https://trigger.dev) | [Docs](https://trigger.dev/docs) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Example projects](https://github.com/triggerdotdev/examples) | [Feature requests](https://triggerdev.featurebase.app/) | [Public roadmap](https://triggerdev.featurebase.app/roadmap) | [Self-hosting](https://trigger.dev/docs/self-hosting/overview) +[![Open Source](https://img.shields.io/badge/Open%20Source-%E2%9D%A4-red.svg)](https://github.com/triggerdotdev/trigger.dev) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/triggerdotdev/trigger.dev/blob/main/LICENSE) +[![npm](https://img.shields.io/npm/v/@trigger.dev/sdk.svg?label=npm)](https://www.npmjs.com/package/@trigger.dev/sdk) +[![SDK downloads](https://img.shields.io/npm/dm/@trigger.dev/sdk.svg?label=SDK%20downloads)](https://www.npmjs.com/package/@trigger.dev/sdk) -# **โœจ Trigger.dev** -### **The developer-first open source Zapier alternative.** +[![Twitter Follow](https://img.shields.io/twitter/follow/triggerdotdev?style=social)](https://twitter.com/triggerdotdev) +[![Discord](https://img.shields.io/discord/1066956501299777596?logo=discord&logoColor=white&color=7289da)](https://discord.gg/nkqV9xBYWy) +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/triggerdotdev/trigger.dev) +[![GitHub stars](https://img.shields.io/github/stars/triggerdotdev/trigger.dev?style=social)](https://github.com/triggerdotdev/trigger.dev) + -Trigger.dev is an open source platform that makes it easy for developers to create event-driven background tasks directly in their code. Build, test and run workflows locally using our SDK. Subscribe to webhooks, schedule jobs, run background jobs and add long delays easily and reliably. In our web app you get full visibility of every run your workflow has ever made making it easier to monitor and debug. +## About Trigger.dev -  +Trigger.dev is the open-source platform for building AI workflows in TypeScript. Long-running tasks with retries, queues, observability, and elastic scaling. -# **โญ๏ธ Features:** +## The platform designed for building AI agents -- ๐Ÿ‘‚ Easily subscribe to [webhooks](https://docs.trigger.dev/triggers/webhooks) - โ€” they work locally without tunnelling. -- ๐Ÿ”ฅ Fire your own [custom events](https://docs.trigger.dev/triggers/custom-events)โ€”a single event can trigger multiple workflows. -- ๐Ÿ“† [Schedule workflows](https://docs.trigger.dev/triggers/scheduled)โ€”easily repeat tasks or use CRON syntax for advanced cases. -- ๐Ÿšฆ Add [long delays](https://docs.trigger.dev/functions/delays) inside workflows (up to a year) and they will pick up where they left off. -- ๐Ÿค When your server goes down [itโ€™s not a problem](https://docs.trigger.dev/guides/resumability), workflows will reconnect and continue. -- ๐Ÿชง [View every step of every run](https://docs.trigger.dev/viewing-runs), with data, previews and errors. -- ๐Ÿ‘‹ Connect to and authenticate with APIs using our custom integrations. -- ๐Ÿš— If you have a custom use case, we support [Fetch for calling any HTTP endpoint](https://docs.trigger.dev/functions/fetch) or [webhooks](https://docs.trigger.dev/triggers/webhooks) for subscribing to events from APIs. -- ๐Ÿ“ก All API calls are automatically retried with exponential back off. -- ๐Ÿ˜€ TypeScript SDK, so whether youโ€™re using JavaScript or TypeScript you will have a great experience. +Build [AI agents](https://trigger.dev/product/ai-agents) using all the frameworks, services and LLMs you're used to, deploy them to Trigger.dev and get durable, long-running tasks with retries, queues, observability, and elastic scaling out of the box. -  +- **Long-running without timeouts**: Execute your tasks with absolutely no timeouts, unlike AWS Lambda, Vercel, and other serverless platforms. -# **๐ŸŒฑ Documentation:** +- **Durability, retries & queues**: Build rock solid agents and AI applications using our durable tasks, retries, queues and idempotency. -- [Getting Started with Trigger.dev](https://docs.trigger.dev/getting-started) -- Example workflows - - [Welcome email drip campaign using Resend and Slack](https://docs.trigger.dev/examples/resend) - - [Post to Slack when a GitHub issue is created or modified](https://docs.trigger.dev/examples/slack) - - [Create a new product on Shopify](https://docs.trigger.dev/examples/shopify) - - [When a GitHub repo is starred, post information about the user to Slack](https://docs.trigger.dev/examples/github) -- Triggers: - - [Webhooks](https://docs.trigger.dev/triggers/webhooks) - - [Custom events](https://docs.trigger.dev/triggers/custom-events) - - [Scheduled](https://docs.trigger.dev/triggers/scheduled) -- Functions: - - [Fetch](https://docs.trigger.dev/functions/fetch) - - [Logging](https://docs.trigger.dev/functions/logging) - - [Delays](https://docs.trigger.dev/functions/delays) - - [Send event](https://docs.trigger.dev/functions/send-event) - - [Loops, conditionals, etc](https://docs.trigger.dev/functions/loops-conditionals-etc) +- **True runtime freedom**: Customize your deployed tasks with system packages โ€“ run browsers, Python scripts, FFmpeg and more. -  +- **Human-in-the-loop**: Programmatically pause your tasks until a human can approve, reject or give feedback. -# ๐Ÿ”ฌ **Anatomy of a workflow** +- **Realtime apps & streaming**: Move your background jobs to the foreground by subscribing to runs or streaming AI responses to your app. -* You create workflows in code on your server using our SDK -* Each API integration is a separate package, e.g. `@trigger.dev/slack` -* Each workflow has an event that triggers it, e.g. `github.events.newStarEvent`, `scheduleEvent`, `customEvent` -* Each workflow has a `run` function that is called when the event is triggered -* If we don't have an integration for the API you want to use, you can use `fetch` to call any HTTP endpoint and `webhookEvent` to subscribe to webhooks +- **Observability & monitoring**: Each run has full tracing and logs. Configure error alerts to catch bugs fast. -## **Example workflows** +## Key features: -
Post to Slack when a GitHub issue is created or modified - +- **[JavaScript and TypeScript SDK](https://trigger.dev/docs/tasks/overview)** - Build background tasks using familiar programming models +- **[Long-running tasks](https://trigger.dev/docs/runs/max-duration)** - Handle resource-heavy tasks without timeouts +- **[Durable cron schedules](https://trigger.dev/docs/tasks/scheduled#scheduled-tasks-cron)** - Create and attach recurring schedules of up to a year +- **[Trigger.dev Realtime](https://trigger.dev/docs/realtime/overview)** - Trigger, subscribe to, and get real-time updates for runs, with LLM streaming support +- **[Build extensions](https://trigger.dev/docs/config/extensions/overview#build-extensions)** - Hook directly into the build system and customize the build process. Run Python scripts, FFmpeg, browsers, and more. +- **[React hooks](https://trigger.dev/docs/frontend/react-hooks#react-hooks)** - Interact with the Trigger.dev API on your frontend using our React hooks package +- **[Batch triggering](https://trigger.dev/docs/triggering#tasks-batchtrigger)** - Use batchTrigger() to initiate multiple runs of a task with custom payloads and options +- **[Structured inputs / outputs](https://trigger.dev/docs/tasks/schemaTask#schematask)** - Define precise data schemas for your tasks with runtime payload validation +- **[Waits](https://trigger.dev/docs/wait)** - Add waits to your tasks to pause execution for a specified duration +- **[Preview branches](https://trigger.dev/docs/deployment/preview-branches)** - Create isolated environments for testing and development. Integrates with Vercel and git workflows +- **[Waitpoints](https://trigger.dev/docs/wait-for-token#wait-for-token)** - Add human-in-the-loop judgment at critical decision points without disrupting workflow +- **[Concurrency & queues](https://trigger.dev/docs/queue-concurrency#concurrency-and-queues)** - Set concurrency rules to manage how multiple tasks execute +- **[Multiple environments](https://trigger.dev/docs/how-it-works#dev-mode)** - Support for DEV, PREVIEW, STAGING, and PROD environments +- **[No infrastructure to manage](https://trigger.dev/docs/how-it-works#trigger-dev-architecture)** - Auto-scaling infrastructure that eliminates timeouts and server management +- **[Automatic retries](https://trigger.dev/docs/errors-retrying)** - If your task encounters an uncaught error, we automatically attempt to run it again +- **[Checkpointing](https://trigger.dev/docs/how-it-works#the-checkpoint-resume-system)** - Tasks are inherently durable, thanks to our checkpointing feature +- **[Versioning](https://trigger.dev/docs/versioning)** - Atomic versioning allows you to deploy new versions without affecting running tasks +- **[Machines](https://trigger.dev/docs/machines)** - Configure the number of vCPUs and GBs of RAM you want the task to use +- **[Observability & monitoring](https://trigger.dev/product/observability-and-monitoring)** - Monitor every aspect of your tasks' performance with comprehensive logging and visualization tools +- **[Logging & tracing](https://trigger.dev/docs/logging)** - Comprehensive logging and tracing for all your tasks +- **[Tags](https://trigger.dev/docs/tags#tags)** - Attach up to ten tags to each run, allowing you to filter via the dashboard, realtime, and the SDK +- **[Run metadata](https://trigger.dev/docs/runs/metadata#run-metadata)** - Attach metadata to runs which updates as the run progresses and is available to use in your frontend for live updates +- **[Bulk actions](https://trigger.dev/docs/bulk-actions)** - Perform actions on multiple runs simultaneously, including replaying and cancelling +- **[Real-time alerts](https://trigger.dev/docs/troubleshooting-alerts#alerts)** - Choose your preferred notification method for run failures and deployments -_Integrations required: Slack, GitHub_ +## Write tasks in your codebase -```ts -import { Trigger } from "@trigger.dev/sdk"; -import * as github from "@trigger.dev/github"; -import * as slack from "@trigger.dev/slack"; - -new Trigger({ - id: "new-github-star-to-slack", - name: "New GitHub Star: triggerdotdev/trigger.dev", - apiKey: "", - on: github.events.newStarEvent({ - repo: "triggerdotdev/trigger.dev", - }), - run: async (event) => { - await slack.postMessage("github-stars", { - channelName: "github-stars", - text: `New GitHub star from \n<${event.sender.html_url}|${event.sender.login}>`, - }); - }, -}).listen(); -``` - -
- -
Welcome email drip campaign - - -_Integrations required: Slack, Resend_ +Create tasks where they belong: in your codebase. Version control, localhost, test and review like you're already used to. ```ts -import { customEvent, Trigger, sendEvent } from "@trigger.dev/sdk"; -import * as resend from "@trigger.dev/resend"; -import * as slack from "@trigger.dev/slack"; -import React from "react"; -import { z } from "zod"; -import { getUser } from "../db"; -import { InactiveEmail, TipsEmail, WelcomeEmail } from "./email-templates"; - -new Trigger({ - id: "welcome-email-campaign", - name: "Welcome email drip campaign", - apiKey: "", - on: customEvent({ - name: "user.created", - schema: z.object({ - userId: z.string(), - }), - }), - async run(event, context) { - //get the user data from the database - const user = await getUser(event.userId); - - await slack.postMessage("send-to-slack", { - channelName: "new-users", - text: `New user signed up: ${user.name} (${user.email})`, - }); - - //Send the first email - const welcomeResponse = await resend.sendEmail("welcome-email", { - from: "Trigger.dev ", - replyTo: "James ", - to: user.email, - subject: "Welcome to Trigger.dev", - react: , - }); - await context.logger.debug( - `Sent welcome email to ${welcomeResponse.to} with id ${welcomeResponse.id}` - ); - - //wait 1 day, check if the user has created a workflow and send the appropriate email - await context.waitFor("wait-a-while", { days: 1 }); - const updatedUser = await getUser(event.userId); - - if (updatedUser.hasOnboarded) { - await resend.sendEmail("onboarding-complete", { - from: "Trigger.dev ", - replyTo: "James ", - to: updatedUser.email, - subject: "Pro tips for workflows", - react: , - }); - } else { - await resend.sendEmail("onboarding-incomplete", { - from: "Trigger.dev ", - replyTo: "James ", - to: updatedUser.email, - subject: "Help with your first workflow", - react: , - }); - } +import { task } from "@trigger.dev/sdk"; + +//1. You need to export each task +export const helloWorld = task({ + //2. Use a unique id for each task + id: "hello-world", + //3. The run function is the main function of the task + run: async (payload: { message: string }) => { + //4. You can write code that runs for a long time here, there are no timeouts + console.log(payload.message); }, -}).listen(); +}); ``` -
- -[More examples here](https://docs.trigger.dev/examples/examples) +## Deployment -  +Use our SDK to write tasks in your codebase. There's no infrastructure to manage, your tasks automatically scale and connect to our cloud. Or you can always self-host. -# ๐Ÿ‘€ **Viewing runs:** +## Environments -One of the most powerful features of Trigger.dev is the [runs page](https://docs.trigger.dev/viewing-runs). All of the steps in a workflow, including the initial event, can be viewed in detail. See the status / output of each step, the logs, rich previews, errors and much more. +We support `Development`, `Staging`, `Preview`, and `Production` environments, allowing you to test your tasks before deploying them to production. -![Viewing runs](https://github.com/triggerdotdev/trigger.dev/raw/main/apps/docs/images/run-succeeded.png) +## Full visibility of every job run +View every task in every run so you can tell exactly what happened. We provide a full trace view of every task run so you can see what happened at every step. -  +![Trace view image](https://content.trigger.dev/trace-view.png) -# **๐Ÿ  Running Trigger.dev locally:** +# Getting started -To run Trigger.dev locally, [follow these steps](https://github.com/triggerdotdev/trigger.dev/blob/main/DEVELOPMENT.md). +The quickest way to get started is to create an account and project in our [web app](https://cloud.trigger.dev), and follow the instructions in the onboarding. Build and deploy your first task in minutes. -  +### Useful links: -# **๐Ÿ‘ Contributing:** +- [Quick start](https://trigger.dev/docs/quick-start) - get up and running in minutes +- [How it works](https://trigger.dev/docs/how-it-works) - understand how Trigger.dev works under the hood +- [Guides and examples](https://trigger.dev/docs/guides/introduction) - walk-through guides and code examples for popular frameworks and use cases -We are open source and love contributions! +## Self-hosting -- Request a feature in our [Discord community](https://discord.gg/JtBAxBr2m3) -- Open a PR +If you prefer to self-host Trigger.dev, you can follow our [self-hosting guides](https://trigger.dev/docs/self-hosting/overview): -  +- [Docker self-hosting guide](https://trigger.dev/docs/self-hosting/docker) - use Docker Compose to spin up a Trigger.dev instance +- [Kubernetes self-hosting guide](https://trigger.dev/docs/self-hosting/kubernetes) - use our official Helm chart to deploy Trigger.dev to your Kubernetes cluster -# **๐Ÿง˜โ€โ™‚๏ธ Self-hosting guide:** +## Support and community -Please subscribe to the [GitHub issue](https://github.com/triggerdotdev/trigger.dev/issues/48) to be notified when it's live. +We have a large active community in our official [Discord server](https://trigger.dev/discord) for support, including a dedicated channel for self-hosting. -  +## Development +To setup and develop locally or contribute to the open source project, follow our [development guide](./CONTRIBUTING.md). -# **๐Ÿ“ง Support & contact:** +## Meet the Amazing People Behind This Project: -- Join our [Discord community](https://discord.gg/JtBAxBr2m3) -- If you have any other questions, get in touch at [hello@trigger.dev](mailto:hello@trigger.dev) + + + diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000000..8ba3ecb5007 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,36 @@ +## Guide on releasing a new version + +### Automated release (v4+) + +Releases are fully automated via CI: + +1. PRs merge to `main` with changesets (for package changes) and/or `.server-changes/` files (for server-only changes). +2. The [changesets-pr.yml](./.github/workflows/changesets-pr.yml) workflow automatically creates/updates the `changeset-release/main` PR with version bumps and an enhanced summary of all changes. Consumed `.server-changes/` files are removed on the release branch (same approach changesets uses for `.changeset/` files โ€” they're deleted on the branch, so merging the PR cleans them up). +3. When ready to release, merge the changeset release PR into `main`. +4. The [release.yml](./.github/workflows/release.yml) workflow automatically: + - Publishes all packages to npm + - Creates a single unified GitHub release (e.g., "trigger.dev v4.3.4") + - Tags and triggers Docker image builds + - After Docker images are pushed, updates the GitHub release with the exact GHCR tag link + +### What engineers need to do + +- **Package changes**: Add a changeset with `pnpm run changeset:add` +- **Server-only changes**: Add a `.server-changes/` file (see `.server-changes/README.md`) +- **Mixed PRs**: Just the changeset is enough + +See `CHANGESETS.md` for full details on changesets and server changes. + +### Legacy release (v3) + +1. Merge in the changeset PR into main, making sure to cancel both the release and publish github actions from that merge. +2. Pull the changes locally into main +3. Run `pnpm i` which will update the pnpm lock file with the new versions +4. create a commit with "Release 3.x.x" and push. This will build and release the packages +5. Create a git tag on that release commit with v.docker.3.x.x and push the tag to origin. This will publish the `v3.x.x` docker image to GitHub Container Registry. +6. Once the image is built and pushed, create a new GitHub release and select the tag you just created, along with the previous tag that was released. +7. This will generate some release notes. Edit out the package changes and leave only the server changes. +8. Name the release `@trigger.dev/docker@3.x.x` +9. Include the package link (e.g. https://github.com/triggerdotdev/trigger.dev/pkgs/container/trigger.dev/278459584?tag=v3.x.x) +10. Once the packages have been published, head over to the [v2-legacy repo](https://github.com/triggerdotdev/v2-legacy.trigger.dev) and follow the instructions in the README for creating a matching release. +11. Before deploying to cloud, compare the differences in the previously created release and double check to see if there are any migrations with indexes created concurrently, and make sure to run those before deploying. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000000..6dbe24a6698 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,49 @@ +# Security Policy + +We take the security of Trigger.dev seriously โ€” for both our Cloud service and self-hosted deployments. This document explains how to report a vulnerability and what to expect from us. + +## Reporting a vulnerability + +**Please do not report security vulnerabilities through public GitHub issues, pull requests, or our Discord.** + +Use one of these private channels instead: + +1. **GitHub (preferred):** Open a private report from the repository's **Security** tab โ€” click **"Report a vulnerability"** ([direct link](https://github.com/triggerdotdev/trigger.dev/security/advisories/new)). +2. **Email:** `security-advisories@trigger.dev` + +Please include as much of the following as you can: + +- A description of the vulnerability and its impact +- Steps to reproduce, ideally with a proof of concept +- Affected version(s) and component(s) +- Any suggested remediation + +If you report by email, we will open a private GitHub Security Advisory to track the issue. All reports โ€” however they reach us โ€” are tracked there. + +## What to expect + +| Stage | Target | +| --- | --- | +| Acknowledgement of your report | within 3 business days | +| Validation and severity assessment (CVSS 3.1) | within 1 week | + +We assess severity using CVSS 3.1 and prioritise remediation accordingly: + +| Severity (CVSS 3.1) | Target time to resolve | +| --- | --- | +| Critical (9.0โ€“10.0) | 7 days | +| High (7.0โ€“8.9) | 30 days | +| Medium (4.0โ€“6.9) | 90 days | +| Low (0.1โ€“3.9) | As needed | + +These are best-effort targets, measured from the point we validate and accept a report โ€” not guarantees. Real-world exploitability may lead us to escalate an issue beyond its base score. + +## Coordinated disclosure + +We follow coordinated disclosure. Please give us a reasonable opportunity to investigate and ship a fix before any public disclosure. Our default disclosure window is 90 days from acceptance, though we aim to resolve issues sooner. + +Once a fix is released we publish a GitHub Security Advisory (and request a CVE where applicable), and we credit reporters unless you ask to remain anonymous. + +## Supported versions + +We patch the **latest released version line** only. Self-hosters should run the latest version-tagged release to receive security fixes. See the [self-hosting documentation](https://trigger.dev/docs/self-hosting/overview). diff --git a/ai/references/migrations.md b/ai/references/migrations.md new file mode 100644 index 00000000000..c6fbf79e9d7 --- /dev/null +++ b/ai/references/migrations.md @@ -0,0 +1,121 @@ +## Creating and applying migrations + +We use prisma migrations to manage the database schema. Please follow the following steps when editing the `internal-packages/database/prisma/schema.prisma` file: + +Edit the `schema.prisma` file to add or modify the schema. + +Create a new migration file but don't apply it yet: + +```bash +cd internal-packages/database +pnpm run db:migrate:dev:create --name "add_new_column_to_table" +``` + +The migration file will be created in the `prisma/migrations` directory, but it will have a bunch of edits to the schema that are not needed and will need to be removed before we can apply the migration. Here's an example of what the migration file might look like: + +```sql +-- AlterEnum +ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED'; + +-- AlterTable +ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB; + +-- AlterTable +ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_WaitpointRunConnections_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_completedWaitpoints_AB_unique"; + +-- CreateIndex +CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops); + +-- CreateIndex +CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC); + +-- CreateIndex +CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC); +``` + +All the following lines should be removed: + +```sql +-- AlterTable +ALTER TABLE "public"."_BackgroundWorkerToBackgroundWorkerFile" ADD CONSTRAINT "_BackgroundWorkerToBackgroundWorkerFile_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_BackgroundWorkerToBackgroundWorkerFile_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_BackgroundWorkerToTaskQueue" ADD CONSTRAINT "_BackgroundWorkerToTaskQueue_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_BackgroundWorkerToTaskQueue_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_TaskRunToTaskRunTag" ADD CONSTRAINT "_TaskRunToTaskRunTag_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_TaskRunToTaskRunTag_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_WaitpointRunConnections" ADD CONSTRAINT "_WaitpointRunConnections_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_WaitpointRunConnections_AB_unique"; + +-- AlterTable +ALTER TABLE "public"."_completedWaitpoints" ADD CONSTRAINT "_completedWaitpoints_AB_pkey" PRIMARY KEY ("A", "B"); + +-- DropIndex +DROP INDEX "public"."_completedWaitpoints_AB_unique"; + +-- CreateIndex +CREATE INDEX "SecretStore_key_idx" ON "public"."SecretStore"("key" text_pattern_ops); + +-- CreateIndex +CREATE INDEX "TaskRun_runtimeEnvironmentId_id_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "id" DESC); + +-- CreateIndex +CREATE INDEX "TaskRun_runtimeEnvironmentId_createdAt_idx" ON "public"."TaskRun"("runtimeEnvironmentId", "createdAt" DESC); +``` + +Leaving only this: + +```sql +-- AlterEnum +ALTER TYPE "public"."TaskRunExecutionStatus" ADD VALUE 'DELAYED'; + +-- AlterTable +ALTER TABLE "public"."TaskRun" ADD COLUMN "debounce" JSONB; +``` + +After editing the migration file, apply the migration: + +```bash +cd internal-packages/database +pnpm run db:migrate:deploy && pnpm run generate +``` diff --git a/ai/references/repo.md b/ai/references/repo.md new file mode 100644 index 00000000000..68286729ca3 --- /dev/null +++ b/ai/references/repo.md @@ -0,0 +1,36 @@ +## Repo Overview + +This is a pnpm 10.33.2 monorepo that uses turborepo @turbo.json. The following workspaces are relevant + +## Apps + +- /apps/webapp is a remix app that is the main API and dashboard for trigger.dev +- /apps/supervisor is a node.js app that handles the execution of built tasks, interaction with the webapp through internal "engine" APIs, as well as interfacing with things like docker or kubernetes, to execute the code. + +## Public Packages + +- /packages/trigger-sdk is the `@trigger.dev/sdk` main SDK package. +- /packages/cli-v3 is the `trigger.dev` CLI package. See our [CLI dev command](https://trigger.dev/docs/cli-dev.md) and [Deployment](https://trigger.dev/docs/deployment/overview.md) docs for more information. +- /packages/core is the `@trigger.dev/core` package that is shared across the SDK and other packages +- /packages/build defines the types and prebuilt build extensions for trigger.dev. See our [build extensions docs](https://trigger.dev/docs/config/extensions/overview.md) for more information. +- /packages/react-hooks defines some useful react hooks like our realtime hooks. See our [Realtime hooks](https://trigger.dev/docs/frontend/react-hooks/realtime.md) and our [Trigger hooks](https://trigger.dev/docs/frontend/react-hooks/triggering.md) for more information. +- /packages/redis-worker is the `@trigger.dev/redis-worker` package that implements a custom background job/worker sytem powered by redis for offloading work to the background, used in the webapp and also in the Run Engine 2.0. + +## Internal Packages + +- /internal-packages/\* are packages that are used internally only, not published, and usually they have a tsc build step and are used in the webapp +- /internal-packages/database is the `@trigger.dev/database` package that exports a prisma client, has the schema file, and exports a few other helpers. +- /internal-packages/run-engine is the `@internal/run-engine` package that is "Run Engine 2.0" and handles moving a run all the way through it's lifecycle +- /internal-packages/redis is the `@internal/redis` package that exports Redis types and the `createRedisClient` function to unify how we create redis clients in the repo. It's not used everywhere yet, but it's the preferred way to create redis clients from now on. +- /internal-packages/testcontainers is the `@internal/testcontainers` package that exports a few useful functions for spinning up local testcontainers when writing vitest tests. See our [tests.md](./tests.md) file for more information. + +## References + +- /references/\* are test workspaces that we use to write and test the system. Not quite e2e tests or automated, but just a useful place to help develop new features + +## Other + +- /docs is our trigger.dev/docs mintlify documentation site +- /docker/Dockerfile is the one that creates the main trigger.dev published image +- /docker/docker-compose.yml is the file we run locally to start postgresql, redis, and electric when we are doing local development. You can run it with `pnpm run docker` +- /CONTRIBUTING.md defines the steps it takes for OSS contributors to start contributing. diff --git a/ai/references/tests.md b/ai/references/tests.md new file mode 100644 index 00000000000..2bb236c75bc --- /dev/null +++ b/ai/references/tests.md @@ -0,0 +1,86 @@ +## Running Tests + +We use vitest exclusively for testing. To execute tests for a particular workspace, run the following command: + +```bash +pnpm run test --filter webapp +``` + +Prefer running tests on a single file (and first cding into the directory): + +```bash +cd apps/webapp +pnpm run test ./src/components/Button.test.ts +``` + +If you are cd'ing into a directory, you may have to build dependencies first: + +```bash +pnpm run build --filter webapp +cd apps/webapp +pnpm run test ./src/components/Button.test.ts +``` + +## Writing Tests + +We use vitest for testing. We almost NEVER mock anything. Start with a top-level "describe", and have multiple "it" statements inside of it. + +New test files should be placed right next to the file being tested. For example: + +- Source file: `./src/services/MyService.ts` +- Test file: `./src/services/MyService.test.ts` + +When writing anything that needs redis or postgresql, we have some internal "testcontainers" that are used to spin up a local instance, redis, or both. + +redisTest: + +```typescript +import { redisTest } from "@internal/testcontainers"; +import { createRedisClient } from "@internal/redis"; + +describe("redisTest", () => { + redisTest("should use redis", async ({ redisOptions }) => { + const redis = createRedisClient(redisOptions); + + await redis.set("test", "test"); + const result = await redis.get("test"); + expect(result).toEqual("test"); + }); +}); +``` + +postgresTest: + +```typescript +import { postgresTest } from "@internal/testcontainers"; + +describe("postgresTest", () => { + postgresTest("should use postgres", async ({ prisma }) => { + // prisma is an instance of PrismaClient + }); +}); +``` + +containerTest: + +```typescript +import { containerTest } from "@internal/testcontainers"; + +describe("containerTest", () => { + containerTest("should use container", async ({ prisma, redisOptions }) => { + // container has both prisma and redis + }); +}); +``` + +## Dos and Dont's + +- Do not mock anything. +- Do not use mocks in tests. +- Do not use spies in tests. +- Do not use stubs in tests. +- Do not use fakes in tests. +- Do not use sinon in tests. +- Structure each test with a setup, action, and assertion style. +- Feel free to write long test names. +- If there is any randomness in the code under test, use `seedrandom` to make it deterministic by allowing the caller to provide a seed. diff --git a/ailogger-output.log b/ailogger-output.log new file mode 100644 index 00000000000..e69de29bb2d diff --git a/apps/supervisor/.env.example b/apps/supervisor/.env.example new file mode 100644 index 00000000000..4355a4eea30 --- /dev/null +++ b/apps/supervisor/.env.example @@ -0,0 +1,20 @@ +# This needs to match the token of the worker group you want to connect to +TRIGGER_WORKER_TOKEN= + +# This needs to match the MANAGED_WORKER_SECRET env var on the webapp +MANAGED_WORKER_SECRET=managed-secret + +# Point this at the webapp in prod +TRIGGER_API_URL=http://localhost:3030 + +# Point this at the webapp or an OTel collector in prod +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:3030/otel +# Use this on macOS +# OTEL_EXPORTER_OTLP_ENDPOINT=http://host.docker.internal:3030/otel + +# Optional settings +DEBUG=1 +TRIGGER_DEQUEUE_INTERVAL_MS=1000 + +# Dev-only: stream this process's logs over a local telnet/TCP socket (nc localhost 6769). Uncomment to enable. +# SUPERVISOR_TELNET_LOGS_PORT=6769 diff --git a/apps/supervisor/.nvmrc b/apps/supervisor/.nvmrc new file mode 100644 index 00000000000..5bcf9c6e6ac --- /dev/null +++ b/apps/supervisor/.nvmrc @@ -0,0 +1 @@ +v24.18.0 diff --git a/apps/supervisor/CLAUDE.md b/apps/supervisor/CLAUDE.md new file mode 100644 index 00000000000..9cc7c82fd9d --- /dev/null +++ b/apps/supervisor/CLAUDE.md @@ -0,0 +1,20 @@ +# Supervisor + +Node.js app that manages task execution containers. Receives work from the platform, starts Docker/Kubernetes containers, monitors execution, and reports results. + +## Key Directories + +- `src/services/` - Core service logic +- `src/workloadManager/` - Container orchestration abstraction (Docker or Kubernetes) +- `src/workloadServer/` - HTTP server for workload communication (heartbeats, snapshots) +- `src/clients/` - Platform communication (webapp) +- `src/env.ts` - Environment configuration + +## Architecture + +- **WorkloadManager**: Abstracts Docker vs Kubernetes execution +- **SupervisorSession**: Manages the dequeue loop with EWMA-based dynamic scaling +- **ResourceMonitor**: Tracks CPU/memory during execution +- **PodCleaner/FailedPodHandler**: Kubernetes-specific cleanup + +Communicates with the platform via Socket.io and HTTP. Receives task assignments through the dequeue protocol from the webapp. diff --git a/apps/supervisor/Containerfile b/apps/supervisor/Containerfile new file mode 100644 index 00000000000..edc6ba2ee0d --- /dev/null +++ b/apps/supervisor/Containerfile @@ -0,0 +1,55 @@ +FROM node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd AS node-24-alpine + +WORKDIR /app + +FROM node-24-alpine AS pruner + +COPY --chown=node:node . . +RUN npx -q turbo@2.10.0 prune --scope=supervisor --docker + +FROM node-24-alpine AS base + +RUN apk add --no-cache dumb-init + +COPY --chown=node:node .gitignore .gitignore +COPY --from=pruner --chown=node:node /app/out/json/ . +COPY --from=pruner --chown=node:node /app/out/pnpm-lock.yaml ./pnpm-lock.yaml +COPY --from=pruner --chown=node:node /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml + +RUN corepack enable && corepack prepare pnpm@10.33.2 --activate + +FROM base AS deps-fetcher +RUN apk add --no-cache python3-dev py3-setuptools make g++ gcc linux-headers +RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm fetch --frozen-lockfile + +FROM deps-fetcher AS dev-deps +ENV NODE_ENV development + +RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store pnpm install --frozen-lockfile --offline --ignore-scripts + +FROM base AS builder + +COPY --from=pruner --chown=node:node /app/out/full/ . +COPY --from=dev-deps --chown=node:node /app/ . +COPY --chown=node:node turbo.json turbo.json +COPY --chown=node:node .configs/tsconfig.base.json .configs/tsconfig.base.json +COPY --chown=node:node scripts/updateVersion.ts scripts/updateVersion.ts +COPY --chown=node:node scripts/retry-prisma-generate.mjs scripts/retry-prisma-generate.mjs + +RUN pnpm run generate && \ + pnpm run --filter supervisor... build&& \ + pnpm deploy --legacy --filter=supervisor --prod /prod/supervisor + +FROM base AS runner + +ENV NODE_ENV production + +COPY --from=builder /prod/supervisor /app/apps/supervisor + +EXPOSE 8000 +USER node + +# ensure pnpm is installed during build and not silently downloaded at runtime +RUN pnpm -v + +CMD [ "/usr/bin/dumb-init", "--", "pnpm", "run", "--filter", "supervisor", "start"] diff --git a/apps/supervisor/README.md b/apps/supervisor/README.md new file mode 100644 index 00000000000..86b447269d2 --- /dev/null +++ b/apps/supervisor/README.md @@ -0,0 +1,105 @@ +# Supervisor + +## Dev setup + +1. Create a worker group + +```sh +api_url=http://localhost:3030 +wg_name=my-worker + +# edit this +admin_pat=tr_pat_... + +curl -sS \ + -X POST \ + "$api_url/admin/api/v1/workers" \ + -H "Authorization: Bearer $admin_pat" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$wg_name\"}" +``` + +If the worker group is newly created, the response will include a `token` field. If the group already exists, no token is returned. + +2. Create `.env` and set the worker token + +```sh +cp .env.example .env + +# Then edit your .env and set this to the token.plaintext value +TRIGGER_WORKER_TOKEN=tr_wgt_... +``` + +3. Start the supervisor + +```sh +pnpm dev +``` + +4. Build CLI, then deploy a test project + +```sh +pnpm exec trigger deploy --self-hosted + +# The additional network flag is required on linux +pnpm exec trigger deploy --self-hosted --network host +``` + +## Worker group management + +### Shared variables + +```sh +api_url=http://localhost:3030 +admin_pat=tr_pat_... # edit this +``` + +- These are used by all commands + +### Create a worker group + +```sh +wg_name=my-worker + +curl -sS \ + -X POST \ + "$api_url/admin/api/v1/workers" \ + -H "Authorization: Bearer $admin_pat" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$wg_name\"}" +``` + +- If the worker group already exists, no token will be returned + +### Set a worker group as default for a project + +```sh +wg_name=my-worker +project_id=clsw6q8wz... + +curl -sS \ + -X POST \ + "$api_url/admin/api/v1/workers" \ + -H "Authorization: Bearer $admin_pat" \ + -H "Content-Type: application/json" \ + -d "{\"name\": \"$wg_name\", \"projectId\": \"$project_id\", \"makeDefaultForProject\": true}" +``` + +- If the worker group doesn't exist, yet it will be created +- If the worker group already exists, it will be attached to the project as default. No token will be returned. + +### Remove the default worker group from a project + +```sh +project_id=clsw6q8wz... + +curl -sS \ + -X POST \ + "$api_url/admin/api/v1/workers" \ + -H "Authorization: Bearer $admin_pat" \ + -H "Content-Type: application/json" \ + -d "{\"projectId\": \"$project_id\", \"removeDefaultFromProject\": true}" +``` + +- The project will then use the global default again +- When `removeDefaultFromProject: true` no other actions will be performed diff --git a/apps/supervisor/package.json b/apps/supervisor/package.json new file mode 100644 index 00000000000..2725fe2b729 --- /dev/null +++ b/apps/supervisor/package.json @@ -0,0 +1,32 @@ +{ + "name": "supervisor", + "private": true, + "version": "0.0.1", + "main": "dist/index.js", + "type": "module", + "scripts": { + "build": "tsc", + "dev": "tsx --require dotenv/config --watch src/index.ts || (echo '!! Remember to run: nvm use'; exit 1)", + "start": "node dist/index.js", + "test:run": "vitest --no-file-parallelism --run", + "test:watch": "vitest --no-file-parallelism", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@aws-sdk/client-ecr": "^3.839.0", + "@internal/compute": "workspace:*", + "@kubernetes/client-node": "^1.0.0", + "@trigger.dev/core": "workspace:*", + "dockerode": "^4.0.6", + "ioredis": "~5.6.0", + "p-limit": "^6.2.0", + "prom-client": "^15.1.0", + "socket.io": "4.7.4", + "std-env": "^3.8.0", + "zod": "3.25.76" + }, + "devDependencies": { + "@internal/testcontainers": "workspace:*", + "@types/dockerode": "^3.3.33" + } +} diff --git a/apps/supervisor/src/backpressure/backpressureMetrics.ts b/apps/supervisor/src/backpressure/backpressureMetrics.ts new file mode 100644 index 00000000000..ffe57628548 --- /dev/null +++ b/apps/supervisor/src/backpressure/backpressureMetrics.ts @@ -0,0 +1,34 @@ +import { Counter, Gauge, type Registry } from "prom-client"; + +/** Prometheus metrics for dequeue backpressure. */ +export class BackpressureMetrics { + /** 1 while backpressure is engaged (computed signal, set even in dry-run). */ + readonly engaged: Gauge; + /** 1 when running in dry-run (gates inert). */ + readonly dryRun: Gauge; + /** Dequeue attempts the gate skipped - or would have, in dry-run (labelled). */ + readonly skipsTotal: Counter; + + constructor(opts: { register: Registry; prefix?: string }) { + const prefix = opts.prefix ?? "supervisor_backpressure"; + + this.engaged = new Gauge({ + name: `${prefix}_engaged`, + help: "1 while dequeue backpressure is engaged (computed signal, regardless of dry-run)", + registers: [opts.register], + }); + + this.dryRun = new Gauge({ + name: `${prefix}_dry_run`, + help: "1 when dequeue backpressure is in dry-run mode (gates inert)", + registers: [opts.register], + }); + + this.skipsTotal = new Counter({ + name: `${prefix}_skipped_dequeues_total`, + help: "Dequeue attempts skipped by backpressure (or would be, in dry-run)", + labelNames: ["dry_run"], + registers: [opts.register], + }); + } +} diff --git a/apps/supervisor/src/backpressure/backpressureMonitor.test.ts b/apps/supervisor/src/backpressure/backpressureMonitor.test.ts new file mode 100644 index 00000000000..7af28ffc9f5 --- /dev/null +++ b/apps/supervisor/src/backpressure/backpressureMonitor.test.ts @@ -0,0 +1,353 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Registry } from "prom-client"; +import { BackpressureMonitor, type BackpressureSignalSource } from "./backpressureMonitor.js"; +import { BackpressureMetrics } from "./backpressureMetrics.js"; + +function countingSource(verdict: { engaged: boolean } | null): { + source: BackpressureSignalSource; + reads: () => number; +} { + let reads = 0; + return { + source: { + read: async () => { + reads++; + return verdict; + }, + }, + reads: () => reads, + }; +} + +describe("BackpressureMonitor", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("when disabled, never skips dequeue and never reads the signal source", () => { + // Even though the source would report "engaged", a disabled monitor must be + // a complete no-op: this is the backwards-compatibility guarantee. + const { source, reads } = countingSource({ engaged: true }); + const monitor = new BackpressureMonitor({ enabled: false, source }); + + monitor.start(); + + expect(monitor.shouldSkipDequeue()).toBe(false); + expect(reads()).toBe(0); + + monitor.stop(); + }); + + it("when enabled and the source reports engaged, skips dequeue after a refresh", async () => { + const { source } = countingSource({ engaged: true }); + const monitor = new BackpressureMonitor({ enabled: true, source, refreshIntervalMs: 1000 }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); // flush the initial async read + + expect(monitor.shouldSkipDequeue()).toBe(true); + + monitor.stop(); + }); + + it("when enabled and the source reports clear, does not skip dequeue", async () => { + const { source } = countingSource({ engaged: false }); + const monitor = new BackpressureMonitor({ enabled: true, source, refreshIntervalMs: 1000 }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(monitor.shouldSkipDequeue()).toBe(false); + + monitor.stop(); + }); + + it("fails open (stops skipping) when the source throws", async () => { + let call = 0; + const source: BackpressureSignalSource = { + read: async () => { + call++; + if (call === 1) { + return { engaged: true }; + } + throw new Error("signal source unreachable"); + }, + }; + const monitor = new BackpressureMonitor({ enabled: true, source, refreshIntervalMs: 1000 }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(monitor.shouldSkipDequeue()).toBe(true); // engaged from the first read + + await vi.advanceTimersByTimeAsync(1000); // next refresh throws + expect(monitor.shouldSkipDequeue()).toBe(false); // fail-open: a dead source must not pin the brake + + monitor.stop(); + }); + + it("fails open when the source reports unknown (null)", async () => { + const { source } = countingSource(null); + const monitor = new BackpressureMonitor({ enabled: true, source, refreshIntervalMs: 1000 }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(monitor.shouldSkipDequeue()).toBe(false); + + monitor.stop(); + }); + + it("fails open when the cached verdict goes stale (older than max age)", async () => { + // Source stops updating (e.g. hangs) after the first read; the verdict ages out. + const source: BackpressureSignalSource = { + read: async () => ({ engaged: true, ts: Date.now() }), + }; + const monitor = new BackpressureMonitor({ + enabled: true, + source, + refreshIntervalMs: 1_000_000, // effectively only the initial read fires + maxVerdictAgeMs: 15_000, + }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(monitor.shouldSkipDequeue()).toBe(true); + + await vi.advanceTimersByTimeAsync(15_001); // verdict now older than max age + expect(monitor.shouldSkipDequeue()).toBe(false); + + monitor.stop(); + }); + + it("does not read the source on the hot path (reads are driven by the refresh tick)", async () => { + const { source, reads } = countingSource({ engaged: true }); + const monitor = new BackpressureMonitor({ enabled: true, source, refreshIntervalMs: 1000 }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(reads()).toBe(1); // just the initial refresh + + for (let i = 0; i < 1000; i++) { + monitor.shouldSkipDequeue(); + } + + expect(reads()).toBe(1); // hot-path calls performed zero I/O + + monitor.stop(); + }); + + it("does not start an overlapping refresh while one is in flight", async () => { + let reads = 0; + const source: BackpressureSignalSource = { + // Never resolves - simulates a hung read. + read: () => { + reads++; + return new Promise<{ engaged: boolean } | null>(() => {}); + }, + }; + const monitor = new BackpressureMonitor({ enabled: true, source, refreshIntervalMs: 1000 }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(3000); // several intervals while the first read hangs + + expect(reads).toBe(1); // in-flight guard prevents stacking + + monitor.stop(); + }); + + it("stops refreshing after stop()", async () => { + const { source, reads } = countingSource({ engaged: true }); + const monitor = new BackpressureMonitor({ enabled: true, source, refreshIntervalMs: 1000 }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + const readsAtStop = reads(); + + monitor.stop(); + await vi.advanceTimersByTimeAsync(5000); + + expect(reads()).toBe(readsAtStop); + }); + + it("isEngaged reflects the hard engaged state (the signal for freezing scale-up)", async () => { + const { source } = countingSource({ engaged: true }); + const monitor = new BackpressureMonitor({ enabled: true, source, refreshIntervalMs: 1000 }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(monitor.isEngaged()).toBe(true); + + monitor.stop(); + }); + + it("isEngaged is false when clear and when stale", async () => { + const source: BackpressureSignalSource = { + read: async () => ({ engaged: true, ts: Date.now() }), + }; + const monitor = new BackpressureMonitor({ + enabled: true, + source, + refreshIntervalMs: 1_000_000, + maxVerdictAgeMs: 15_000, + }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(monitor.isEngaged()).toBe(true); + + await vi.advanceTimersByTimeAsync(15_001); // stale โ†’ fail-open + expect(monitor.isEngaged()).toBe(false); + + monitor.stop(); + }); + + it("ramps the dequeue gate after release instead of resuming instantly", async () => { + let engaged = true; + let rnd = 0.5; + const source: BackpressureSignalSource = { read: async () => ({ engaged }) }; + const monitor = new BackpressureMonitor({ + enabled: true, + source, + refreshIntervalMs: 1000, + rampMs: 10_000, + random: () => rnd, + }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(monitor.shouldSkipDequeue()).toBe(true); // hard engaged + + // Release: the next refresh observes the clear verdict and starts the ramp. + engaged = false; + await vi.advanceTimersByTimeAsync(1000); + expect(monitor.isEngaged()).toBe(false); + + // Just after release (progress ~0): skip probability ~1, so skip regardless. + rnd = 0.99; + expect(monitor.shouldSkipDequeue()).toBe(true); + + // Halfway through the ramp (progress 0.5): skip probability 0.5. + await vi.advanceTimersByTimeAsync(5000); + rnd = 0.4; + expect(monitor.shouldSkipDequeue()).toBe(true); // 0.4 < 0.5 โ†’ skip + rnd = 0.6; + expect(monitor.shouldSkipDequeue()).toBe(false); // 0.6 โ‰ฅ 0.5 โ†’ allow + + // Past the ramp window: never skip. + await vi.advanceTimersByTimeAsync(5000); + rnd = 0.0; + expect(monitor.shouldSkipDequeue()).toBe(false); + + monitor.stop(); + }); + + it("fails open on an engaged verdict with no timestamp when staleness is enforced", async () => { + // A verdict claiming engaged but carrying no ts can't be checked for freshness; + // when maxVerdictAgeMs is set we must not trust it (else a dead producer could + // pin the brake forever). + const { source } = countingSource({ engaged: true }); // no ts + const monitor = new BackpressureMonitor({ + enabled: true, + source, + refreshIntervalMs: 1000, + maxVerdictAgeMs: 15_000, + }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(monitor.computeEngaged()).toBe(false); + expect(monitor.shouldSkipDequeue()).toBe(false); + + monitor.stop(); + }); + + it("in dry-run, the gates are inert but computeEngaged still reflects the real signal", async () => { + const { source } = countingSource({ engaged: true }); + const monitor = new BackpressureMonitor({ + enabled: true, + source, + refreshIntervalMs: 1000, + dryRun: true, + }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(monitor.computeEngaged()).toBe(true); // real signal, for observability/metrics + expect(monitor.isEngaged()).toBe(false); // inert: no scale-up freeze + expect(monitor.shouldSkipDequeue()).toBe(false); // inert: no dequeue skip + + monitor.stop(); + }); + + it("logs on verdict transitions", async () => { + let engaged = true; + const source: BackpressureSignalSource = { read: async () => ({ engaged }) }; + const logs: Array<{ message: string; meta?: Record }> = []; + const logger = { + info: (message: string, meta?: Record) => logs.push({ message, meta }), + }; + const monitor = new BackpressureMonitor({ + enabled: true, + source, + refreshIntervalMs: 1000, + logger, + }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(logs.some((l) => l.meta?.engaged === true)).toBe(true); + + engaged = false; + await vi.advanceTimersByTimeAsync(1000); + expect(logs.some((l) => l.meta?.engaged === false)).toBe(true); + + monitor.stop(); + }); + + it("records prometheus metrics", async () => { + const { source } = countingSource({ engaged: true }); + const register = new Registry(); + const metrics = new BackpressureMetrics({ register }); + const monitor = new BackpressureMonitor({ + enabled: true, + source, + refreshIntervalMs: 1000, + metrics, + }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + + expect(await register.metrics()).toContain("supervisor_backpressure_engaged 1"); + + monitor.shouldSkipDequeue(); + expect(await register.metrics()).toMatch( + /supervisor_backpressure_skipped_dequeues_total\{dry_run="false"\} [1-9]/ + ); + + monitor.stop(); + }); + + it("resumes instantly when no ramp is configured", async () => { + let engaged = true; + const source: BackpressureSignalSource = { read: async () => ({ engaged }) }; + const monitor = new BackpressureMonitor({ enabled: true, source, refreshIntervalMs: 1000 }); + + monitor.start(); + await vi.advanceTimersByTimeAsync(0); + expect(monitor.shouldSkipDequeue()).toBe(true); + + engaged = false; + await vi.advanceTimersByTimeAsync(1000); + expect(monitor.shouldSkipDequeue()).toBe(false); // no ramp โ†’ instant resume + + monitor.stop(); + }); +}); diff --git a/apps/supervisor/src/backpressure/backpressureMonitor.ts b/apps/supervisor/src/backpressure/backpressureMonitor.ts new file mode 100644 index 00000000000..6b4170697e5 --- /dev/null +++ b/apps/supervisor/src/backpressure/backpressureMonitor.ts @@ -0,0 +1,179 @@ +import type { BackpressureMetrics } from "./backpressureMetrics.js"; + +export interface BackpressureLogger { + info(message: string, meta?: Record): void; +} + +export type BackpressureVerdict = { + engaged: boolean; + /** Epoch ms the verdict was produced. Used for consumer-side staleness fail-open. */ + ts?: number; +}; + +/** + * Source of the current backpressure verdict. `read()` returns `null` when the + * verdict is unknown (missing/unreadable) - the monitor treats unknown as + * "not engaged" (fail-open). + */ +export interface BackpressureSignalSource { + read(): Promise; +} + +export type BackpressureMonitorOptions = { + enabled: boolean; + source: BackpressureSignalSource; + refreshIntervalMs?: number; + /** + * If set, a cached verdict older than this is treated as unknown (fail-open). + * Guards against the source silently going stale (e.g. hanging reads). + */ + maxVerdictAgeMs?: number; + /** + * If set, after backpressure releases the dequeue gate stays partially engaged + * for this long, skipping a linearly-decaying fraction of attempts so the + * aggregate dequeue rate ramps from ~0 to full instead of snapping to full and + * re-flooding a freshly-recovered cluster. 0/unset = instant resume. + */ + rampMs?: number; + /** Injectable RNG for the resume ramp; defaults to Math.random. */ + random?: () => number; + /** + * When true, the gates are inert (never skip dequeues, never freeze scale-up). + * computeEngaged() still reflects the real signal so it can be observed. + */ + dryRun?: boolean; + logger?: BackpressureLogger; + metrics?: BackpressureMetrics; +}; + +const DEFAULT_REFRESH_INTERVAL_MS = 1000; + +export class BackpressureMonitor { + private verdict: BackpressureVerdict | null = null; + private timer?: ReturnType; + private refreshInFlight = false; + private wasEngaged = false; + private releasedAt?: number; + + constructor(private readonly opts: BackpressureMonitorOptions) { + this.opts.metrics?.dryRun.set(this.opts.dryRun ? 1 : 0); + } + + start(): void { + if (!this.opts.enabled) { + return; + } + + void this.refreshTick(); + this.timer = setInterval( + () => void this.refreshTick(), + this.opts.refreshIntervalMs ?? DEFAULT_REFRESH_INTERVAL_MS + ); + } + + /** Skip a tick if the previous refresh is still in flight, so slow/hung reads can't stack. */ + private async refreshTick(): Promise { + if (this.refreshInFlight) { + return; + } + this.refreshInFlight = true; + try { + await this.refresh(); + } finally { + this.refreshInFlight = false; + } + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + /** + * Raw hard backpressure state: true while the (fresh) verdict says engaged, + * ignoring dry-run. Used for observability/metrics so the real signal is + * visible even when the gates are inert. + */ + computeEngaged(): boolean { + const verdict = this.verdict; + if (verdict?.engaged !== true) { + return false; + } + + // When staleness enforcement is on, an engaged verdict must carry a fresh + // timestamp. A missing or stale ts can't be trusted (a dead producer could + // otherwise pin the brake forever), so fail open. + const maxAge = this.opts.maxVerdictAgeMs; + if (maxAge !== undefined) { + if (verdict.ts === undefined || Date.now() - verdict.ts > maxAge) { + return false; + } + } + + return true; + } + + /** + * Effective hard state: the signal for freezing consumer-pool scale-up. Inert + * (false) in dry-run. Hot-path read, no I/O. + */ + isEngaged(): boolean { + return this.opts.dryRun ? false : this.computeEngaged(); + } + + /** Hot-path read: synchronous, never performs I/O. Inert (false) in dry-run. */ + shouldSkipDequeue(): boolean { + const wouldSkip = this.computeShouldSkip(); + if (wouldSkip) { + this.opts.metrics?.skipsTotal.inc({ dry_run: this.opts.dryRun ? "true" : "false" }); + } + return this.opts.dryRun ? false : wouldSkip; + } + + private computeShouldSkip(): boolean { + if (this.computeEngaged()) { + return true; + } + + // Post-release ramp: skip a linearly-decaying fraction of attempts so the + // aggregate dequeue rate climbs back to full over rampMs rather than snapping. + const rampMs = this.opts.rampMs; + if (rampMs && this.releasedAt !== undefined) { + const elapsed = Date.now() - this.releasedAt; + if (elapsed < rampMs) { + const skipProbability = 1 - elapsed / rampMs; + return (this.opts.random ?? Math.random)() < skipProbability; + } + } + + return false; + } + + private async refresh(): Promise { + try { + this.verdict = await this.opts.source.read(); + } catch { + // Fail-open: a dead/unreachable source must never pin the brake. Treat as + // unknown (no verdict) so dequeue resumes as if backpressure were off. + this.verdict = null; + } + + // Track the engagedโ†’released transition to anchor the resume ramp. Use the + // staleness-aware state so a stale verdict doesn't pin wasEngaged / the gauge. + const nowEngaged = this.computeEngaged(); + this.opts.metrics?.engaged.set(nowEngaged ? 1 : 0); + + if (nowEngaged !== this.wasEngaged) { + this.opts.logger?.info("backpressure verdict changed", { + engaged: nowEngaged, + dryRun: !!this.opts.dryRun, + }); + } + if (this.wasEngaged && !nowEngaged) { + this.releasedAt = Date.now(); + } + this.wasEngaged = nowEngaged; + } +} diff --git a/apps/supervisor/src/backpressure/k8sPodCountSignalSource.test.ts b/apps/supervisor/src/backpressure/k8sPodCountSignalSource.test.ts new file mode 100644 index 00000000000..8c7104cfb14 --- /dev/null +++ b/apps/supervisor/src/backpressure/k8sPodCountSignalSource.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect } from "vitest"; +import { parsePodCount, K8sPodCountSignalSource } from "./k8sPodCountSignalSource.js"; + +describe("parsePodCount", () => { + it("reads the pods object count", () => { + const text = [ + "# HELP apiserver_storage_objects Number of stored objects", + "# TYPE apiserver_storage_objects gauge", + 'apiserver_storage_objects{resource="pods"} 8421', + 'apiserver_storage_objects{resource="configmaps"} 17', + ].join("\n"); + expect(parsePodCount(text)).toBe(8421); + }); + + it("is tolerant of extra labels in any order", () => { + const text = 'apiserver_storage_objects{group="",resource="pods",extra="x"} 12'; + expect(parsePodCount(text)).toBe(12); + }); + + it("parses scientific notation", () => { + const text = 'apiserver_storage_objects{resource="pods"} 1.2e+04'; + expect(parsePodCount(text)).toBe(12000); + }); + + it("throws when the pods metric is absent", () => { + const text = 'apiserver_storage_objects{resource="configmaps"} 17'; + expect(() => parsePodCount(text)).toThrow(/not found/); + }); + + it("throws on a non-finite value (e.g. 1e999)", () => { + const text = 'apiserver_storage_objects{resource="pods"} 1e999'; + expect(() => parsePodCount(text)).toThrow(); + }); + + it("throws on a negative value", () => { + const text = 'apiserver_storage_objects{resource="pods"} -5'; + expect(() => parsePodCount(text)).toThrow(); + }); +}); + +function metrics(count: number): string { + return `apiserver_storage_objects{resource="pods"} ${count}`; +} + +describe("K8sPodCountSignalSource", () => { + it("engages at the engage threshold and reports the count", async () => { + const counts: number[] = []; + const source = new K8sPodCountSignalSource({ + fetchMetrics: async () => metrics(10000), + engageThreshold: 10000, + releaseThreshold: 5000, + reportPodCount: (c) => counts.push(c), + }); + const verdict = await source.read(); + expect(verdict.engaged).toBe(true); + expect(typeof verdict.ts).toBe("number"); + expect(counts).toEqual([10000]); + }); + + it("does not engage below the engage threshold", async () => { + const source = new K8sPodCountSignalSource({ + fetchMetrics: async () => metrics(9999), + engageThreshold: 10000, + releaseThreshold: 5000, + }); + expect((await source.read()).engaged).toBe(false); + }); + + it("stays engaged in the hysteresis band, releases only below release threshold", async () => { + let count = 10000; + const source = new K8sPodCountSignalSource({ + fetchMetrics: async () => metrics(count), + engageThreshold: 10000, + releaseThreshold: 5000, + }); + expect((await source.read()).engaged).toBe(true); // engage + count = 7000; + expect((await source.read()).engaged).toBe(true); // band -> still engaged + count = 4999; + expect((await source.read()).engaged).toBe(false); // below release -> off + count = 7000; + expect((await source.read()).engaged).toBe(false); // band again -> stays off + }); + + it("propagates scrape failures (monitor fails open on throw)", async () => { + const source = new K8sPodCountSignalSource({ + fetchMetrics: async () => { + throw new Error("connection refused"); + }, + engageThreshold: 10000, + releaseThreshold: 5000, + }); + await expect(source.read()).rejects.toThrow("connection refused"); + }); +}); diff --git a/apps/supervisor/src/backpressure/k8sPodCountSignalSource.ts b/apps/supervisor/src/backpressure/k8sPodCountSignalSource.ts new file mode 100644 index 00000000000..e1fa0b78b11 --- /dev/null +++ b/apps/supervisor/src/backpressure/k8sPodCountSignalSource.ts @@ -0,0 +1,46 @@ +import type { BackpressureSignalSource, BackpressureVerdict } from "./backpressureMonitor.js"; + +// Reads the apiserver's stored-pod-object count from a Prometheus /metrics scrape. +const POD_COUNT_RE = /^apiserver_storage_objects\{[^}]*resource="pods"[^}]*\}\s+([0-9.eE+]+)/m; + +export function parsePodCount(metricsText: string): number { + const match = metricsText.match(POD_COUNT_RE); + if (!match) { + throw new Error('apiserver_storage_objects{resource="pods"} not found in metrics'); + } + const value = Number(match[1]); + if (!Number.isFinite(value)) { + throw new Error(`unparseable pod count: ${match[1]}`); + } + return value; +} + +export type K8sPodCountSignalSourceOptions = { + fetchMetrics: () => Promise; + engageThreshold: number; + releaseThreshold: number; + reportPodCount?: (count: number) => void; +}; + +// Engage/release with hysteresis so a count hovering near the line doesn't flap. +export class K8sPodCountSignalSource implements BackpressureSignalSource { + private engaged = false; + + constructor(private readonly opts: K8sPodCountSignalSourceOptions) {} + + async read(): Promise { + const text = await this.opts.fetchMetrics(); + const count = parsePodCount(text); + this.opts.reportPodCount?.(count); + + if (this.engaged) { + if (count < this.opts.releaseThreshold) { + this.engaged = false; + } + } else if (count >= this.opts.engageThreshold) { + this.engaged = true; + } + + return { engaged: this.engaged, ts: Date.now() }; + } +} diff --git a/apps/supervisor/src/backpressure/redisBackpressureSignalSource.test.ts b/apps/supervisor/src/backpressure/redisBackpressureSignalSource.test.ts new file mode 100644 index 00000000000..dc8040a4c5e --- /dev/null +++ b/apps/supervisor/src/backpressure/redisBackpressureSignalSource.test.ts @@ -0,0 +1,65 @@ +import { redisTest } from "@internal/testcontainers"; +import { Redis } from "ioredis"; +import { describe, expect } from "vitest"; +import { RedisBackpressureSignalSource } from "./redisBackpressureSignalSource.js"; + +const KEY = "backpressure:test"; + +describe("RedisBackpressureSignalSource", () => { + redisTest("returns null when the key is absent", async ({ redisOptions }) => { + const redis = new Redis(redisOptions); + try { + const source = new RedisBackpressureSignalSource(redis, KEY); + expect(await source.read()).toBeNull(); + } finally { + await redis.quit(); + } + }); + + redisTest("parses a valid engaged verdict", async ({ redisOptions }) => { + const redis = new Redis(redisOptions); + try { + await redis.set(KEY, JSON.stringify({ engaged: true, ts: 1_700_000_000_000 })); + const source = new RedisBackpressureSignalSource(redis, KEY); + expect(await source.read()).toEqual({ engaged: true, ts: 1_700_000_000_000 }); + } finally { + await redis.quit(); + } + }); + + redisTest("parses a clear verdict", async ({ redisOptions }) => { + const redis = new Redis(redisOptions); + try { + await redis.set(KEY, JSON.stringify({ engaged: false })); + const source = new RedisBackpressureSignalSource(redis, KEY); + expect(await source.read()).toEqual({ engaged: false }); + } finally { + await redis.quit(); + } + }); + + redisTest("returns null for malformed JSON (fail-open)", async ({ redisOptions }) => { + const redis = new Redis(redisOptions); + try { + await redis.set(KEY, "not json {"); + const source = new RedisBackpressureSignalSource(redis, KEY); + expect(await source.read()).toBeNull(); + } finally { + await redis.quit(); + } + }); + + redisTest( + "returns null for valid JSON of the wrong shape (fail-open)", + async ({ redisOptions }) => { + const redis = new Redis(redisOptions); + try { + await redis.set(KEY, JSON.stringify({ foo: "bar" })); + const source = new RedisBackpressureSignalSource(redis, KEY); + expect(await source.read()).toBeNull(); + } finally { + await redis.quit(); + } + } + ); +}); diff --git a/apps/supervisor/src/backpressure/redisBackpressureSignalSource.ts b/apps/supervisor/src/backpressure/redisBackpressureSignalSource.ts new file mode 100644 index 00000000000..4f8a54c6247 --- /dev/null +++ b/apps/supervisor/src/backpressure/redisBackpressureSignalSource.ts @@ -0,0 +1,35 @@ +import type { Redis } from "ioredis"; +import { z } from "zod"; +import type { BackpressureSignalSource, BackpressureVerdict } from "./backpressureMonitor.js"; + +const VerdictSchema = z.object({ + engaged: z.boolean(), + ts: z.number().optional(), +}); + +/** Reads the backpressure verdict from a Redis key written by the cluster-side aggregator. */ +export class RedisBackpressureSignalSource implements BackpressureSignalSource { + constructor( + private readonly redis: Redis, + private readonly key: string + ) {} + + async read(): Promise { + const raw = await this.redis.get(this.key); + if (raw === null) { + return null; + } + + // A malformed or wrong-shaped value is treated as unknown (null) so the + // monitor fails open rather than acting on garbage. + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + return null; + } + + const parsed = VerdictSchema.safeParse(json); + return parsed.success ? parsed.data : null; + } +} diff --git a/apps/supervisor/src/clients/kubernetes.ts b/apps/supervisor/src/clients/kubernetes.ts new file mode 100644 index 00000000000..9ab8e5bb3c1 --- /dev/null +++ b/apps/supervisor/src/clients/kubernetes.ts @@ -0,0 +1,108 @@ +import * as k8s from "@kubernetes/client-node"; +import type { Informer, KubernetesObject, ListPromise } from "@kubernetes/client-node"; +import { assertExhaustive } from "@trigger.dev/core/utils"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import * as https from "node:https"; + +export const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local"; + +const logger = new SimpleStructuredLogger("kubernetes-client"); + +export function createK8sApi() { + const kubeConfig = getKubeConfig(); + + function makeInformer( + path: string, + listPromiseFn: ListPromise, + labelSelector?: string, + fieldSelector?: string + ): Informer { + return k8s.makeInformer(kubeConfig, path, listPromiseFn, labelSelector, fieldSelector); + } + + const api = { + core: kubeConfig.makeApiClient(k8s.CoreV1Api), + batch: kubeConfig.makeApiClient(k8s.BatchV1Api), + apps: kubeConfig.makeApiClient(k8s.AppsV1Api), + makeInformer, + }; + + return api; +} + +export type K8sApi = ReturnType; + +function getKubeConfig() { + logger.debug("getKubeConfig()", { RUNTIME_ENV }); + + const kubeConfig = new k8s.KubeConfig(); + + switch (RUNTIME_ENV) { + case "local": + kubeConfig.loadFromDefault(); + break; + case "kubernetes": + kubeConfig.loadFromCluster(); + break; + default: + assertExhaustive(RUNTIME_ENV); + } + + return kubeConfig; +} + +export { k8s }; + +/** + * Builds a function that scrapes the apiserver's Prometheus /metrics endpoint. + * One lightweight aggregate read - not a pod listing. Requires the service + * account to be granted GET on the /metrics non-resource URL. + */ +export function createApiserverMetricsFetcher(timeoutMs: number): () => Promise { + const kubeConfig = getKubeConfig(); + + return async () => { + const cluster = kubeConfig.getCurrentCluster(); + if (!cluster) { + throw new Error("no current cluster in kubeconfig"); + } + const url = new URL(`${cluster.server}/metrics`); + const opts: https.RequestOptions = { + method: "GET", + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + path: url.pathname, + }; + // applyToHTTPSOptions sets the cluster CA, client cert/key, and auth headers + // (incl. exec plugins) on the request - so TLS verifies against the cluster + // CA, not the system store. The fetch-options path attaches the CA as an + // https.Agent, which global fetch (undici) ignores. + await kubeConfig.applyToHTTPSOptions(opts); + + return new Promise((resolve, reject) => { + const req = https.request(opts, (res) => { + const status = res.statusCode ?? 0; + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => { + if (status >= 200 && status < 300) { + resolve(body); + } else { + reject(new Error(`apiserver /metrics scrape failed: ${status}`)); + } + }); + }); + // Without this a hung connect/TLS/read never settles, and the monitor's + // refreshInFlight guard would freeze the source (silent fail-open). + req.setTimeout(timeoutMs, () => { + req.destroy(new Error(`apiserver /metrics scrape timed out after ${timeoutMs}ms`)); + }); + req.on("error", reject); + req.end(); + }); + }; +} diff --git a/apps/supervisor/src/env.test.ts b/apps/supervisor/src/env.test.ts new file mode 100644 index 00000000000..b02d117513c --- /dev/null +++ b/apps/supervisor/src/env.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi } from "vitest"; + +// Mock std-env before importing env.ts so the module-level `Env.parse(stdEnv)` +// doesn't fail in a test environment that lacks required vars. +vi.mock("std-env", () => ({ + env: { + TRIGGER_API_URL: "http://localhost:3030", + TRIGGER_WORKER_TOKEN: "test-token", + MANAGED_WORKER_SECRET: "test-secret", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318", + }, +})); + +const { Env } = await import("./env.js"); + +// Minimal env that satisfies all required fields; everything else has defaults. +const base = { + TRIGGER_API_URL: "http://localhost:3030", + TRIGGER_WORKER_TOKEN: "test-token", + MANAGED_WORKER_SECRET: "test-secret", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://localhost:4318", +}; + +describe("Env superRefine - backpressure source awareness", () => { + it("pod-count source can be enabled without a Redis host", () => { + expect(() => + Env.parse({ + ...base, + TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_ENABLED: "true", + }) + ).not.toThrow(); + }); + + it("redis source requires a Redis host", () => { + expect(() => + Env.parse({ + ...base, + TRIGGER_DEQUEUE_BACKPRESSURE_ENABLED: "true", + }) + ).toThrow(); + }); + + it("both sources can be enabled together (with a Redis host)", () => { + expect(() => + Env.parse({ + ...base, + TRIGGER_DEQUEUE_BACKPRESSURE_ENABLED: "true", + TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_HOST: "localhost", + TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_ENABLED: "true", + }) + ).not.toThrow(); + }); + + it("rejects pod-count release >= engage when the source is enabled", () => { + expect(() => + Env.parse({ + ...base, + TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_ENABLED: "true", + TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_ENGAGE: "100", + TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_RELEASE: "100", + }) + ).toThrow(); + }); +}); diff --git a/apps/supervisor/src/env.ts b/apps/supervisor/src/env.ts new file mode 100644 index 00000000000..32e669b5914 --- /dev/null +++ b/apps/supervisor/src/env.ts @@ -0,0 +1,385 @@ +import { randomUUID } from "crypto"; +import { env as stdEnv } from "std-env"; +import { z } from "zod"; +import { AdditionalEnvVars, BoolEnv } from "./envUtil.js"; + +export const Env = z + .object({ + // This will come from `spec.nodeName` in k8s + TRIGGER_WORKER_INSTANCE_NAME: z.string().default(randomUUID()), + TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30), + + // Opt-in, dev-only: stream this process's logs over a local telnet/TCP socket on this port. + SUPERVISOR_TELNET_LOGS_PORT: z.coerce.number().optional(), + + // Required settings + TRIGGER_API_URL: z.string().url(), + TRIGGER_WORKER_TOKEN: z.string(), // accepts file:// path to read from a file + MANAGED_WORKER_SECRET: z.string(), + OTEL_EXPORTER_OTLP_ENDPOINT: z.string().url(), // set on the runners + + // Workload API settings (coordinator mode) - the workload API is what the run controller connects to + TRIGGER_WORKLOAD_API_ENABLED: BoolEnv.default(true), + TRIGGER_WORKLOAD_API_PROTOCOL: z + .string() + .transform((s) => z.enum(["http", "https"]).parse(s.toLowerCase())) + .default("http"), + TRIGGER_WORKLOAD_API_DOMAIN: z.string().optional(), // If unset, will use orchestrator-specific default + TRIGGER_WORKLOAD_API_HOST_INTERNAL: z.string().default("0.0.0.0"), + TRIGGER_WORKLOAD_API_PORT_INTERNAL: z.coerce.number().default(8020), // This is the port the workload API listens on + TRIGGER_WORKLOAD_API_PORT_EXTERNAL: z.coerce.number().default(8020), // This is the exposed port passed to the run controller + + // Runner settings + RUNNER_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().optional(), + RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS: z.coerce.number().optional(), + RUNNER_ADDITIONAL_ENV_VARS: AdditionalEnvVars, // optional (csv) + RUNNER_PRETTY_LOGS: BoolEnv.default(false), + + // Dequeue settings (provider mode) + TRIGGER_DEQUEUE_ENABLED: BoolEnv.default(true), + // Which worker-queue class this supervisor fleet serves. "default" pulls the + // region queue (standard/agent runs); "scheduled" pulls the dedicated + // scheduled-lineage queue. Run a separate fleet per class for isolation. + TRIGGER_WORKER_QUEUE_CLASS: z.enum(["default", "scheduled"]).default("default"), + TRIGGER_DEQUEUE_INTERVAL_MS: z.coerce.number().int().default(250), + TRIGGER_DEQUEUE_IDLE_INTERVAL_MS: z.coerce.number().int().default(1000), + TRIGGER_DEQUEUE_MAX_RUN_COUNT: z.coerce.number().int().default(1), + TRIGGER_DEQUEUE_MIN_CONSUMER_COUNT: z.coerce.number().int().default(1), + TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT: z.coerce.number().int().default(10), + TRIGGER_DEQUEUE_SCALING_STRATEGY: z.enum(["none", "smooth", "aggressive"]).default("none"), + TRIGGER_DEQUEUE_SCALING_UP_COOLDOWN_MS: z.coerce.number().int().default(5000), // 5 seconds + TRIGGER_DEQUEUE_SCALING_DOWN_COOLDOWN_MS: z.coerce.number().int().default(30000), // 30 seconds + TRIGGER_DEQUEUE_SCALING_TARGET_RATIO: z.coerce.number().default(1.0), // Target ratio of queue items to consumers (1.0 = 1 item per consumer) + TRIGGER_DEQUEUE_SCALING_EWMA_ALPHA: z.coerce.number().min(0).max(1).default(0.3), // Smooths queue length measurements (0=historical, 1=current) + TRIGGER_DEQUEUE_SCALING_BATCH_WINDOW_MS: z.coerce.number().int().positive().default(1000), // Batch window for metrics processing (ms) + TRIGGER_DEQUEUE_SCALING_DAMPING_FACTOR: z.coerce.number().min(0).max(1).default(0.7), // Smooths consumer count changes after EWMA (0=no scaling, 1=immediate) + + // Dequeue backpressure - off by default. When enabled, the supervisor reads a + // verdict from Redis (written by the cluster-side aggregator) and pauses dequeues + // while the worker cluster can't schedule pods. Disabled = total no-op: no Redis + // client is created, no reads happen, and the dequeue loop is unaffected. + TRIGGER_DEQUEUE_BACKPRESSURE_ENABLED: BoolEnv.default(false), + // Safety default: even when enabled, backpressure only logs what it would do. + // Set to false to actually skip dequeues / freeze scale-up. + TRIGGER_DEQUEUE_BACKPRESSURE_DRY_RUN: BoolEnv.default(true), + TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_KEY: z.string().default("engine:dequeue:backpressure"), + TRIGGER_DEQUEUE_BACKPRESSURE_REFRESH_MS: z.coerce.number().int().positive().default(1000), + TRIGGER_DEQUEUE_BACKPRESSURE_RAMP_MS: z.coerce.number().int().min(0).default(30_000), // Resume ramp window after release; 0 = instant resume + + TRIGGER_DEQUEUE_BACKPRESSURE_MAX_VERDICT_AGE_MS: z.coerce + .number() + .int() + .positive() + .default(15_000), // Stale verdict โ†’ fail-open (treat as not engaged) + TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_HOST: z.string().optional(), + TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_PORT: z.coerce.number().int().optional(), + TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_USERNAME: z.string().optional(), + TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_PASSWORD: z.string().optional(), + TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_TLS_DISABLED: BoolEnv.default(false), + TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_ENABLED: BoolEnv.default(false), + TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_DRY_RUN: BoolEnv.default(true), + TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_ENGAGE: z.coerce + .number() + .int() + .positive() + .default(10_000), + TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_RELEASE: z.coerce + .number() + .int() + .positive() + .default(5_000), + TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_REFRESH_MS: z.coerce + .number() + .int() + .positive() + .default(5_000), + // Hard timeout on the apiserver /metrics scrape. A hung request would otherwise + // never settle and freeze the monitor's refresh loop (fail-open silently). + TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_SCRAPE_TIMEOUT_MS: z.coerce + .number() + .int() + .positive() + .default(10_000), + + // Optional services + TRIGGER_WARM_START_URL: z.string().optional(), + TRIGGER_CHECKPOINT_URL: z.string().optional(), + TRIGGER_METADATA_URL: z.string().optional(), + + // Warm-start delivery verification: after a warm-start hit, probe the + // platform and cold-start the run if no runner acted on the dispatch + TRIGGER_WARM_START_VERIFY_ENABLED: BoolEnv.default(false), + TRIGGER_WARM_START_VERIFY_DELAY_MS: z.coerce + .number() + .int() + .min(1_000) + .max(60_000) + .default(10_000), + + // Used by the resource monitor + RESOURCE_MONITOR_ENABLED: BoolEnv.default(false), + RESOURCE_MONITOR_OVERRIDE_CPU_TOTAL: z.coerce.number().optional(), + RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB: z.coerce.number().optional(), + + // Docker settings + DOCKER_API_VERSION: z.string().optional(), + DOCKER_PLATFORM: z.string().optional(), // e.g. linux/amd64, linux/arm64 + DOCKER_STRIP_IMAGE_DIGEST: BoolEnv.default(true), + DOCKER_REGISTRY_USERNAME: z.string().optional(), + DOCKER_REGISTRY_PASSWORD: z.string().optional(), + DOCKER_REGISTRY_URL: z.string().optional(), // e.g. https://index.docker.io/v1 + DOCKER_ENFORCE_MACHINE_PRESETS: BoolEnv.default(true), + DOCKER_AUTOREMOVE_EXITED_CONTAINERS: BoolEnv.default(true), + /** + * Network mode to use for all runners. Supported standard values are: `bridge`, `host`, `none`, and `container:`. + * Any other value is taken as a custom network's name to which all runners should connect to. + * + * Accepts a list of comma-separated values to attach to multiple networks. Additional networks are interpreted as network names and will be attached after container creation. + * + * **WARNING**: Specifying multiple networks will slightly increase startup times. + * + * @default "host" + */ + DOCKER_RUNNER_NETWORKS: z.string().default("host"), + + // Compute settings + COMPUTE_GATEWAY_URL: z.string().url().optional(), + COMPUTE_GATEWAY_AUTH_TOKEN: z.string().optional(), + COMPUTE_GATEWAY_TIMEOUT_MS: z.coerce.number().int().default(30_000), + COMPUTE_SNAPSHOTS_ENABLED: BoolEnv.default(false), + COMPUTE_TRACE_SPANS_ENABLED: BoolEnv.default(true), + COMPUTE_TRACE_OTLP_ENDPOINT: z.string().url().optional(), // Override for span export (derived from TRIGGER_API_URL if unset) + COMPUTE_SNAPSHOT_DELAY_MS: z.coerce.number().int().min(0).max(60_000).default(5_000), + COMPUTE_SNAPSHOT_DISPATCH_LIMIT: z.coerce.number().int().min(1).max(100).default(10), + // Instance create retries for transient placement failures (1 = no retries) + COMPUTE_INSTANCE_CREATE_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(10).default(3), + COMPUTE_INSTANCE_CREATE_RETRY_BASE_DELAY_MS: z.coerce + .number() + .int() + .min(0) + .max(10_000) + .default(250), + + // Kubernetes settings + KUBERNETES_FORCE_ENABLED: BoolEnv.default(false), + KUBERNETES_NAMESPACE: z.string().default("default"), + KUBERNETES_WORKER_NODETYPE_LABEL: z.string().default("v4-worker"), + KUBERNETES_IMAGE_PULL_SECRETS: z.string().optional(), // csv + KUBERNETES_EPHEMERAL_STORAGE_SIZE_LIMIT: z.string().default("10Gi"), + KUBERNETES_EPHEMERAL_STORAGE_SIZE_REQUEST: z.string().default("2Gi"), + KUBERNETES_STRIP_IMAGE_DIGEST: BoolEnv.default(false), + KUBERNETES_CPU_REQUEST_MIN_CORES: z.coerce.number().min(0).default(0), + KUBERNETES_CPU_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(0.75), // Ratio of CPU limit, so 0.75 = 75% of CPU limit + KUBERNETES_MEMORY_REQUEST_MIN_GB: z.coerce.number().min(0).default(0), + KUBERNETES_MEMORY_REQUEST_RATIO: z.coerce.number().min(0).max(1).default(1), // Ratio of memory limit, so 1 = 100% of memory limit + + // Per-preset overrides of the global KUBERNETES_CPU_REQUEST_RATIO + KUBERNETES_CPU_REQUEST_RATIO_MICRO: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_SMALL_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_SMALL_2X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_2X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_LARGE_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_CPU_REQUEST_RATIO_LARGE_2X: z.coerce.number().min(0).max(1).optional(), + + // Per-preset overrides of the global KUBERNETES_MEMORY_REQUEST_RATIO + KUBERNETES_MEMORY_REQUEST_RATIO_MICRO: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_2X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_2X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_1X: z.coerce.number().min(0).max(1).optional(), + KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_2X: z.coerce.number().min(0).max(1).optional(), + + KUBERNETES_MEMORY_OVERHEAD_GB: z.coerce.number().min(0).optional(), // Optional memory overhead to add to the limit in GB + KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods + + // Pod DNS config โ€” override the cluster default ndots to `KUBERNETES_POD_DNS_NDOTS`. + // Default k8s ndots is 5: any name with fewer than 5 dots (e.g. `api.example.com`, 2 dots) is first walked + // through every entry in the cluster search list (`.svc.cluster.local`, `svc.cluster.local`, `cluster.local`) + // before being tried as-is, turning one resolution into 4+ CoreDNS queries (ร—2 with A+AAAA). + // Overriding the default can be useful to cut CoreDNS query amplification for external domains. + // Note: before enabling, make sure no code path relies on search-list expansion for names with dots โ‰ฅ the value + // set here โ€” those names will now hit their as-is form first and could resolve externally before falling back. + KUBERNETES_POD_DNS_NDOTS_OVERRIDE_ENABLED: BoolEnv.default(false), + KUBERNETES_POD_DNS_NDOTS: z.coerce.number().int().min(1).max(15).default(2), + // Large machine affinity settings - large-* presets prefer a dedicated pool + KUBERNETES_LARGE_MACHINE_AFFINITY_ENABLED: BoolEnv.default(false), + KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_KEY: z + .string() + .trim() + .min(1) + .default("node.cluster.x-k8s.io/machinepool"), + KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_VALUE: z + .string() + .trim() + .min(1) + .default("large-machines"), + KUBERNETES_LARGE_MACHINE_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(100), + + // Project affinity settings - pods from the same project prefer the same node + KUBERNETES_PROJECT_AFFINITY_ENABLED: BoolEnv.default(false), + KUBERNETES_PROJECT_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(50), + KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY: z + .string() + .trim() + .min(1) + .default("kubernetes.io/hostname"), + + // Schedule affinity settings - runs from schedule trees prefer a dedicated pool + KUBERNETES_SCHEDULED_RUN_AFFINITY_ENABLED: BoolEnv.default(false), + KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_KEY: z + .string() + .trim() + .min(1) + .default("node.cluster.x-k8s.io/machinepool"), + KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_VALUE: z + .string() + .trim() + .min(1) + .default("scheduled-runs"), + KUBERNETES_SCHEDULED_RUN_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(80), + KUBERNETES_SCHEDULED_RUN_ANTI_AFFINITY_WEIGHT: z.coerce + .number() + .int() + .min(1) + .max(100) + .default(20), + + // Schedule toleration settings - scheduled runs tolerate taints on the dedicated pool + // Comma-separated list of tolerations in the format: key=value:effect + // For Exists operator (no value): key:effect + KUBERNETES_SCHEDULED_RUN_TOLERATIONS: z + .string() + .transform((val, ctx) => { + const tolerations = val + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + .map((entry) => { + const colonIdx = entry.lastIndexOf(":"); + if (colonIdx === -1) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid toleration format (missing effect): "${entry}"`, + }); + return z.NEVER; + } + + const effect = entry.slice(colonIdx + 1); + const validEffects = ["NoSchedule", "NoExecute", "PreferNoSchedule"]; + if (!validEffects.includes(effect)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid toleration effect "${effect}" in "${entry}". Must be one of: ${validEffects.join( + ", " + )}`, + }); + return z.NEVER; + } + + const keyValue = entry.slice(0, colonIdx); + const eqIdx = keyValue.indexOf("="); + const key = eqIdx === -1 ? keyValue : keyValue.slice(0, eqIdx); + + if (!key) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid toleration format (empty key): "${entry}"`, + }); + return z.NEVER; + } + + if (eqIdx === -1) { + return { key, operator: "Exists" as const, effect }; + } + + return { + key, + operator: "Equal" as const, + value: keyValue.slice(eqIdx + 1), + effect, + }; + }); + + return tolerations; + }) + .optional(), + + // Placement tags settings + PLACEMENT_TAGS_ENABLED: BoolEnv.default(false), + PLACEMENT_TAGS_PREFIX: z.string().default("node.cluster.x-k8s.io"), + + // Metrics + METRICS_ENABLED: BoolEnv.default(true), + METRICS_COLLECT_DEFAULTS: BoolEnv.default(true), + METRICS_HOST: z.string().default("127.0.0.1"), + METRICS_PORT: z.coerce.number().int().default(9090), + + // Pod cleaner + POD_CLEANER_ENABLED: BoolEnv.default(true), + POD_CLEANER_INTERVAL_MS: z.coerce.number().int().default(10000), + POD_CLEANER_BATCH_SIZE: z.coerce.number().int().default(500), + + // Failed pod handler + FAILED_POD_HANDLER_ENABLED: BoolEnv.default(true), + FAILED_POD_HANDLER_RECONNECT_INTERVAL_MS: z.coerce.number().int().default(1000), + + // Debug + DEBUG: BoolEnv.default(false), + SEND_RUN_DEBUG_LOGS: BoolEnv.default(false), + + // Wide-event observability - off by default. Emits one flat-keyed JSON + // line per natural unit of work (dequeue iteration, HTTP request, socket + // lifecycle). High-QPS hotpath, so the kill switch must be honoured. + TRIGGER_WIDE_EVENTS_ENABLED: BoolEnv.default(false), + // When true, also emit wide events for high-frequency HTTP routes + // (heartbeat, snapshots-since, logs/debug). Off in prod to keep event + // volume manageable; on in test environments for full-fidelity debugging. + TRIGGER_WIDE_EVENTS_NOISY_ROUTES: BoolEnv.default(false), + }) + .superRefine((data, ctx) => { + if ( + data.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_ENABLED && + data.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_RELEASE >= + data.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_ENGAGE + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_RELEASE must be less than TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_ENGAGE", + path: ["TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_RELEASE"], + }); + } + if (data.COMPUTE_SNAPSHOTS_ENABLED && !data.TRIGGER_METADATA_URL) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "TRIGGER_METADATA_URL is required when COMPUTE_SNAPSHOTS_ENABLED is true", + path: ["TRIGGER_METADATA_URL"], + }); + } + if (data.COMPUTE_SNAPSHOTS_ENABLED && !data.TRIGGER_WORKLOAD_API_DOMAIN) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "TRIGGER_WORKLOAD_API_DOMAIN is required when COMPUTE_SNAPSHOTS_ENABLED is true", + path: ["TRIGGER_WORKLOAD_API_DOMAIN"], + }); + } + if ( + data.TRIGGER_DEQUEUE_BACKPRESSURE_ENABLED && + !data.TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_HOST + ) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_HOST is required when TRIGGER_DEQUEUE_BACKPRESSURE_ENABLED is true", + path: ["TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_HOST"], + }); + } + }) + .transform((data) => ({ + ...data, + COMPUTE_TRACE_OTLP_ENDPOINT: data.COMPUTE_TRACE_OTLP_ENDPOINT ?? `${data.TRIGGER_API_URL}/otel`, + })); + +export const env = Env.parse(stdEnv); diff --git a/apps/supervisor/src/envUtil.test.ts b/apps/supervisor/src/envUtil.test.ts new file mode 100644 index 00000000000..c3d35758f16 --- /dev/null +++ b/apps/supervisor/src/envUtil.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "vitest"; +import { BoolEnv, AdditionalEnvVars } from "./envUtil.js"; + +describe("BoolEnv", () => { + it("should parse string 'true' as true", () => { + expect(BoolEnv.parse("true")).toBe(true); + expect(BoolEnv.parse("TRUE")).toBe(true); + expect(BoolEnv.parse("True")).toBe(true); + }); + + it("should parse string '1' as true", () => { + expect(BoolEnv.parse("1")).toBe(true); + }); + + it("should parse string 'false' as false", () => { + expect(BoolEnv.parse("false")).toBe(false); + expect(BoolEnv.parse("FALSE")).toBe(false); + expect(BoolEnv.parse("False")).toBe(false); + }); + + it("should handle whitespace", () => { + expect(BoolEnv.parse(" true ")).toBe(true); + expect(BoolEnv.parse(" 1 ")).toBe(true); + }); + + it("should pass through boolean values", () => { + expect(BoolEnv.parse(true)).toBe(true); + expect(BoolEnv.parse(false)).toBe(false); + }); + + it("should return false for invalid inputs", () => { + expect(BoolEnv.parse("invalid")).toBe(false); + expect(BoolEnv.parse("")).toBe(false); + }); +}); + +describe("AdditionalEnvVars", () => { + it("should parse single key-value pair", () => { + expect(AdditionalEnvVars.parse("FOO=bar")).toEqual({ FOO: "bar" }); + }); + + it("should parse multiple key-value pairs", () => { + expect(AdditionalEnvVars.parse("FOO=bar,BAZ=qux")).toEqual({ + FOO: "bar", + BAZ: "qux", + }); + }); + + it("should handle whitespace", () => { + expect(AdditionalEnvVars.parse(" FOO = bar , BAZ = qux ")).toEqual({ + FOO: "bar", + BAZ: "qux", + }); + }); + + it("should return undefined for empty string", () => { + expect(AdditionalEnvVars.parse("")).toBeUndefined(); + }); + + it("should return undefined for invalid format", () => { + expect(AdditionalEnvVars.parse("invalid")).toBeUndefined(); + }); + + it("should skip invalid pairs but include valid ones", () => { + expect(AdditionalEnvVars.parse("FOO=bar,INVALID,BAZ=qux")).toEqual({ + FOO: "bar", + BAZ: "qux", + }); + }); + + it("should pass through undefined", () => { + expect(AdditionalEnvVars.parse(undefined)).toBeUndefined(); + }); + + it("should handle empty values", () => { + expect(AdditionalEnvVars.parse("FOO=,BAR=value")).toEqual({ + BAR: "value", + }); + }); +}); diff --git a/apps/supervisor/src/envUtil.ts b/apps/supervisor/src/envUtil.ts new file mode 100644 index 00000000000..917f984cc37 --- /dev/null +++ b/apps/supervisor/src/envUtil.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; + +const logger = new SimpleStructuredLogger("env-util"); + +const baseBoolEnv = z.preprocess((val) => { + if (typeof val !== "string") { + return val; + } + + return ["true", "1"].includes(val.toLowerCase().trim()); +}, z.boolean()); + +// Create a type-safe version that only accepts boolean defaults +export const BoolEnv = baseBoolEnv as Omit & { + default: (value: boolean) => z.ZodDefault; +}; + +export const AdditionalEnvVars = z.preprocess((val) => { + if (typeof val !== "string") { + return val; + } + + if (!val) { + return undefined; + } + + try { + const result = val.split(",").reduce( + (acc, pair) => { + const [key, value] = pair.split("="); + if (!key || !value) { + return acc; + } + acc[key.trim()] = value.trim(); + return acc; + }, + {} as Record + ); + + // Return undefined if no valid key-value pairs were found + return Object.keys(result).length === 0 ? undefined : result; + } catch (error) { + logger.warn("Failed to parse additional env vars", { error, val }); + return undefined; + } +}, z.record(z.string(), z.string()).optional()); diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts new file mode 100644 index 00000000000..701b8a25e16 --- /dev/null +++ b/apps/supervisor/src/index.ts @@ -0,0 +1,765 @@ +import { SupervisorSession } from "@trigger.dev/core/v3/workers"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import { formatLogLine, startTelnetLogServer } from "@trigger.dev/core/v3/telnetLogServer"; +import { env } from "./env.js"; +import { WorkloadServer } from "./workloadServer/index.js"; +import type { WorkloadManagerOptions, WorkloadManager } from "./workloadManager/types.js"; +import Docker from "dockerode"; +import { z } from "zod"; +import { type DequeuedMessage } from "@trigger.dev/core/v3"; +import { + DockerResourceMonitor, + KubernetesResourceMonitor, + NoopResourceMonitor, + type ResourceMonitor, +} from "./resourceMonitor.js"; +import { KubernetesWorkloadManager } from "./workloadManager/kubernetes.js"; +import { DockerWorkloadManager } from "./workloadManager/docker.js"; +import { ComputeWorkloadManager } from "./workloadManager/compute.js"; +import { + HttpServer, + CheckpointClient, + isKubernetesEnvironment, +} from "@trigger.dev/core/v3/serverOnly"; +import { createK8sApi, createApiserverMetricsFetcher } from "./clients/kubernetes.js"; +import { collectDefaultMetrics, Gauge, Histogram } from "prom-client"; +import { register } from "./metrics.js"; +import { PodCleaner } from "./services/podCleaner.js"; +import { FailedPodHandler } from "./services/failedPodHandler.js"; +import { getWorkerToken } from "./workerToken.js"; +import { OtlpTraceService } from "./services/otlpTraceService.js"; +import { + WarmStartVerificationService, + type WarmStartTimings, +} from "./services/warmStartVerificationService.js"; +import { extractTraceparent, getRestoreRunnerId } from "./util.js"; +import { Redis } from "ioredis"; +import { BackpressureMonitor } from "./backpressure/backpressureMonitor.js"; +import { RedisBackpressureSignalSource } from "./backpressure/redisBackpressureSignalSource.js"; +import { BackpressureMetrics } from "./backpressure/backpressureMetrics.js"; +import { K8sPodCountSignalSource } from "./backpressure/k8sPodCountSignalSource.js"; +import { + fromContext, + recordPhaseSince, + runWideEvent, + setExtra, + setMeta, + type WideEventOptions, +} from "./wideEvents/index.js"; + +if (env.METRICS_COLLECT_DEFAULTS) { + collectDefaultMetrics({ register }); +} + +const workloadCreateDuration = new Histogram({ + name: "workload_create_duration_seconds", + help: "Duration of workload manager create calls. A create may include backend-internal retries, so one observation can span multiple attempts.", + labelNames: ["backend", "outcome"], + buckets: [0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60], + registers: [register], +}); + +class ManagedSupervisor { + private readonly workerSession: SupervisorSession; + private readonly metricsServer?: HttpServer; + private readonly workloadServer: WorkloadServer; + private readonly workloadManager: WorkloadManager; + private readonly workloadManagerBackend: "compute" | "kubernetes" | "docker"; + private readonly computeManager?: ComputeWorkloadManager; + private readonly logger = new SimpleStructuredLogger("managed-supervisor"); + private readonly resourceMonitor: ResourceMonitor; + private readonly checkpointClient?: CheckpointClient; + private readonly warmStartVerifier?: WarmStartVerificationService; + + private readonly podCleaner?: PodCleaner; + private readonly failedPodHandler?: FailedPodHandler; + private readonly tracing?: OtlpTraceService; + private readonly backpressureMonitors: BackpressureMonitor[] = []; + private readonly backpressureRedis?: Redis; + + private readonly isKubernetes = isKubernetesEnvironment(env.KUBERNETES_FORCE_ENABLED); + private readonly warmStartUrl = env.TRIGGER_WARM_START_URL; + + private readonly wideEventOpts: WideEventOptions = { + service: "supervisor", + env: { nodeId: env.TRIGGER_WORKER_INSTANCE_NAME }, + enabled: env.TRIGGER_WIDE_EVENTS_ENABLED, + }; + private readonly wideEventsNoisyRoutes = env.TRIGGER_WIDE_EVENTS_NOISY_ROUTES; + + constructor() { + // Strip secret-like env vars before debug-logging the rest. Add any new + // secret env var here so it never lands in the DEBUG "Starting up" log. + const { + TRIGGER_WORKER_TOKEN, + MANAGED_WORKER_SECRET, + COMPUTE_GATEWAY_AUTH_TOKEN, + DOCKER_REGISTRY_PASSWORD, + TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_PASSWORD, + ...envWithoutSecrets + } = env; + + if (env.DEBUG) { + this.logger.debug("Starting up", { envWithoutSecrets }); + } + + if (this.warmStartUrl) { + this.logger.log("๐Ÿ”ฅ Warm starts enabled", { + warmStartUrl: this.warmStartUrl, + }); + } + + const workloadManagerOptions = { + workloadApiProtocol: env.TRIGGER_WORKLOAD_API_PROTOCOL, + workloadApiDomain: env.TRIGGER_WORKLOAD_API_DOMAIN, + workloadApiPort: env.TRIGGER_WORKLOAD_API_PORT_EXTERNAL, + warmStartUrl: this.warmStartUrl, + metadataUrl: env.TRIGGER_METADATA_URL, + imagePullSecrets: env.KUBERNETES_IMAGE_PULL_SECRETS?.split(","), + heartbeatIntervalSeconds: env.RUNNER_HEARTBEAT_INTERVAL_SECONDS, + snapshotPollIntervalSeconds: env.RUNNER_SNAPSHOT_POLL_INTERVAL_SECONDS, + additionalEnvVars: env.RUNNER_ADDITIONAL_ENV_VARS, + dockerAutoremove: env.DOCKER_AUTOREMOVE_EXITED_CONTAINERS, + checkpointsEnabled: !!env.TRIGGER_CHECKPOINT_URL, + } satisfies WorkloadManagerOptions; + + this.resourceMonitor = env.RESOURCE_MONITOR_ENABLED + ? this.isKubernetes + ? new KubernetesResourceMonitor(createK8sApi(), env.TRIGGER_WORKER_INSTANCE_NAME) + : new DockerResourceMonitor(new Docker()) + : new NoopResourceMonitor(); + + if (env.COMPUTE_GATEWAY_URL) { + if (!env.TRIGGER_WORKLOAD_API_DOMAIN) { + throw new Error("TRIGGER_WORKLOAD_API_DOMAIN is not set, cannot create compute manager"); + } + + const callbackUrl = `${env.TRIGGER_WORKLOAD_API_PROTOCOL}://${env.TRIGGER_WORKLOAD_API_DOMAIN}:${env.TRIGGER_WORKLOAD_API_PORT_EXTERNAL}/api/v1/compute/snapshot-complete`; + + if (env.COMPUTE_TRACE_SPANS_ENABLED) { + this.tracing = new OtlpTraceService({ + endpointUrl: env.COMPUTE_TRACE_OTLP_ENDPOINT, + }); + } + + const computeManager = new ComputeWorkloadManager({ + ...workloadManagerOptions, + gateway: { + url: env.COMPUTE_GATEWAY_URL, + authToken: env.COMPUTE_GATEWAY_AUTH_TOKEN, + timeoutMs: env.COMPUTE_GATEWAY_TIMEOUT_MS, + }, + snapshots: { + enabled: env.COMPUTE_SNAPSHOTS_ENABLED, + delayMs: env.COMPUTE_SNAPSHOT_DELAY_MS, + dispatchLimit: env.COMPUTE_SNAPSHOT_DISPATCH_LIMIT, + callbackUrl, + }, + tracing: this.tracing, + runner: { + instanceName: env.TRIGGER_WORKER_INSTANCE_NAME, + otelEndpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT, + prettyLogs: env.RUNNER_PRETTY_LOGS, + sendRunDebugLogs: env.SEND_RUN_DEBUG_LOGS, + }, + createRetry: { + maxAttempts: env.COMPUTE_INSTANCE_CREATE_MAX_ATTEMPTS, + baseDelayMs: env.COMPUTE_INSTANCE_CREATE_RETRY_BASE_DELAY_MS, + }, + }); + this.computeManager = computeManager; + this.workloadManager = computeManager; + this.workloadManagerBackend = "compute"; + } else if (this.isKubernetes) { + this.workloadManager = new KubernetesWorkloadManager(workloadManagerOptions); + this.workloadManagerBackend = "kubernetes"; + } else { + this.workloadManager = new DockerWorkloadManager(workloadManagerOptions); + this.workloadManagerBackend = "docker"; + } + + if (this.isKubernetes) { + if (env.POD_CLEANER_ENABLED) { + this.logger.log("๐Ÿงน Pod cleaner enabled", { + namespace: env.KUBERNETES_NAMESPACE, + batchSize: env.POD_CLEANER_BATCH_SIZE, + intervalMs: env.POD_CLEANER_INTERVAL_MS, + }); + this.podCleaner = new PodCleaner({ + register, + namespace: env.KUBERNETES_NAMESPACE, + batchSize: env.POD_CLEANER_BATCH_SIZE, + intervalMs: env.POD_CLEANER_INTERVAL_MS, + }); + } else { + this.logger.warn("Pod cleaner disabled"); + } + + if (env.FAILED_POD_HANDLER_ENABLED) { + this.logger.log("๐Ÿ” Failed pod handler enabled", { + namespace: env.KUBERNETES_NAMESPACE, + reconnectIntervalMs: env.FAILED_POD_HANDLER_RECONNECT_INTERVAL_MS, + }); + this.failedPodHandler = new FailedPodHandler({ + register, + namespace: env.KUBERNETES_NAMESPACE, + reconnectIntervalMs: env.FAILED_POD_HANDLER_RECONNECT_INTERVAL_MS, + }); + } else { + this.logger.warn("Failed pod handler disabled"); + } + } + + if (env.TRIGGER_DEQUEUE_INTERVAL_MS > env.TRIGGER_DEQUEUE_IDLE_INTERVAL_MS) { + this.logger.warn( + `โš ๏ธ TRIGGER_DEQUEUE_INTERVAL_MS (${env.TRIGGER_DEQUEUE_INTERVAL_MS}) is greater than TRIGGER_DEQUEUE_IDLE_INTERVAL_MS (${env.TRIGGER_DEQUEUE_IDLE_INTERVAL_MS}) - did you mix them up?` + ); + } + + // Redis-verdict source (external aggregator). Keeps existing metric names. + if (env.TRIGGER_DEQUEUE_BACKPRESSURE_ENABLED) { + this.backpressureRedis = new Redis({ + host: env.TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_HOST, + port: env.TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_PORT, + username: env.TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_USERNAME, + password: env.TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_PASSWORD, + ...(env.TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_TLS_DISABLED ? {} : { tls: {} }), + maxRetriesPerRequest: null, + }); + this.backpressureRedis.on("error", (error) => + this.logger.error("Backpressure redis error", { error: error.message }) + ); + this.backpressureMonitors.push( + new BackpressureMonitor({ + enabled: true, + source: new RedisBackpressureSignalSource( + this.backpressureRedis, + env.TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_KEY + ), + refreshIntervalMs: env.TRIGGER_DEQUEUE_BACKPRESSURE_REFRESH_MS, + maxVerdictAgeMs: env.TRIGGER_DEQUEUE_BACKPRESSURE_MAX_VERDICT_AGE_MS, + rampMs: env.TRIGGER_DEQUEUE_BACKPRESSURE_RAMP_MS, + dryRun: env.TRIGGER_DEQUEUE_BACKPRESSURE_DRY_RUN, + logger: this.logger, + metrics: new BackpressureMetrics({ register }), + }) + ); + this.logger.log("๐Ÿ›‘ Dequeue backpressure enabled (redis source)", { + key: env.TRIGGER_DEQUEUE_BACKPRESSURE_REDIS_KEY, + refreshIntervalMs: env.TRIGGER_DEQUEUE_BACKPRESSURE_REFRESH_MS, + dryRun: env.TRIGGER_DEQUEUE_BACKPRESSURE_DRY_RUN, + }); + } + + // Pod-count source (in-process apiserver scrape). Namespaced metrics so the + // redis source's metric names are preserved. + if (env.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_ENABLED) { + // RELEASE < ENGAGE is enforced in env.ts (superRefine), so it's valid here. + const podCountGauge = new Gauge({ + name: "supervisor_cluster_pod_count", + help: "Total pod objects stored in the cluster, scraped for backpressure", + registers: [register], + }); + this.backpressureMonitors.push( + new BackpressureMonitor({ + enabled: true, + source: new K8sPodCountSignalSource({ + fetchMetrics: createApiserverMetricsFetcher( + env.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_SCRAPE_TIMEOUT_MS + ), + engageThreshold: env.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_ENGAGE, + releaseThreshold: env.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_RELEASE, + reportPodCount: (count) => podCountGauge.set(count), + }), + refreshIntervalMs: env.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_REFRESH_MS, + maxVerdictAgeMs: env.TRIGGER_DEQUEUE_BACKPRESSURE_MAX_VERDICT_AGE_MS, + rampMs: env.TRIGGER_DEQUEUE_BACKPRESSURE_RAMP_MS, + dryRun: env.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_DRY_RUN, + logger: this.logger, + metrics: new BackpressureMetrics({ + register, + prefix: "supervisor_backpressure_pod_count", + }), + }) + ); + this.logger.log("๐Ÿ›‘ Dequeue backpressure enabled (pod-count source)", { + engage: env.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_ENGAGE, + release: env.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_RELEASE, + refreshIntervalMs: env.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_REFRESH_MS, + dryRun: env.TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_DRY_RUN, + }); + } + + this.workerSession = new SupervisorSession({ + workerToken: getWorkerToken(), + apiUrl: env.TRIGGER_API_URL, + instanceName: env.TRIGGER_WORKER_INSTANCE_NAME, + managedWorkerSecret: env.MANAGED_WORKER_SECRET, + dequeueIntervalMs: env.TRIGGER_DEQUEUE_INTERVAL_MS, + dequeueIdleIntervalMs: env.TRIGGER_DEQUEUE_IDLE_INTERVAL_MS, + queueConsumerEnabled: env.TRIGGER_DEQUEUE_ENABLED, + maxRunCount: env.TRIGGER_DEQUEUE_MAX_RUN_COUNT, + queueClass: env.TRIGGER_WORKER_QUEUE_CLASS, + metricsRegistry: register, + scaling: { + strategy: env.TRIGGER_DEQUEUE_SCALING_STRATEGY, + minConsumerCount: env.TRIGGER_DEQUEUE_MIN_CONSUMER_COUNT, + maxConsumerCount: env.TRIGGER_DEQUEUE_MAX_CONSUMER_COUNT, + scaleUpCooldownMs: env.TRIGGER_DEQUEUE_SCALING_UP_COOLDOWN_MS, + scaleDownCooldownMs: env.TRIGGER_DEQUEUE_SCALING_DOWN_COOLDOWN_MS, + targetRatio: env.TRIGGER_DEQUEUE_SCALING_TARGET_RATIO, + ewmaAlpha: env.TRIGGER_DEQUEUE_SCALING_EWMA_ALPHA, + batchWindowMs: env.TRIGGER_DEQUEUE_SCALING_BATCH_WINDOW_MS, + dampingFactor: env.TRIGGER_DEQUEUE_SCALING_DAMPING_FACTOR, + // Freeze scale-up while backpressure is hard-engaged (not during the resume + // ramp). Undefined when backpressure is disabled โ†’ no effect on scaling. + shouldPauseScaling: () => this.backpressureMonitors.some((m) => m.isEngaged()), + }, + runNotificationsEnabled: env.TRIGGER_WORKLOAD_API_ENABLED, + heartbeatIntervalSeconds: env.TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS, + sendRunDebugLogs: env.SEND_RUN_DEBUG_LOGS, + preDequeue: async () => { + // Synchronous, hot-path-safe cached read; false when no monitors are active. + const skipForBackpressure = this.backpressureMonitors.some((m) => m.shouldSkipDequeue()); + + if (!env.RESOURCE_MONITOR_ENABLED || this.isKubernetes) { + // Resource monitor is not used in k8s; backpressure is the only gate there. + return { skipDequeue: skipForBackpressure }; + } + + const resources = await this.resourceMonitor.getNodeResources(); + + return { + maxResources: { + cpu: resources.cpuAvailable, + memory: resources.memoryAvailable, + }, + skipDequeue: + skipForBackpressure || + resources.cpuAvailable < 0.25 || + resources.memoryAvailable < 0.25, + }; + }, + preSkip: async () => { + // When the node is full, it should still try to warm start runs + // await this.tryWarmStartAllThisNode(); + }, + }); + + if (env.TRIGGER_CHECKPOINT_URL) { + this.logger.log("๐Ÿฅถ Checkpoints enabled", { + checkpointUrl: env.TRIGGER_CHECKPOINT_URL, + }); + + this.checkpointClient = new CheckpointClient({ + apiUrl: new URL(env.TRIGGER_CHECKPOINT_URL), + workerClient: this.workerSession.httpClient, + orchestrator: this.isKubernetes ? "KUBERNETES" : "DOCKER", + }); + } + + if (env.TRIGGER_WARM_START_VERIFY_ENABLED && this.warmStartUrl) { + this.logger.log("Warm-start delivery verification enabled", { + delayMs: env.TRIGGER_WARM_START_VERIFY_DELAY_MS, + }); + + this.warmStartVerifier = new WarmStartVerificationService({ + workerClient: this.workerSession.httpClient, + delayMs: env.TRIGGER_WARM_START_VERIFY_DELAY_MS, + createWorkload: (message, timings) => this.createWorkload(message, timings), + wideEventOpts: this.wideEventOpts, + }); + } + + this.workerSession.on("runNotification", async ({ time, run }) => { + this.logger.verbose("runNotification", { time, run }); + + this.workloadServer.notifyRun({ run }); + }); + + this.workerSession.on( + "runQueueMessage", + async ({ time, message, dequeueResponseMs, pollingIntervalMs }) => { + this.logger.verbose(`Received message with timestamp ${time.toLocaleString()}`, message); + + const traceparent = extractTraceparent(message.run.traceContext); + + await runWideEvent( + { + ...this.wideEventOpts, + op: "dequeue", + kind: "inbound", + traceparent, + setup: (state) => { + setMeta(state, "run_id", message.run.friendlyId); + setMeta(state, "env_id", message.environment.id); + setMeta(state, "org_id", message.organization.id); + setMeta(state, "project_id", message.project.id); + if (message.deployment.friendlyId) { + setMeta(state, "deployment_id", message.deployment.friendlyId); + } + setMeta(state, "machine_preset", message.run.machine.name); + state.extras.iteration = "dequeue"; + state.extras.dequeue_response_ms = dequeueResponseMs; + state.extras.polling_interval_ms = pollingIntervalMs; + state.extras.completed_waitpoints = message.completedWaitpoints.length; + }, + }, + async () => { + if (message.completedWaitpoints.length > 0) { + this.logger.debug("Run has completed waitpoints", { + runId: message.run.id, + completedWaitpoints: message.completedWaitpoints.length, + }); + } + + if (!message.image) { + setExtra(fromContext(), "path_taken", "skipped_no_image"); + this.logger.error("Run has no image", { runId: message.run.id }); + return; + } + + const { checkpoint, ...rest } = message; + + // Register trace context early so snapshot spans work for all paths + // (cold create, restore, warm start). Re-registration on restore is safe + // since dequeue always provides fresh context. + if (this.computeManager?.traceSpansEnabled && traceparent) { + this.workloadServer.registerRunTraceContext(message.run.friendlyId, { + traceparent, + envId: message.environment.id, + orgId: message.organization.id, + projectId: message.project.id, + }); + } + + if (checkpoint) { + setExtra(fromContext(), "path_taken", "restore"); + this.logger.debug("Restoring run", { runId: message.run.id }); + + if (this.computeManager) { + const restoreStart = performance.now(); + try { + const runnerId = getRestoreRunnerId(message.run.friendlyId, checkpoint.id); + + const didRestore = await this.computeManager.restore({ + snapshotId: checkpoint.location, + runnerId, + runFriendlyId: message.run.friendlyId, + snapshotFriendlyId: message.snapshot.friendlyId, + machine: message.run.machine, + traceContext: message.run.traceContext, + envId: message.environment.id, + orgId: message.organization.id, + projectId: message.project.id, + hasPrivateLink: message.organization.hasPrivateLink, + dequeuedAt: message.dequeuedAt, + }); + recordPhaseSince("restore", restoreStart, undefined); + setExtra(fromContext(), "did_restore", didRestore); + + if (didRestore) { + this.logger.debug("Compute restore successful", { + runId: message.run.id, + runnerId, + }); + } else { + this.logger.error("Compute restore failed", { + runId: message.run.id, + runnerId, + }); + } + } catch (error) { + recordPhaseSince( + "restore", + restoreStart, + error instanceof Error ? error : new Error(String(error)) + ); + this.logger.error("Failed to restore run (compute)", { error }); + } + + return; + } + + if (!this.checkpointClient) { + this.logger.error("No checkpoint client", { runId: message.run.id }); + return; + } + + const restoreStart = performance.now(); + try { + const didRestore = await this.checkpointClient.restoreRun({ + runFriendlyId: message.run.friendlyId, + snapshotFriendlyId: message.snapshot.friendlyId, + body: { + ...rest, + checkpoint, + }, + }); + recordPhaseSince("restore", restoreStart, undefined); + setExtra(fromContext(), "did_restore", didRestore); + + if (didRestore) { + this.logger.debug("Restore successful", { runId: message.run.id }); + } else { + this.logger.error("Restore failed", { runId: message.run.id }); + } + } catch (error) { + recordPhaseSince( + "restore", + restoreStart, + error instanceof Error ? error : new Error(String(error)) + ); + this.logger.error("Failed to restore run", { error }); + } + + return; + } + + this.logger.debug("Scheduling run", { runId: message.run.id }); + + const warmStartStart = performance.now(); + const didWarmStart = await this.tryWarmStart(message, traceparent); + const warmStartCheckMs = Math.round(performance.now() - warmStartStart); + recordPhaseSince("warm_start", warmStartStart, undefined); + setExtra(fromContext(), "did_warm_start", didWarmStart); + + if (didWarmStart) { + setExtra(fromContext(), "path_taken", "warm_start"); + this.logger.debug("Warm start successful", { runId: message.run.id }); + // A hit only means the response was written to the long-poll + // socket, not that the runner received it. Schedule a delivery + // verification that cold-starts the run if nobody acts on it. + this.warmStartVerifier?.schedule(message, { + dequeueResponseMs, + pollingIntervalMs, + warmStartCheckMs, + }); + return; + } + + setExtra(fromContext(), "path_taken", "cold_create"); + + await this.createWorkload(message, { + dequeueResponseMs, + pollingIntervalMs, + warmStartCheckMs, + }); + } + ); + } + ); + + if (env.METRICS_ENABLED) { + this.metricsServer = new HttpServer({ + port: env.METRICS_PORT, + host: env.METRICS_HOST, + metrics: { + register, + expose: true, + }, + }); + } + + // Responds to workload requests only + this.workloadServer = new WorkloadServer({ + port: env.TRIGGER_WORKLOAD_API_PORT_INTERNAL, + host: env.TRIGGER_WORKLOAD_API_HOST_INTERNAL, + workerClient: this.workerSession.httpClient, + checkpointClient: this.checkpointClient, + computeManager: this.computeManager, + tracing: this.tracing, + wideEventOpts: this.wideEventOpts, + wideEventsNoisyRoutes: this.wideEventsNoisyRoutes, + }); + + this.workloadServer.on("runConnected", this.onRunConnected.bind(this)); + this.workloadServer.on("runDisconnected", this.onRunDisconnected.bind(this)); + } + + async onRunConnected({ run }: { run: { friendlyId: string } }) { + this.logger.debug("Run connected", { run }); + // The dispatched run reached a runner on this node - no fallback needed. + this.warmStartVerifier?.cancel(run.friendlyId); + this.workerSession.subscribeToRunNotifications([run.friendlyId]); + } + + async onRunDisconnected({ run }: { run: { friendlyId: string } }) { + this.logger.debug("Run disconnected", { run }); + this.workerSession.unsubscribeFromRunNotifications([run.friendlyId]); + } + + private async createWorkload(message: DequeuedMessage, timings: WarmStartTimings) { + const createStart = performance.now(); + try { + if (!message.deployment.friendlyId) { + // mostly a type guard, deployments always exists for deployed environments + // a proper fix would be to use a discriminated union schema to differentiate between dequeued runs in dev and in deployed environments. + throw new Error("Deployment is missing"); + } + + if (!message.image) { + // same type-guard situation as deployment above + throw new Error("Image is missing"); + } + + await this.workloadManager.create({ + dequeuedAt: message.dequeuedAt, + dequeueResponseMs: timings.dequeueResponseMs, + pollingIntervalMs: timings.pollingIntervalMs, + warmStartCheckMs: timings.warmStartCheckMs, + envId: message.environment.id, + envType: message.environment.type, + image: message.image, + machine: message.run.machine, + orgId: message.organization.id, + projectId: message.project.id, + deploymentFriendlyId: message.deployment.friendlyId, + deploymentVersion: message.backgroundWorker.version, + runtime: message.backgroundWorker.runtime, + runId: message.run.id, + runFriendlyId: message.run.friendlyId, + version: message.version, + nextAttemptNumber: message.run.attemptNumber, + snapshotId: message.snapshot.id, + snapshotFriendlyId: message.snapshot.friendlyId, + placementTags: message.placementTags, + traceContext: message.run.traceContext, + annotations: message.run.annotations, + hasPrivateLink: message.organization.hasPrivateLink, + }); + recordPhaseSince("workload_create", createStart, undefined); + workloadCreateDuration.observe( + { backend: this.workloadManagerBackend, outcome: "success" }, + (performance.now() - createStart) / 1000 + ); + + // Disabled for now + // this.resourceMonitor.blockResources({ + // cpu: message.run.machine.cpu, + // memory: message.run.machine.memory, + // }); + } catch (error) { + recordPhaseSince( + "workload_create", + createStart, + error instanceof Error ? error : new Error(String(error)) + ); + workloadCreateDuration.observe( + { backend: this.workloadManagerBackend, outcome: "error" }, + (performance.now() - createStart) / 1000 + ); + this.logger.error("Failed to create workload", { + runId: message.run.friendlyId, + error, + }); + } + } + + private async tryWarmStart( + dequeuedMessage: DequeuedMessage, + traceparent: string | undefined + ): Promise { + if (!this.warmStartUrl) { + return false; + } + + const warmStartUrlWithPath = new URL("/warm-start", this.warmStartUrl); + + const headers: Record = { + "Content-Type": "application/json", + }; + // Propagate the inbound W3C traceparent so the upstream warm-start + // receiver continues the same trace instead of minting a new one. Gated + // by the same kill switch as the wide-event emission so the whole PR is + // a no-op on the wire when disabled. + if (this.wideEventOpts.enabled && traceparent) { + headers.traceparent = traceparent; + } + + try { + const res = await fetch(warmStartUrlWithPath.href, { + method: "POST", + headers, + body: JSON.stringify({ dequeuedMessage }), + }); + + if (!res.ok) { + this.logger.error("Warm start failed", { + runId: dequeuedMessage.run.id, + }); + return false; + } + + const data = await res.json(); + const parsedData = z.object({ didWarmStart: z.boolean() }).safeParse(data); + + if (!parsedData.success) { + this.logger.error("Warm start response invalid", { + runId: dequeuedMessage.run.id, + data, + }); + return false; + } + + return parsedData.data.didWarmStart; + } catch (error) { + this.logger.error("Warm start error", { + runId: dequeuedMessage.run.id, + error, + }); + return false; + } + } + + async start() { + this.logger.log("Starting up"); + + // Optional services + this.backpressureMonitors.forEach((m) => m.start()); + await this.podCleaner?.start(); + await this.failedPodHandler?.start(); + await this.metricsServer?.start(); + + if (env.TRIGGER_WORKLOAD_API_ENABLED) { + this.logger.log("Workload API enabled", { + protocol: env.TRIGGER_WORKLOAD_API_PROTOCOL, + domain: env.TRIGGER_WORKLOAD_API_DOMAIN, + port: env.TRIGGER_WORKLOAD_API_PORT_INTERNAL, + }); + await this.workloadServer.start(); + } else { + this.logger.warn("Workload API disabled"); + } + + await this.workerSession.start(); + } + + async stop() { + this.logger.log("Shutting down"); + // Stop the verifier first: its timer can otherwise fire mid-shutdown and + // cold-create a workload on a node that is going down. + this.warmStartVerifier?.stop(); + await this.workloadServer.stop(); + await this.workerSession.stop(); + + // Optional services + this.backpressureMonitors.forEach((m) => m.stop()); + await this.backpressureRedis?.quit(); + await this.podCleaner?.stop(); + await this.failedPodHandler?.stop(); + await this.metricsServer?.stop(); + } +} + +// Opt-in, dev-only: mirror this process's structured logs to a local telnet/TCP stream. +if (env.SUPERVISOR_TELNET_LOGS_PORT && env.SUPERVISOR_TELNET_LOGS_PORT > 0) { + const telnetLogServer = startTelnetLogServer({ + port: env.SUPERVISOR_TELNET_LOGS_PORT, + name: "supervisor", + }); + SimpleStructuredLogger.onLog = (log) => telnetLogServer.broadcast(formatLogLine(log)); +} + +const worker = new ManagedSupervisor(); +worker.start(); diff --git a/apps/supervisor/src/metrics.ts b/apps/supervisor/src/metrics.ts new file mode 100644 index 00000000000..caec4861533 --- /dev/null +++ b/apps/supervisor/src/metrics.ts @@ -0,0 +1,3 @@ +import { Registry } from "prom-client"; + +export const register = new Registry(); diff --git a/apps/supervisor/src/resourceMonitor.ts b/apps/supervisor/src/resourceMonitor.ts new file mode 100644 index 00000000000..507a52bbf60 --- /dev/null +++ b/apps/supervisor/src/resourceMonitor.ts @@ -0,0 +1,278 @@ +import type Docker from "dockerode"; +import type { MachineResources } from "@trigger.dev/core/v3"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import { env } from "./env.js"; +import type { K8sApi } from "./clients/kubernetes.js"; + +const logger = new SimpleStructuredLogger("resource-monitor"); + +interface NodeResources { + cpuTotal: number; // in cores + cpuAvailable: number; + memoryTotal: number; // in bytes + memoryAvailable: number; +} + +interface ResourceRequest { + cpu: number; // in cores + memory: number; // in bytes +} + +export abstract class ResourceMonitor { + protected cacheTimeoutMs = 5_000; + protected lastUpdateMs = 0; + + protected cachedResources: NodeResources = { + cpuTotal: 0, + cpuAvailable: 0, + memoryTotal: 0, + memoryAvailable: 0, + }; + + protected resourceParser: ResourceParser; + + constructor(Parser: new () => ResourceParser) { + this.resourceParser = new Parser(); + } + + abstract getNodeResources(fromCache?: boolean): Promise; + + blockResources(resources: MachineResources): void { + const { cpu, memory } = this.toResourceRequest(resources); + + logger.debug("[ResourceMonitor] Blocking resources", { + raw: resources, + converted: { cpu, memory }, + }); + + this.cachedResources.cpuAvailable -= cpu; + this.cachedResources.memoryAvailable -= memory; + } + + async wouldFit(request: ResourceRequest): Promise { + const resources = await this.getNodeResources(); + return resources.cpuAvailable >= request.cpu && resources.memoryAvailable >= request.memory; + } + + private toResourceRequest(resources: MachineResources): ResourceRequest { + return { + cpu: resources.cpu ?? 0, + memory: this.gbToBytes(resources.memory ?? 0), + }; + } + + private gbToBytes(gb: number): number { + return gb * 1024 * 1024 * 1024; + } + + protected isCacheValid(): boolean { + return this.cachedResources !== null && Date.now() - this.lastUpdateMs < this.cacheTimeoutMs; + } + + protected applyOverrides(resources: NodeResources): NodeResources { + if ( + !env.RESOURCE_MONITOR_OVERRIDE_CPU_TOTAL && + !env.RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB + ) { + return resources; + } + + logger.debug("[ResourceMonitor] ๐Ÿ›ก๏ธ Applying resource overrides", { + cpuTotal: env.RESOURCE_MONITOR_OVERRIDE_CPU_TOTAL, + memoryTotalGb: env.RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB, + }); + + const cpuTotal = env.RESOURCE_MONITOR_OVERRIDE_CPU_TOTAL ?? resources.cpuTotal; + const memoryTotal = env.RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB + ? this.gbToBytes(env.RESOURCE_MONITOR_OVERRIDE_MEMORY_TOTAL_GB) + : resources.memoryTotal; + + const cpuDiff = cpuTotal - resources.cpuTotal; + const memoryDiff = memoryTotal - resources.memoryTotal; + + const cpuAvailable = Math.max(0, resources.cpuAvailable + cpuDiff); + const memoryAvailable = Math.max(0, resources.memoryAvailable + memoryDiff); + + return { + cpuTotal, + cpuAvailable, + memoryTotal, + memoryAvailable, + }; + } +} + +type SystemInfo = { + NCPU: number | undefined; + MemTotal: number | undefined; +}; + +export class DockerResourceMonitor extends ResourceMonitor { + private docker: Docker; + + constructor(docker: Docker) { + super(DockerResourceParser); + this.docker = docker; + } + + async getNodeResources(fromCache?: boolean): Promise { + if (this.isCacheValid() || fromCache) { + // logger.debug("[ResourceMonitor] Using cached resources"); + return this.cachedResources; + } + + const info: SystemInfo = await this.docker.info(); + const stats = await this.docker.listContainers({ all: true }); + + // Get system-wide resources + const cpuTotal = info.NCPU ?? 0; + const memoryTotal = info.MemTotal ?? 0; + + // Calculate used resources from running containers + let cpuUsed = 0; + let memoryUsed = 0; + + for (const container of stats) { + if (container.State === "running") { + const c = this.docker.getContainer(container.Id); + const { HostConfig } = await c.inspect(); + + const cpu = this.resourceParser.cpu(HostConfig.NanoCpus ?? 0); + const memory = this.resourceParser.memory(HostConfig.Memory ?? 0); + + cpuUsed += cpu; + memoryUsed += memory; + } + } + + this.cachedResources = this.applyOverrides({ + cpuTotal, + cpuAvailable: cpuTotal - cpuUsed, + memoryTotal, + memoryAvailable: memoryTotal - memoryUsed, + }); + + this.lastUpdateMs = Date.now(); + + return this.cachedResources; + } +} + +export class KubernetesResourceMonitor extends ResourceMonitor { + private k8s: K8sApi; + private nodeName: string; + + constructor(k8s: K8sApi, nodeName: string) { + super(KubernetesResourceParser); + this.k8s = k8s; + this.nodeName = nodeName; + } + + async getNodeResources(fromCache?: boolean): Promise { + if (this.isCacheValid() || fromCache) { + logger.debug("[ResourceMonitor] Using cached resources"); + return this.cachedResources; + } + + const node = await this.k8s.core.readNode({ name: this.nodeName }); + const pods = await this.k8s.core.listPodForAllNamespaces({ + // TODO: ensure this includes all pods that consume resources + fieldSelector: `spec.nodeName=${this.nodeName},status.phase=Running`, + }); + + const allocatable = node.status?.allocatable; + const cpuTotal = this.resourceParser.cpu(allocatable?.cpu ?? "0"); + const memoryTotal = this.resourceParser.memory(allocatable?.memory ?? "0"); + + // Sum up resources requested by all pods on this node + let cpuRequested = 0; + let memoryRequested = 0; + + for (const pod of pods.items) { + if (pod.status?.phase === "Running") { + if (!pod.spec) { + continue; + } + + for (const container of pod.spec.containers) { + const resources = container.resources?.requests ?? {}; + cpuRequested += this.resourceParser.cpu(resources.cpu ?? "0"); + memoryRequested += this.resourceParser.memory(resources.memory ?? "0"); + } + } + } + + this.cachedResources = this.applyOverrides({ + cpuTotal, + cpuAvailable: cpuTotal - cpuRequested, + memoryTotal, + memoryAvailable: memoryTotal - memoryRequested, + }); + + this.lastUpdateMs = Date.now(); + + return this.cachedResources; + } +} + +export class NoopResourceMonitor extends ResourceMonitor { + constructor() { + super(NoopResourceParser); + } + + async getNodeResources(): Promise { + return { + cpuTotal: 0, + cpuAvailable: Infinity, + memoryTotal: 0, + memoryAvailable: Infinity, + }; + } +} + +abstract class ResourceParser { + abstract cpu(cpu: number | string): number; + abstract memory(memory: number | string): number; +} + +class DockerResourceParser extends ResourceParser { + cpu(cpu: number): number { + return cpu / 1e9; + } + + memory(memory: number): number { + return memory; + } +} + +class KubernetesResourceParser extends ResourceParser { + cpu(cpu: string): number { + if (cpu.endsWith("m")) { + return parseInt(cpu.slice(0, -1)) / 1000; + } + return parseInt(cpu); + } + + memory(memory: string): number { + if (memory.endsWith("Ki")) { + return parseInt(memory.slice(0, -2)) * 1024; + } + if (memory.endsWith("Mi")) { + return parseInt(memory.slice(0, -2)) * 1024 * 1024; + } + if (memory.endsWith("Gi")) { + return parseInt(memory.slice(0, -2)) * 1024 * 1024 * 1024; + } + return parseInt(memory); + } +} + +class NoopResourceParser extends ResourceParser { + cpu(cpu: number): number { + return cpu; + } + + memory(memory: number): number { + return memory; + } +} diff --git a/apps/supervisor/src/services/computeSnapshotService.test.ts b/apps/supervisor/src/services/computeSnapshotService.test.ts new file mode 100644 index 00000000000..44a13a4bdd3 --- /dev/null +++ b/apps/supervisor/src/services/computeSnapshotService.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it, vi } from "vitest"; +import { setTimeout as sleep } from "node:timers/promises"; +import { ComputeSnapshotService } from "./computeSnapshotService.js"; +import type { ComputeWorkloadManager } from "../workloadManager/compute.js"; +import type { SupervisorHttpClient } from "@trigger.dev/core/v3/workers"; + +// The TimerWheel ticks every 100ms, so a 200ms delay dispatches within ~300ms. +const DELAY_MS = 200; +// Long enough that a pending snapshot would certainly have dispatched. +const SETTLE_MS = 600; + +function createService() { + const snapshot = vi.fn( + async (_opts: { runnerId: string; metadata: Record }) => true + ); + + const computeManager = { + snapshotDelayMs: DELAY_MS, + snapshotDispatchLimit: 1, + snapshot, + } as unknown as ComputeWorkloadManager; + + const service = new ComputeSnapshotService({ + computeManager, + workerClient: {} as SupervisorHttpClient, + wideEventOpts: { service: "supervisor-test", env: {}, enabled: false }, + }); + + return { service, snapshot }; +} + +function delayedSnapshot(runnerId = "runner-1") { + return { + runnerId, + runFriendlyId: "run_1", + snapshotFriendlyId: "snapshot_1", + }; +} + +describe("ComputeSnapshotService", () => { + it("dispatches a scheduled snapshot after the delay", async () => { + const { service, snapshot } = createService(); + try { + service.schedule("run_1", delayedSnapshot()); + + await vi.waitFor(() => expect(snapshot).toHaveBeenCalledTimes(1), { timeout: 2_000 }); + expect(snapshot).toHaveBeenCalledWith({ + runnerId: "runner-1", + metadata: { runId: "run_1", snapshotFriendlyId: "snapshot_1" }, + }); + } finally { + service.stop(); + } + }); + + it("cancel before the delay expires prevents the dispatch", async () => { + const { service, snapshot } = createService(); + try { + service.schedule("run_1", delayedSnapshot()); + + expect(service.cancel("run_1")).toBe(true); + + await sleep(SETTLE_MS); + expect(snapshot).not.toHaveBeenCalled(); + } finally { + service.stop(); + } + }); + + it("cancel returns false when nothing is pending", () => { + const { service } = createService(); + try { + expect(service.cancel("run_1")).toBe(false); + } finally { + service.stop(); + } + }); + + it("cancel with a matching runnerId cancels the pending snapshot", async () => { + const { service, snapshot } = createService(); + try { + service.schedule("run_1", delayedSnapshot("runner-a")); + + expect(service.cancel("run_1", "runner-a")).toBe(true); + + await sleep(SETTLE_MS); + expect(snapshot).not.toHaveBeenCalled(); + } finally { + service.stop(); + } + }); + + it("cancel with a different runnerId leaves the pending snapshot alone", async () => { + const { service, snapshot } = createService(); + try { + service.schedule("run_1", delayedSnapshot("runner-a")); + + // A stale runner for a reassigned run must not cancel the new runner's snapshot. + expect(service.cancel("run_1", "runner-b")).toBe(false); + + await vi.waitFor(() => expect(snapshot).toHaveBeenCalledTimes(1), { timeout: 2_000 }); + expect(snapshot).toHaveBeenCalledWith(expect.objectContaining({ runnerId: "runner-a" })); + } finally { + service.stop(); + } + }); + + it("re-scheduling the same run replaces the pending snapshot", async () => { + const { service, snapshot } = createService(); + try { + service.schedule("run_1", delayedSnapshot()); + service.schedule("run_1", { + runnerId: "runner-1", + runFriendlyId: "run_1", + snapshotFriendlyId: "snapshot_2", + }); + + await vi.waitFor(() => expect(snapshot).toHaveBeenCalledTimes(1), { timeout: 2_000 }); + await sleep(SETTLE_MS); + + expect(snapshot).toHaveBeenCalledTimes(1); + expect(snapshot).toHaveBeenCalledWith({ + runnerId: "runner-1", + metadata: { runId: "run_1", snapshotFriendlyId: "snapshot_2" }, + }); + } finally { + service.stop(); + } + }); +}); diff --git a/apps/supervisor/src/services/computeSnapshotService.ts b/apps/supervisor/src/services/computeSnapshotService.ts new file mode 100644 index 00000000000..216753fc12d --- /dev/null +++ b/apps/supervisor/src/services/computeSnapshotService.ts @@ -0,0 +1,323 @@ +import pLimit from "p-limit"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import { parseTraceparent } from "@trigger.dev/core/v3/isomorphic"; +import type { SupervisorHttpClient } from "@trigger.dev/core/v3/workers"; +import { type SnapshotCallbackPayload } from "@internal/compute"; +import type { ComputeWorkloadManager } from "../workloadManager/compute.js"; +import { TimerWheel } from "./timerWheel.js"; +import type { OtlpTraceService } from "./otlpTraceService.js"; +import { + emitOneShot, + fromContext, + recordPhaseSince, + runWideEvent, + setExtra, + setMeta, + type WideEventOptions, +} from "../wideEvents/index.js"; + +type DelayedSnapshot = { + runnerId: string; + runFriendlyId: string; + snapshotFriendlyId: string; +}; + +export type RunTraceContext = { + traceparent: string; + envId: string; + orgId: string; + projectId: string; +}; + +export type ComputeSnapshotServiceOptions = { + computeManager: ComputeWorkloadManager; + workerClient: SupervisorHttpClient; + tracing?: OtlpTraceService; + wideEventOpts: WideEventOptions; +}; + +export class ComputeSnapshotService { + private readonly logger = new SimpleStructuredLogger("compute-snapshot-service"); + + private static readonly MAX_TRACE_CONTEXTS = 10_000; + private readonly runTraceContexts = new Map(); + private readonly timerWheel: TimerWheel; + private readonly dispatchLimit: ReturnType; + + private readonly computeManager: ComputeWorkloadManager; + private readonly workerClient: SupervisorHttpClient; + private readonly tracing?: OtlpTraceService; + private readonly wideEventOpts: WideEventOptions; + + constructor(opts: ComputeSnapshotServiceOptions) { + this.computeManager = opts.computeManager; + this.workerClient = opts.workerClient; + this.tracing = opts.tracing; + this.wideEventOpts = opts.wideEventOpts; + + this.dispatchLimit = pLimit(this.computeManager.snapshotDispatchLimit); + this.timerWheel = new TimerWheel({ + delayMs: this.computeManager.snapshotDelayMs, + onExpire: (item) => { + this.dispatchLimit(() => this.dispatch(item.data)).catch((error) => { + this.logger.error("Snapshot dispatch failed", { + runId: item.data.runFriendlyId, + runnerId: item.data.runnerId, + error, + }); + }); + }, + }); + this.timerWheel.start(); + } + + /** Schedule a delayed snapshot for a run. Replaces any pending snapshot for the same run. */ + schedule(runFriendlyId: string, data: DelayedSnapshot) { + this.timerWheel.submit(runFriendlyId, data); + emitOneShot({ + ...this.wideEventOpts, + op: "snapshot.schedule", + kind: "event", + populate: (state) => { + state.meta.run_id = runFriendlyId; + state.meta.snapshot_id = data.snapshotFriendlyId; + state.extras.runner_id = data.runnerId; + state.extras.delay_ms = this.computeManager.snapshotDelayMs; + }, + }); + this.logger.debug("Snapshot scheduled", { + runFriendlyId, + snapshotFriendlyId: data.snapshotFriendlyId, + delayMs: this.computeManager.snapshotDelayMs, + }); + } + + /** + * Cancel a pending delayed snapshot. Returns true if one was cancelled. + * When `runnerId` is given, only a snapshot scheduled for that same runner + * is cancelled - a stale runner for a run that has since been reassigned + * must not cancel the new runner's pending snapshot. + */ + cancel(runFriendlyId: string, runnerId?: string): boolean { + if (runnerId) { + const pending = this.timerWheel.peek(runFriendlyId); + if (pending && pending.data.runnerId !== runnerId) { + return false; + } + } + const cancelled = this.timerWheel.cancel(runFriendlyId); + if (cancelled) { + emitOneShot({ + ...this.wideEventOpts, + op: "snapshot.canceled", + kind: "event", + populate: (state) => { + state.meta.run_id = runFriendlyId; + }, + }); + this.logger.debug("Snapshot cancelled", { runFriendlyId }); + } + return cancelled; + } + + /** Handle the callback from the gateway after a snapshot completes or fails. */ + async handleCallback(body: SnapshotCallbackPayload) { + const snapshotId = body.status === "completed" ? body.snapshot_id : undefined; + const runId = body.metadata?.runId; + const snapshotFriendlyId = body.metadata?.snapshotFriendlyId; + + // Enrich the wrapping route's wide event with snapshot metadata. The + // `/api/v1/compute/snapshot-complete` route is registered with `wideRoute`, + // so `fromContext()` returns the State of that route and these calls + // become extras/meta on the same wide event - no nested emission. + const state = fromContext(); + if (state) { + state.extras["snapshot.status"] = body.status; + if (body.instance_id) state.extras["snapshot.instance_id"] = body.instance_id; + if (body.duration_ms !== undefined) state.extras["snapshot.duration_ms"] = body.duration_ms; + if (snapshotId) state.extras["snapshot.id"] = snapshotId; + if (body.status === "failed" && body.error) state.extras["snapshot.error"] = body.error; + } + if (runId) setMeta(state, "run_id", runId); + if (snapshotFriendlyId) setMeta(state, "snapshot_id", snapshotFriendlyId); + + this.logger.debug("Snapshot callback", { + snapshotId, + instanceId: body.instance_id, + status: body.status, + error: body.status === "failed" ? body.error : undefined, + metadata: body.metadata, + durationMs: body.duration_ms, + }); + + if (!runId || !snapshotFriendlyId) { + this.logger.error("Snapshot callback missing metadata", { body }); + return { ok: false as const, status: 400 }; + } + + this.#emitSnapshotSpan(runId, body.duration_ms, snapshotId); + + if (body.status === "completed") { + const submitStart = performance.now(); + const result = await this.workerClient.submitSuspendCompletion({ + runId, + snapshotId: snapshotFriendlyId, + body: { + success: true, + checkpoint: { + type: "COMPUTE", + location: body.snapshot_id, + }, + }, + }); + recordPhaseSince( + "submit_completion", + submitStart, + result.success ? undefined : new Error(String(result.error)) + ); + + if (result.success) { + this.logger.debug("Suspend completion submitted", { + runId, + instanceId: body.instance_id, + snapshotId: body.snapshot_id, + }); + } else { + setExtra(state, "submit_completion.error", String(result.error)); + this.logger.error("Failed to submit suspend completion", { + runId, + snapshotFriendlyId, + error: result.error, + }); + } + } else { + const submitStart = performance.now(); + const result = await this.workerClient.submitSuspendCompletion({ + runId, + snapshotId: snapshotFriendlyId, + body: { + success: false, + error: body.error ?? "Snapshot failed", + }, + }); + recordPhaseSince( + "submit_completion", + submitStart, + result.success ? undefined : new Error(String(result.error)) + ); + + if (!result.success) { + setExtra(state, "submit_completion.error", String(result.error)); + this.logger.error("Failed to submit suspend failure", { + runId, + snapshotFriendlyId, + error: result.error, + }); + } + } + + return { ok: true as const, status: 200 }; + } + + registerTraceContext(runFriendlyId: string, ctx: RunTraceContext) { + // Evict oldest entries if we've hit the cap. This is best-effort: on a busy + // supervisor, entries for long-lived runs may be evicted before their snapshot + // callback arrives, causing those snapshot spans to be silently dropped. + // That's acceptable - trace spans are observability sugar, not correctness. + if (this.runTraceContexts.size >= ComputeSnapshotService.MAX_TRACE_CONTEXTS) { + const firstKey = this.runTraceContexts.keys().next().value; + if (firstKey) { + this.runTraceContexts.delete(firstKey); + } + } + + this.runTraceContexts.set(runFriendlyId, ctx); + } + + /** Stop the timer wheel, dropping pending snapshots. */ + stop(): string[] { + // Intentionally drop pending snapshots rather than dispatching them. The supervisor + // is shutting down, so our callback URL will be dead by the time the gateway responds. + // Runners detect the supervisor is gone and reconnect to a new instance, which + // re-triggers the snapshot workflow. Snapshots are an optimization, not a correctness + // requirement - runs continue fine without them. + const remaining = this.timerWheel.stop(); + const droppedRuns = remaining.map((item) => item.key); + + if (droppedRuns.length > 0) { + this.logger.info("Stopped, dropped pending snapshots", { count: droppedRuns.length }); + this.logger.debug("Dropped snapshot details", { runs: droppedRuns }); + } + + return droppedRuns; + } + + /** Dispatch a snapshot request to the gateway. */ + private async dispatch(snapshot: DelayedSnapshot): Promise { + await runWideEvent( + { + ...this.wideEventOpts, + op: "snapshot.dispatch", + kind: "scheduled", + setup: (state) => { + state.meta.run_id = snapshot.runFriendlyId; + state.meta.snapshot_id = snapshot.snapshotFriendlyId; + state.extras.runner_id = snapshot.runnerId; + }, + }, + async () => { + const result = await this.computeManager.snapshot({ + runnerId: snapshot.runnerId, + metadata: { + runId: snapshot.runFriendlyId, + snapshotFriendlyId: snapshot.snapshotFriendlyId, + }, + }); + + if (!result) { + throw new Error("Snapshot dispatch returned no result"); + } + } + ); + } + + #emitSnapshotSpan(runFriendlyId: string, durationMs?: number, snapshotId?: string) { + if (!this.tracing) return; + + const ctx = this.runTraceContexts.get(runFriendlyId); + if (!ctx) return; + + const parsed = parseTraceparent(ctx.traceparent); + if (!parsed) return; + + const endEpochMs = Date.now(); + const startEpochMs = durationMs ? endEpochMs - durationMs : endEpochMs; + + const spanAttributes: Record = { + "compute.type": "snapshot", + }; + + if (durationMs !== undefined) { + spanAttributes["compute.total_ms"] = durationMs; + } + + if (snapshotId) { + spanAttributes["compute.snapshot_id"] = snapshotId; + } + + this.tracing.emit({ + traceId: parsed.traceId, + parentSpanId: parsed.spanId, + spanName: "compute.snapshot", + startTimeMs: startEpochMs, + endTimeMs: endEpochMs, + resourceAttributes: { + "ctx.environment.id": ctx.envId, + "ctx.organization.id": ctx.orgId, + "ctx.project.id": ctx.projectId, + "ctx.run.id": runFriendlyId, + }, + spanAttributes, + }); + } +} diff --git a/apps/supervisor/src/services/failedPodHandler.test.ts b/apps/supervisor/src/services/failedPodHandler.test.ts new file mode 100644 index 00000000000..110e5806442 --- /dev/null +++ b/apps/supervisor/src/services/failedPodHandler.test.ts @@ -0,0 +1,581 @@ +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { FailedPodHandler } from "./failedPodHandler.js"; +import { type K8sApi, createK8sApi } from "../clients/kubernetes.js"; +import { Registry } from "prom-client"; +import { setTimeout } from "timers/promises"; + +// These tests require live K8s cluster credentials - skip by default +describe.skipIf(!process.env.K8S_INTEGRATION_TESTS)("FailedPodHandler Integration Tests", () => { + const k8s = createK8sApi(); + const namespace = "integration-test"; + const register = new Registry(); + + beforeAll(async () => { + // Create the test namespace if it doesn't exist + try { + await k8s.core.readNamespace({ name: namespace }); + } catch (_error) { + await k8s.core.createNamespace({ + body: { + metadata: { + name: namespace, + }, + }, + }); + } + + // Clear any existing pods in the namespace + await deleteAllPodsInNamespace({ k8sApi: k8s, namespace }); + }); + + afterEach(async () => { + // Clear metrics to avoid conflicts + register.clear(); + + // Delete any remaining pods in the namespace + await deleteAllPodsInNamespace({ k8sApi: k8s, namespace }); + }); + + it("should process and delete failed pods with app=task-run label", async () => { + const handler = new FailedPodHandler({ namespace, k8s, register }); + + try { + // Create failed pods with the correct label + const podNames = await createTestPods({ + k8sApi: k8s, + namespace, + count: 2, + shouldFail: true, + }); + + // Wait for pods to reach Failed state + await waitForPodsPhase({ + k8sApi: k8s, + namespace, + podNames, + phase: "Failed", + }); + + // Start the handler + await handler.start(); + + // Wait for pods to be deleted + await waitForPodsDeletion({ + k8sApi: k8s, + namespace, + podNames, + }); + + // Verify metrics + const metrics = handler.getMetrics(); + + // Check informer events were recorded + const informerEvents = await metrics.informerEventsTotal.get(); + expect(informerEvents.values).toContainEqual( + expect.objectContaining({ + labels: expect.objectContaining({ + namespace, + verb: "add", + }), + value: 2, + }) + ); + expect(informerEvents.values).toContainEqual( + expect.objectContaining({ + labels: expect.objectContaining({ + namespace, + verb: "connect", + }), + value: 1, + }) + ); + expect(informerEvents.values).not.toContainEqual( + expect.objectContaining({ + labels: expect.objectContaining({ + namespace, + verb: "error", + }), + }) + ); + + // Check pods were processed + const processedPods = await metrics.processedPodsTotal.get(); + expect(processedPods.values).toContainEqual( + expect.objectContaining({ + labels: expect.objectContaining({ + namespace, + status: "Failed", + }), + value: 2, + }) + ); + + // Check pods were deleted + const deletedPods = await metrics.deletedPodsTotal.get(); + expect(deletedPods.values).toContainEqual( + expect.objectContaining({ + labels: expect.objectContaining({ + namespace, + status: "Failed", + }), + value: 2, + }) + ); + + // Check no deletion errors were recorded + const deletionErrors = await metrics.deletionErrorsTotal.get(); + expect(deletionErrors.values).toHaveLength(0); + + // Check processing durations were recorded + const durations = await metrics.processingDurationSeconds.get(); + const failedDurations = durations.values.filter( + (v) => v.labels.namespace === namespace && v.labels.status === "Failed" + ); + expect(failedDurations.length).toBeGreaterThan(0); + } finally { + await handler.stop(); + } + }, 30000); + + it("should ignore pods without app=task-run label", async () => { + const handler = new FailedPodHandler({ namespace, k8s, register }); + + try { + // Create failed pods without the task-run label + const podNames = await createTestPods({ + k8sApi: k8s, + namespace, + count: 1, + shouldFail: true, + labels: { app: "not-task-run" }, + }); + + // Wait for pod to reach Failed state + await waitForPodsPhase({ + k8sApi: k8s, + namespace, + podNames, + phase: "Failed", + }); + + await handler.start(); + + // Wait a reasonable time to ensure pod isn't deleted + await setTimeout(5000); + + // Verify pod still exists + const exists = await podExists({ k8sApi: k8s, namespace, podName: podNames[0]! }); + expect(exists).toBe(true); + + // Verify no metrics were recorded + const metrics = handler.getMetrics(); + const processedPods = await metrics.processedPodsTotal.get(); + expect(processedPods.values).toHaveLength(0); + } finally { + await handler.stop(); + } + }, 30000); + + it("should not process pods that are being deleted", async () => { + const handler = new FailedPodHandler({ namespace, k8s, register }); + + try { + // Create a failed pod that we'll mark for deletion + const podNames = await createTestPods({ + k8sApi: k8s, + namespace, + count: 1, + shouldFail: true, + command: ["/bin/sh", "-c", "sleep 30"], + }); + + // Wait for pod to reach Failed state + await waitForPodsPhase({ + k8sApi: k8s, + namespace, + podNames, + phase: "Running", + }); + + // Delete the pod but don't wait for deletion + await k8s.core.deleteNamespacedPod({ + namespace, + name: podNames[0]!, + gracePeriodSeconds: 5, + }); + + // Start the handler + await handler.start(); + + // Wait for pod to be fully deleted + await waitForPodsDeletion({ + k8sApi: k8s, + namespace, + podNames, + }); + + // Verify metrics show we skipped processing + const metrics = handler.getMetrics(); + const processedPods = await metrics.processedPodsTotal.get(); + expect(processedPods.values).toHaveLength(0); + } finally { + await handler.stop(); + } + }, 30000); + + it("should detect and process pods that fail after handler starts", async () => { + const handler = new FailedPodHandler({ namespace, k8s, register }); + + try { + // Start the handler + await handler.start(); + + // Create failed pods with the correct label + const podNames = await createTestPods({ + k8sApi: k8s, + namespace, + count: 3, + shouldFail: true, + }); + + // Wait for pods to be deleted + await waitForPodsDeletion({ + k8sApi: k8s, + namespace, + podNames, + }); + + // Verify metrics + const metrics = handler.getMetrics(); + + // Check informer events were recorded + const informerEvents = await metrics.informerEventsTotal.get(); + expect(informerEvents.values).toContainEqual( + expect.objectContaining({ + labels: expect.objectContaining({ + namespace, + verb: "add", + }), + value: 3, + }) + ); + expect(informerEvents.values).toContainEqual( + expect.objectContaining({ + labels: expect.objectContaining({ + namespace, + verb: "connect", + }), + value: 1, + }) + ); + expect(informerEvents.values).not.toContainEqual( + expect.objectContaining({ + labels: expect.objectContaining({ + namespace, + verb: "error", + }), + }) + ); + + // Check pods were processed + const processedPods = await metrics.processedPodsTotal.get(); + expect(processedPods.values).toContainEqual( + expect.objectContaining({ + labels: expect.objectContaining({ + namespace, + status: "Failed", + }), + value: 3, + }) + ); + + // Check pods were deleted + const deletedPods = await metrics.deletedPodsTotal.get(); + expect(deletedPods.values).toContainEqual( + expect.objectContaining({ + labels: expect.objectContaining({ + namespace, + status: "Failed", + }), + value: 3, + }) + ); + + // Check no deletion errors were recorded + const deletionErrors = await metrics.deletionErrorsTotal.get(); + expect(deletionErrors.values).toHaveLength(0); + + // Check processing durations were recorded + const durations = await metrics.processingDurationSeconds.get(); + const failedDurations = durations.values.filter( + (v) => v.labels.namespace === namespace && v.labels.status === "Failed" + ); + expect(failedDurations.length).toBeGreaterThan(0); + } finally { + await handler.stop(); + } + }, 60000); + + it("should handle graceful shutdown pods differently", async () => { + const handler = new FailedPodHandler({ namespace, k8s, register }); + + try { + // Create first batch of pods before starting handler + const firstBatchPodNames = await createTestPods({ + k8sApi: k8s, + namespace, + count: 2, + exitCode: FailedPodHandler.GRACEFUL_SHUTDOWN_EXIT_CODE, + }); + + // Wait for pods to reach Failed state + await waitForPodsPhase({ + k8sApi: k8s, + namespace, + podNames: firstBatchPodNames, + phase: "Failed", + }); + + // Start the handler + await handler.start(); + + // Wait for first batch to be deleted + await waitForPodsDeletion({ + k8sApi: k8s, + namespace, + podNames: firstBatchPodNames, + }); + + // Create second batch of pods after handler is running + const secondBatchPodNames = await createTestPods({ + k8sApi: k8s, + namespace, + count: 3, + exitCode: FailedPodHandler.GRACEFUL_SHUTDOWN_EXIT_CODE, + }); + + // Wait for second batch to be deleted + await waitForPodsDeletion({ + k8sApi: k8s, + namespace, + podNames: secondBatchPodNames, + }); + + // Verify metrics + const metrics = handler.getMetrics(); + + // Check informer events were recorded for both batches + const informerEvents = await metrics.informerEventsTotal.get(); + expect(informerEvents.values).toContainEqual( + expect.objectContaining({ + labels: expect.objectContaining({ + namespace, + verb: "add", + }), + value: 5, // 2 from first batch + 3 from second batch + }) + ); + + // Check pods were processed as graceful shutdowns + const processedPods = await metrics.processedPodsTotal.get(); + + // Should not be marked as Failed + const failedPods = processedPods.values.find( + (v) => v.labels.namespace === namespace && v.labels.status === "Failed" + ); + expect(failedPods).toBeUndefined(); + + // Should be marked as GracefulShutdown + const gracefulShutdowns = processedPods.values.find( + (v) => v.labels.namespace === namespace && v.labels.status === "GracefulShutdown" + ); + expect(gracefulShutdowns).toBeDefined(); + expect(gracefulShutdowns?.value).toBe(5); // Total from both batches + + // Check pods were still deleted + const deletedPods = await metrics.deletedPodsTotal.get(); + expect(deletedPods.values).toContainEqual( + expect.objectContaining({ + labels: expect.objectContaining({ + namespace, + status: "Failed", + }), + value: 5, // Total from both batches + }) + ); + + // Check no deletion errors were recorded + const deletionErrors = await metrics.deletionErrorsTotal.get(); + expect(deletionErrors.values).toHaveLength(0); + } finally { + await handler.stop(); + } + }, 30000); +}); + +async function createTestPods({ + k8sApi, + namespace, + count, + labels = { app: "task-run" }, + shouldFail = false, + namePrefix = "test-pod", + command = ["/bin/sh", "-c", shouldFail ? "exit 1" : "exit 0"], + randomizeName = true, + exitCode, +}: { + k8sApi: K8sApi; + namespace: string; + count: number; + labels?: Record; + shouldFail?: boolean; + namePrefix?: string; + command?: string[]; + randomizeName?: boolean; + exitCode?: number; +}) { + const createdPods: string[] = []; + + // If exitCode is specified, override the command + if (exitCode !== undefined) { + command = ["/bin/sh", "-c", `exit ${exitCode}`]; + } + + for (let i = 0; i < count; i++) { + const podName = randomizeName + ? `${namePrefix}-${i}-${Math.random().toString(36).substring(2, 15)}` + : `${namePrefix}-${i}`; + await k8sApi.core.createNamespacedPod({ + namespace, + body: { + metadata: { + name: podName, + labels, + }, + spec: { + restartPolicy: "Never", + containers: [ + { + name: "run-controller", // Changed to match the name we check in failedPodHandler + image: "busybox:1.37.0", + command, + }, + ], + }, + }, + }); + createdPods.push(podName); + } + + return createdPods; +} + +async function waitForPodsDeletion({ + k8sApi, + namespace, + podNames, + timeoutMs = 10000, + waitMs = 1000, +}: { + k8sApi: K8sApi; + namespace: string; + podNames: string[]; + timeoutMs?: number; + waitMs?: number; +}) { + const startTime = Date.now(); + const pendingPods = new Set(podNames); + + while (pendingPods.size > 0 && Date.now() - startTime < timeoutMs) { + const pods = await k8sApi.core.listNamespacedPod({ namespace }); + const existingPods = new Set(pods.items.map((pod) => pod.metadata?.name ?? "")); + + for (const podName of pendingPods) { + if (!existingPods.has(podName)) { + pendingPods.delete(podName); + } + } + + if (pendingPods.size > 0) { + await setTimeout(waitMs); + } + } + + if (pendingPods.size > 0) { + throw new Error( + `Pods [${Array.from(pendingPods).join(", ")}] were not deleted within ${timeoutMs}ms` + ); + } +} + +async function podExists({ + k8sApi, + namespace, + podName, +}: { + k8sApi: K8sApi; + namespace: string; + podName: string; +}) { + const pods = await k8sApi.core.listNamespacedPod({ namespace }); + return pods.items.some((p) => p.metadata?.name === podName); +} + +async function waitForPodsPhase({ + k8sApi, + namespace, + podNames, + phase, + timeoutMs = 10000, + waitMs = 1000, +}: { + k8sApi: K8sApi; + namespace: string; + podNames: string[]; + phase: "Pending" | "Running" | "Succeeded" | "Failed" | "Unknown"; + timeoutMs?: number; + waitMs?: number; +}) { + const startTime = Date.now(); + const pendingPods = new Set(podNames); + + while (pendingPods.size > 0 && Date.now() - startTime < timeoutMs) { + const pods = await k8sApi.core.listNamespacedPod({ namespace }); + + for (const pod of pods.items) { + if (pendingPods.has(pod.metadata?.name ?? "") && pod.status?.phase === phase) { + pendingPods.delete(pod.metadata?.name ?? ""); + } + } + + if (pendingPods.size > 0) { + await setTimeout(waitMs); + } + } + + if (pendingPods.size > 0) { + throw new Error( + `Pods [${Array.from(pendingPods).join( + ", " + )}] did not reach phase ${phase} within ${timeoutMs}ms` + ); + } +} + +async function deleteAllPodsInNamespace({ + k8sApi, + namespace, +}: { + k8sApi: K8sApi; + namespace: string; +}) { + // Get all pods + const pods = await k8sApi.core.listNamespacedPod({ namespace }); + const podNames = pods.items.map((p) => p.metadata?.name ?? ""); + + // Delete all pods + await k8sApi.core.deleteCollectionNamespacedPod({ namespace, gracePeriodSeconds: 0 }); + + // Wait for all pods to be deleted + await waitForPodsDeletion({ k8sApi, namespace, podNames }); +} diff --git a/apps/supervisor/src/services/failedPodHandler.ts b/apps/supervisor/src/services/failedPodHandler.ts new file mode 100644 index 00000000000..ca458e0ba47 --- /dev/null +++ b/apps/supervisor/src/services/failedPodHandler.ts @@ -0,0 +1,327 @@ +import type { Informer, V1Pod } from "@kubernetes/client-node"; +import { LogLevel, SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import type { Registry } from "prom-client"; +import { Counter, Histogram } from "prom-client"; +import { setTimeout } from "timers/promises"; +import type { K8sApi } from "../clients/kubernetes.js"; +import { createK8sApi } from "../clients/kubernetes.js"; +import { register } from "../metrics.js"; + +type PodStatus = "Pending" | "Running" | "Succeeded" | "Failed" | "Unknown" | "GracefulShutdown"; + +export type FailedPodHandlerOptions = { + namespace: string; + reconnectIntervalMs?: number; + k8s?: K8sApi; + register?: Registry; +}; + +export class FailedPodHandler { + private readonly id: string; + private readonly logger: SimpleStructuredLogger; + private readonly k8s: K8sApi; + private readonly namespace: string; + + private isRunning = false; + + private readonly informer: Informer; + private readonly reconnectIntervalMs: number; + private reconnecting = false; + + // Metrics + private readonly register: Registry; + private readonly processedPodsTotal: Counter; + private readonly deletedPodsTotal: Counter; + private readonly deletionErrorsTotal: Counter; + private readonly processingDurationSeconds: Histogram; + private readonly informerEventsTotal: Counter; + + static readonly GRACEFUL_SHUTDOWN_EXIT_CODE = 200; + + constructor(opts: FailedPodHandlerOptions) { + this.id = Math.random().toString(36).substring(2, 15); + this.logger = new SimpleStructuredLogger("failed-pod-handler", LogLevel.debug, { + id: this.id, + }); + + this.k8s = opts.k8s ?? createK8sApi(); + + this.namespace = opts.namespace; + this.reconnectIntervalMs = opts.reconnectIntervalMs ?? 1000; + + this.informer = this.k8s.makeInformer( + `/api/v1/namespaces/${this.namespace}/pods`, + () => + this.k8s.core.listNamespacedPod({ + namespace: this.namespace, + labelSelector: "app=task-run", + fieldSelector: "status.phase=Failed", + }), + "app=task-run", + "status.phase=Failed" + ); + + // Whenever a matching pod is added to the informer cache + this.informer.on("add", this.onPodCompleted.bind(this)); + + // Informer events + this.informer.on("connect", this.makeOnConnect("failed-pod-informer").bind(this)); + this.informer.on("error", this.makeOnError("failed-pod-informer").bind(this)); + + // Initialize metrics + this.register = opts.register ?? register; + + this.processedPodsTotal = new Counter({ + name: "failed_pod_handler_processed_pods_total", + help: "Total number of failed pods processed", + labelNames: ["namespace", "status"], + registers: [this.register], + }); + + this.deletedPodsTotal = new Counter({ + name: "failed_pod_handler_deleted_pods_total", + help: "Total number of pods deleted", + labelNames: ["namespace", "status"], + registers: [this.register], + }); + + this.deletionErrorsTotal = new Counter({ + name: "failed_pod_handler_deletion_errors_total", + help: "Total number of errors encountered while deleting pods", + labelNames: ["namespace", "error_type"], + registers: [this.register], + }); + + this.processingDurationSeconds = new Histogram({ + name: "failed_pod_handler_processing_duration_seconds", + help: "The duration of pod processing", + labelNames: ["namespace", "status"], + registers: [this.register], + }); + + this.informerEventsTotal = new Counter({ + name: "failed_pod_handler_informer_events_total", + help: "Total number of informer events", + labelNames: ["namespace", "verb"], + registers: [this.register], + }); + } + + async start() { + if (this.isRunning) { + this.logger.warn("failed pod handler already running"); + return; + } + + this.isRunning = true; + + this.logger.info("starting failed pod handler"); + await this.informer.start(); + } + + async stop() { + if (!this.isRunning) { + this.logger.warn("failed pod handler not running"); + return; + } + + this.isRunning = false; + + this.logger.info("stopping failed pod handler"); + await this.informer.stop(); + } + + private async withHistogram( + histogram: Histogram, + promise: Promise, + labels?: Record + ): Promise { + const end = histogram.startTimer({ namespace: this.namespace, ...labels }); + try { + return await promise; + } finally { + end(); + } + } + + /** + * Returns the non-nullable status of a pod + */ + private podStatus(pod: V1Pod): PodStatus { + return (pod.status?.phase ?? "Unknown") as PodStatus; + } + + private async onPodCompleted(pod: V1Pod) { + this.logger.debug("pod-completed", this.podSummary(pod)); + this.informerEventsTotal.inc({ namespace: this.namespace, verb: "add" }); + + if (!pod.metadata?.name) { + this.logger.error("pod-completed: no name", this.podSummary(pod)); + return; + } + + if (!pod.status) { + this.logger.error("pod-completed: no status", this.podSummary(pod)); + return; + } + + if (pod.metadata?.deletionTimestamp) { + this.logger.verbose("pod-completed: pod is being deleted", this.podSummary(pod)); + return; + } + + const podStatus = this.podStatus(pod); + + switch (podStatus) { + case "Succeeded": + await this.withHistogram(this.processingDurationSeconds, this.onPodSucceeded(pod), { + status: podStatus, + }); + break; + case "Failed": + await this.withHistogram(this.processingDurationSeconds, this.onPodFailed(pod), { + status: podStatus, + }); + break; + default: + this.logger.error("pod-completed: unknown phase", this.podSummary(pod)); + } + } + + private async onPodSucceeded(pod: V1Pod) { + this.logger.debug("pod-succeeded", this.podSummary(pod)); + this.processedPodsTotal.inc({ + namespace: this.namespace, + status: this.podStatus(pod), + }); + } + + private async onPodFailed(pod: V1Pod) { + this.logger.debug("pod-failed", this.podSummary(pod)); + + try { + await this.processFailedPod(pod); + } catch (error) { + this.logger.error("pod-failed: error processing pod", this.podSummary(pod), { error }); + } finally { + await this.deletePod(pod); + } + } + + private async processFailedPod(pod: V1Pod) { + this.logger.verbose("pod-failed: processing pod", this.podSummary(pod)); + + const mainContainer = pod.status?.containerStatuses?.find((c) => c.name === "run-controller"); + + // If it's our special "graceful shutdown" exit code, don't process it further, just delete it + if ( + mainContainer?.state?.terminated?.exitCode === FailedPodHandler.GRACEFUL_SHUTDOWN_EXIT_CODE + ) { + this.logger.debug("pod-failed: graceful shutdown detected", this.podSummary(pod)); + this.processedPodsTotal.inc({ + namespace: this.namespace, + status: "GracefulShutdown", + }); + return; + } + + this.processedPodsTotal.inc({ + namespace: this.namespace, + status: this.podStatus(pod), + }); + } + + private async deletePod(pod: V1Pod) { + this.logger.verbose("pod-failed: deleting pod", this.podSummary(pod)); + try { + await this.k8s.core.deleteNamespacedPod({ + name: pod.metadata!.name!, + namespace: this.namespace, + }); + this.deletedPodsTotal.inc({ + namespace: this.namespace, + status: this.podStatus(pod), + }); + } catch (error) { + this.logger.error("pod-failed: error deleting pod", this.podSummary(pod), { error }); + this.deletionErrorsTotal.inc({ + namespace: this.namespace, + error_type: error instanceof Error ? error.name : "unknown", + }); + } + } + + private makeOnError(informerName: string) { + return (err?: unknown) => this.onError(informerName, err); + } + + private async onError(informerName: string, err?: unknown) { + if (!this.isRunning) { + this.logger.warn("onError: informer not running"); + return; + } + + // Guard against multiple simultaneous reconnections + if (this.reconnecting) { + this.logger.debug("onError: reconnection already in progress, skipping", { + informerName, + }); + return; + } + + this.reconnecting = true; + + try { + const error = err instanceof Error ? err : undefined; + this.logger.error("error event fired", { + informerName, + error: error?.message, + errorType: error?.name, + }); + this.informerEventsTotal.inc({ namespace: this.namespace, verb: "error" }); + + // Reconnect on errors + await setTimeout(this.reconnectIntervalMs); + await this.informer.start(); + } catch (handlerError) { + const error = handlerError instanceof Error ? handlerError : undefined; + this.logger.error("onError: reconnection attempt failed", { + informerName, + error: error?.message, + errorType: error?.name, + errorStack: error?.stack, + }); + } finally { + this.reconnecting = false; + } + } + + private makeOnConnect(informerName: string) { + return () => this.onConnect(informerName); + } + + private async onConnect(informerName: string) { + this.logger.info(`informer connected: ${informerName}`); + this.informerEventsTotal.inc({ namespace: this.namespace, verb: "connect" }); + } + + private podSummary(pod: V1Pod) { + return { + name: pod.metadata?.name, + namespace: pod.metadata?.namespace, + status: pod.status?.phase, + deletionTimestamp: pod.metadata?.deletionTimestamp, + }; + } + + // Method to expose metrics for testing + public getMetrics() { + return { + processedPodsTotal: this.processedPodsTotal, + deletedPodsTotal: this.deletedPodsTotal, + deletionErrorsTotal: this.deletionErrorsTotal, + informerEventsTotal: this.informerEventsTotal, + processingDurationSeconds: this.processingDurationSeconds, + }; + } +} diff --git a/apps/supervisor/src/services/otlpTraceService.test.ts b/apps/supervisor/src/services/otlpTraceService.test.ts new file mode 100644 index 00000000000..95053021ca1 --- /dev/null +++ b/apps/supervisor/src/services/otlpTraceService.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from "vitest"; +import { buildPayload } from "./otlpTraceService.js"; + +describe("buildPayload", () => { + it("builds valid OTLP JSON with timing attributes", () => { + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + parentSpanId: "1234567890abcdef", + spanName: "compute.provision", + startTimeMs: 1000, + endTimeMs: 1250, + resourceAttributes: { + "ctx.environment.id": "env_123", + "ctx.organization.id": "org_456", + "ctx.project.id": "proj_789", + "ctx.run.id": "run_abc", + }, + spanAttributes: { + "compute.total_ms": 250, + "compute.gateway.schedule_ms": 1, + "compute.cache.image_cached": true, + }, + }); + + expect(payload.resourceSpans).toHaveLength(1); + + const resourceSpan = payload.resourceSpans[0]!; + + // $trigger=true so the webapp accepts it + const triggerAttr = resourceSpan.resource.attributes.find((a) => a.key === "$trigger"); + expect(triggerAttr).toEqual({ key: "$trigger", value: { boolValue: true } }); + + // Resource attributes + const envAttr = resourceSpan.resource.attributes.find((a) => a.key === "ctx.environment.id"); + expect(envAttr).toEqual({ + key: "ctx.environment.id", + value: { stringValue: "env_123" }, + }); + + // Span basics + const span = resourceSpan.scopeSpans[0]!.spans[0]!; + expect(span.name).toBe("compute.provision"); + expect(span.traceId).toBe("abcd1234abcd1234abcd1234abcd1234"); + expect(span.parentSpanId).toBe("1234567890abcdef"); + + // Integer attribute + const totalMs = span.attributes.find((a) => a.key === "compute.total_ms"); + expect(totalMs).toEqual({ key: "compute.total_ms", value: { intValue: 250 } }); + + // Boolean attribute + const cached = span.attributes.find((a) => a.key === "compute.cache.image_cached"); + expect(cached).toEqual({ key: "compute.cache.image_cached", value: { boolValue: true } }); + }); + + it("generates a valid 16-char hex span ID", () => { + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "test", + startTimeMs: 1000, + endTimeMs: 1001, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!; + expect(span.spanId).toMatch(/^[0-9a-f]{16}$/); + }); + + it("converts timestamps to nanoseconds", () => { + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "test", + startTimeMs: 1000, + endTimeMs: 1250, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!; + expect(span.startTimeUnixNano).toBe("1000000000"); + expect(span.endTimeUnixNano).toBe("1250000000"); + }); + + it("converts real epoch timestamps without precision loss", () => { + // Date.now() values exceed Number.MAX_SAFE_INTEGER when multiplied by 1e6 + const startMs = 1711929600000; // 2024-04-01T00:00:00Z + const endMs = 1711929600250; + + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "test", + startTimeMs: startMs, + endTimeMs: endMs, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!; + expect(span.startTimeUnixNano).toBe("1711929600000000000"); + expect(span.endTimeUnixNano).toBe("1711929600250000000"); + }); + + it("preserves sub-millisecond precision from performance.now() arithmetic", () => { + // provisionStartEpochMs = Date.now() - (performance.now() - startMs) produces fractional ms. + // Use small epoch + fraction to avoid IEEE 754 noise in the fractional part. + const startMs = 1000.322; + const endMs = 1045.789; + + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "test", + startTimeMs: startMs, + endTimeMs: endMs, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!; + expect(span.startTimeUnixNano).toBe("1000322000"); + expect(span.endTimeUnixNano).toBe("1045789000"); + }); + + it("sub-ms precision affects ordering for real epoch values", () => { + // Two spans within the same millisecond should have different nanosecond timestamps + const spanA = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "a", + startTimeMs: 1711929600000.3, + endTimeMs: 1711929600001, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const spanB = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "b", + startTimeMs: 1711929600000.7, + endTimeMs: 1711929600001, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const startA = BigInt(spanA.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.startTimeUnixNano); + const startB = BigInt(spanB.resourceSpans[0]!.scopeSpans[0]!.spans[0]!.startTimeUnixNano); + // A should sort before B (both in the same ms but different sub-ms positions) + expect(startA).toBeLessThan(startB); + }); + + it("omits parentSpanId when not provided", () => { + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "test", + startTimeMs: 1000, + endTimeMs: 1001, + resourceAttributes: {}, + spanAttributes: {}, + }); + + const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!; + expect(span.parentSpanId).toBeUndefined(); + }); + + it("handles double values for non-integer numbers", () => { + const payload = buildPayload({ + traceId: "abcd1234abcd1234abcd1234abcd1234", + spanName: "test", + startTimeMs: 1000, + endTimeMs: 1001, + resourceAttributes: {}, + spanAttributes: { "compute.cpu": 0.25 }, + }); + + const span = payload.resourceSpans[0]!.scopeSpans[0]!.spans[0]!; + const cpu = span.attributes.find((a) => a.key === "compute.cpu"); + expect(cpu).toEqual({ key: "compute.cpu", value: { doubleValue: 0.25 } }); + }); +}); diff --git a/apps/supervisor/src/services/otlpTraceService.ts b/apps/supervisor/src/services/otlpTraceService.ts new file mode 100644 index 00000000000..da3310711d0 --- /dev/null +++ b/apps/supervisor/src/services/otlpTraceService.ts @@ -0,0 +1,104 @@ +import { randomBytes } from "crypto"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; + +export type OtlpTraceServiceOptions = { + endpointUrl: string; + timeoutMs?: number; +}; + +export type OtlpTraceSpan = { + traceId: string; + parentSpanId?: string; + spanName: string; + startTimeMs: number; + endTimeMs: number; + resourceAttributes: Record; + spanAttributes: Record; +}; + +export class OtlpTraceService { + private readonly logger = new SimpleStructuredLogger("otlp-trace"); + + constructor(private opts: OtlpTraceServiceOptions) {} + + /** Fire-and-forget: build payload and send to the configured OTLP endpoint */ + emit(span: OtlpTraceSpan): void { + const payload = buildPayload(span); + + fetch(`${this.opts.endpointUrl}/v1/traces`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + signal: AbortSignal.timeout(this.opts.timeoutMs ?? 5_000), + }).catch((err) => { + this.logger.warn("failed to send compute trace span", { + error: err instanceof Error ? err.message : String(err), + }); + }); + } +} + +// โ”€โ”€ Payload builder (internal) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/** @internal Exported for tests only */ +export function buildPayload(span: OtlpTraceSpan) { + const spanId = randomBytes(8).toString("hex"); + + return { + resourceSpans: [ + { + resource: { + attributes: [ + { key: "$trigger", value: { boolValue: true } }, + ...toOtlpAttributes(span.resourceAttributes), + ], + }, + scopeSpans: [ + { + scope: { name: "supervisor.compute" }, + spans: [ + { + traceId: span.traceId, + spanId, + parentSpanId: span.parentSpanId, + name: span.spanName, + kind: 3, // SPAN_KIND_CLIENT + startTimeUnixNano: msToNano(span.startTimeMs), + endTimeUnixNano: msToNano(span.endTimeMs), + attributes: toOtlpAttributes(span.spanAttributes), + status: { code: 1 }, // STATUS_CODE_OK + }, + ], + }, + ], + }, + ], + }; +} + +function toOtlpAttributes( + attrs: Record +): Array<{ key: string; value: Record }> { + return Object.entries(attrs).map(([key, value]) => ({ + key, + value: toOtlpValue(value), + })); +} + +function toOtlpValue(value: string | number | boolean): Record { + if (typeof value === "string") return { stringValue: value }; + if (typeof value === "boolean") return { boolValue: value }; + if (Number.isInteger(value)) return { intValue: value }; + return { doubleValue: value }; +} + +/** + * Convert epoch milliseconds to nanosecond string, preserving sub-ms precision. + * Fractional ms from performance.now() arithmetic carry meaningful microsecond + * data that affects span sort ordering when events happen within the same ms. + */ +function msToNano(ms: number): string { + const wholeMs = Math.trunc(ms); + const fracNs = Math.round((ms - wholeMs) * 1_000_000); + return String(BigInt(wholeMs) * 1_000_000n + BigInt(fracNs)); +} diff --git a/apps/supervisor/src/services/podCleaner.test.ts b/apps/supervisor/src/services/podCleaner.test.ts new file mode 100644 index 00000000000..827f12600e6 --- /dev/null +++ b/apps/supervisor/src/services/podCleaner.test.ts @@ -0,0 +1,473 @@ +import { PodCleaner } from "./podCleaner.js"; +import { type K8sApi, createK8sApi } from "../clients/kubernetes.js"; +import { setTimeout } from "timers/promises"; +import { describe, it, expect, beforeAll, afterEach } from "vitest"; +import { Registry } from "prom-client"; + +// These tests require live K8s cluster credentials - skip by default +describe.skipIf(!process.env.K8S_INTEGRATION_TESTS)("PodCleaner Integration Tests", () => { + const k8s = createK8sApi(); + const namespace = "integration-test"; + const register = new Registry(); + + beforeAll(async () => { + // Create the test namespace, only if it doesn't exist + try { + await k8s.core.readNamespace({ name: namespace }); + } catch (_error) { + await k8s.core.createNamespace({ + body: { + metadata: { + name: namespace, + }, + }, + }); + } + }); + + afterEach(async () => { + // Clear metrics to avoid conflicts + register.clear(); + + // Delete all pods in the namespace + await k8s.core.deleteCollectionNamespacedPod({ namespace, gracePeriodSeconds: 0 }); + }); + + it("should clean up succeeded pods", async () => { + const podCleaner = new PodCleaner({ namespace, k8s, register }); + + try { + // Create a test pod that's in succeeded state + const podNames = await createTestPods({ + k8sApi: k8s, + namespace, + count: 1, + namePrefix: "test-succeeded-pod", + }); + + if (!podNames[0]) { + throw new Error("Failed to create test pod"); + } + const podName = podNames[0]; + + // Wait for pod to complete + await waitForPodPhase({ + k8sApi: k8s, + namespace, + podName, + phase: "Succeeded", + }); + + // Start the pod cleaner + await podCleaner.start(); + + // Wait for pod to be deleted + await waitForPodDeletion({ + k8sApi: k8s, + namespace, + podName, + }); + + // Verify pod was deleted + expect(await podExists({ k8sApi: k8s, namespace, podName })).toBe(false); + } finally { + await podCleaner.stop(); + } + }, 30000); + + it("should accurately track deletion metrics", async () => { + const podCleaner = new PodCleaner({ namespace, k8s, register }); + try { + // Create a test pod that's in succeeded state + const podNames = await createTestPods({ + k8sApi: k8s, + namespace, + count: 1, + namePrefix: "test-succeeded-pod", + }); + + // Wait for pod to be in succeeded state + await waitForPodsPhase({ + k8sApi: k8s, + namespace, + podNames, + phase: "Succeeded", + }); + + await podCleaner.start(); + + // Wait for pod to be deleted + await waitForPodsDeletion({ + k8sApi: k8s, + namespace, + podNames, + }); + + const metrics = podCleaner.getMetrics(); + const deletionCycles = await metrics.deletionCyclesTotal.get(); + const deletionTimestamp = await metrics.lastDeletionTimestamp.get(); + + expect(deletionCycles?.values[0]?.value).toBeGreaterThan(0); + expect(deletionTimestamp?.values[0]?.value).toBeGreaterThan(0); + } finally { + await podCleaner.stop(); + } + }, 30000); + + it("should handle different batch sizes - small", async () => { + const podCleaner = new PodCleaner({ + namespace, + k8s, + register, + batchSize: 1, + }); + + try { + // Create some pods that will succeed + const podNames = await createTestPods({ + k8sApi: k8s, + namespace, + count: 2, + }); + + await waitForPodsPhase({ + k8sApi: k8s, + namespace, + podNames, + phase: "Succeeded", + }); + + await podCleaner.start(); + + await waitForPodsDeletion({ + k8sApi: k8s, + namespace, + podNames, + }); + + const metrics = podCleaner.getMetrics(); + const cycles = await metrics.deletionCyclesTotal.get(); + + expect(cycles?.values[0]?.value).toBe(2); + } finally { + await podCleaner.stop(); + } + }, 30000); + + it("should handle different batch sizes - large", async () => { + const podCleaner = new PodCleaner({ + namespace, + k8s, + register, + batchSize: 5000, + }); + + try { + // Create some pods that will succeed + const podNames = await createTestPods({ + k8sApi: k8s, + namespace, + count: 10, + }); + + await waitForPodsPhase({ + k8sApi: k8s, + namespace, + podNames, + phase: "Succeeded", + }); + + await podCleaner.start(); + + await waitForPodsDeletion({ + k8sApi: k8s, + namespace, + podNames, + }); + + const metrics = podCleaner.getMetrics(); + const cycles = await metrics.deletionCyclesTotal.get(); + + expect(cycles?.values[0]?.value).toBe(1); + } finally { + await podCleaner.stop(); + } + }, 30000); + + it("should not delete pods without app=task-run label", async () => { + const podCleaner = new PodCleaner({ namespace, k8s, register }); + + try { + // Create a test pod without the task-run label + const podNames = await createTestPods({ + k8sApi: k8s, + namespace, + count: 1, + labels: { app: "different-label" }, + namePrefix: "non-task-run-pod", + }); + + if (!podNames[0]) { + throw new Error("Failed to create test pod"); + } + const podName = podNames[0]; + + // Wait for pod to complete + await waitForPodPhase({ + k8sApi: k8s, + namespace, + podName, + phase: "Succeeded", + }); + + await podCleaner.start(); + + // Wait a reasonable time to ensure pod isn't deleted + await setTimeout(5000); + + // Verify pod still exists + expect(await podExists({ k8sApi: k8s, namespace, podName })).toBe(true); + } finally { + await podCleaner.stop(); + } + }, 30000); + + it("should not delete pods that are still running", async () => { + const podCleaner = new PodCleaner({ namespace, k8s, register }); + + try { + // Create a test pod with a long-running command + const podNames = await createTestPods({ + k8sApi: k8s, + namespace, + count: 1, + namePrefix: "running-pod", + command: ["sleep", "30"], // Will keep pod running + }); + + if (!podNames[0]) { + throw new Error("Failed to create test pod"); + } + const podName = podNames[0]; + + // Wait for pod to be running + await waitForPodPhase({ + k8sApi: k8s, + namespace, + podName, + phase: "Running", + }); + + await podCleaner.start(); + + // Wait a reasonable time to ensure pod isn't deleted + await setTimeout(5000); + + // Verify pod still exists + expect(await podExists({ k8sApi: k8s, namespace, podName })).toBe(true); + } finally { + await podCleaner.stop(); + } + }, 30000); +}); + +// Helper functions +async function waitForPodPhase({ + k8sApi, + namespace, + podName, + phase, + timeoutMs = 10000, + waitMs = 1000, +}: { + k8sApi: K8sApi; + namespace: string; + podName: string; + phase: string; + timeoutMs?: number; + waitMs?: number; +}) { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + const pod = await k8sApi.core.readNamespacedPod({ + namespace, + name: podName, + }); + if (pod.status?.phase === phase) { + return; + } + await setTimeout(waitMs); + } + + throw new Error(`Pod ${podName} did not reach phase ${phase} within ${timeoutMs}ms`); +} + +async function waitForPodDeletion({ + k8sApi, + namespace, + podName, + timeoutMs = 10000, + waitMs = 1000, +}: { + k8sApi: K8sApi; + namespace: string; + podName: string; + timeoutMs?: number; + waitMs?: number; +}) { + const startTime = Date.now(); + + while (Date.now() - startTime < timeoutMs) { + try { + await k8sApi.core.readNamespacedPod({ + namespace, + name: podName, + }); + await setTimeout(waitMs); + } catch (_error) { + // Pod was deleted + return; + } + } + + throw new Error(`Pod ${podName} was not deleted within ${timeoutMs}ms`); +} + +async function createTestPods({ + k8sApi, + namespace, + count, + labels = { app: "task-run" }, + shouldFail = false, + namePrefix = "test-pod", + command = ["/bin/sh", "-c", shouldFail ? "exit 1" : "exit 0"], +}: { + k8sApi: K8sApi; + namespace: string; + count: number; + labels?: Record; + shouldFail?: boolean; + namePrefix?: string; + command?: string[]; +}) { + const createdPods: string[] = []; + + for (let i = 0; i < count; i++) { + const podName = `${namePrefix}-${i}`; + await k8sApi.core.createNamespacedPod({ + namespace, + body: { + metadata: { + name: podName, + labels, + }, + spec: { + restartPolicy: "Never", + containers: [ + { + name: "test", + image: "busybox:1.37.0", + command, + }, + ], + }, + }, + }); + createdPods.push(podName); + } + + return createdPods; +} + +async function waitForPodsPhase({ + k8sApi, + namespace, + podNames, + phase, + timeoutMs = 10000, + waitMs = 1000, +}: { + k8sApi: K8sApi; + namespace: string; + podNames: string[]; + phase: "Pending" | "Running" | "Succeeded" | "Failed" | "Unknown"; + timeoutMs?: number; + waitMs?: number; +}) { + const startTime = Date.now(); + const pendingPods = new Set(podNames); + + while (pendingPods.size > 0 && Date.now() - startTime < timeoutMs) { + const pods = await k8sApi.core.listNamespacedPod({ namespace }); + + for (const pod of pods.items) { + if (pendingPods.has(pod.metadata?.name ?? "") && pod.status?.phase === phase) { + pendingPods.delete(pod.metadata?.name ?? ""); + } + } + + if (pendingPods.size > 0) { + await setTimeout(waitMs); + } + } + + if (pendingPods.size > 0) { + throw new Error( + `Pods [${Array.from(pendingPods).join( + ", " + )}] did not reach phase ${phase} within ${timeoutMs}ms` + ); + } +} + +async function waitForPodsDeletion({ + k8sApi, + namespace, + podNames, + timeoutMs = 10000, + waitMs = 1000, +}: { + k8sApi: K8sApi; + namespace: string; + podNames: string[]; + timeoutMs?: number; + waitMs?: number; +}) { + const startTime = Date.now(); + const pendingPods = new Set(podNames); + + while (pendingPods.size > 0 && Date.now() - startTime < timeoutMs) { + const pods = await k8sApi.core.listNamespacedPod({ namespace }); + const existingPods = new Set(pods.items.map((pod) => pod.metadata?.name ?? "")); + + for (const podName of pendingPods) { + if (!existingPods.has(podName)) { + pendingPods.delete(podName); + } + } + + if (pendingPods.size > 0) { + await setTimeout(waitMs); + } + } + + if (pendingPods.size > 0) { + throw new Error( + `Pods [${Array.from(pendingPods).join(", ")}] were not deleted within ${timeoutMs}ms` + ); + } +} + +async function podExists({ + k8sApi, + namespace, + podName, +}: { + k8sApi: K8sApi; + namespace: string; + podName: string; +}) { + const pods = await k8sApi.core.listNamespacedPod({ namespace }); + return pods.items.some((p) => p.metadata?.name === podName); +} diff --git a/apps/supervisor/src/services/podCleaner.ts b/apps/supervisor/src/services/podCleaner.ts new file mode 100644 index 00000000000..1ee1f240bd0 --- /dev/null +++ b/apps/supervisor/src/services/podCleaner.ts @@ -0,0 +1,119 @@ +import { IntervalService } from "@trigger.dev/core/v3"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import type { Registry } from "prom-client"; +import { Counter, Gauge } from "prom-client"; +import type { K8sApi } from "../clients/kubernetes.js"; +import { createK8sApi } from "../clients/kubernetes.js"; +import { register } from "../metrics.js"; + +export type PodCleanerOptions = { + namespace: string; + k8s?: K8sApi; + register?: Registry; + batchSize?: number; + intervalMs?: number; +}; + +export class PodCleaner { + private readonly logger = new SimpleStructuredLogger("pod-cleaner"); + private readonly k8s: K8sApi; + private readonly namespace: string; + + private readonly batchSize: number; + private readonly deletionInterval: IntervalService; + + // Metrics + private readonly register: Registry; + private readonly deletionCyclesTotal: Counter; + private readonly lastDeletionTimestamp: Gauge; + + constructor(opts: PodCleanerOptions) { + this.k8s = opts.k8s ?? createK8sApi(); + + this.namespace = opts.namespace; + this.batchSize = opts.batchSize ?? 500; + + this.deletionInterval = new IntervalService({ + intervalMs: opts.intervalMs ?? 10000, + leadingEdge: true, + onInterval: this.deleteCompletedPods.bind(this), + }); + + // Initialize metrics + this.register = opts.register ?? register; + + this.deletionCyclesTotal = new Counter({ + name: "pod_cleaner_deletion_cycles_total", + help: "Total number of pod deletion cycles run", + labelNames: ["namespace", "status", "batch_size"], + registers: [this.register], + }); + + this.lastDeletionTimestamp = new Gauge({ + name: "pod_cleaner_last_deletion_timestamp", + help: "Timestamp of the last deletion cycle", + labelNames: ["namespace"], + registers: [this.register], + }); + } + + async start() { + this.deletionInterval.start(); + } + + async stop() { + this.deletionInterval.stop(); + } + + private async deleteCompletedPods() { + let continuationToken: string | undefined; + + do { + try { + const result = await this.k8s.core.deleteCollectionNamespacedPod({ + namespace: this.namespace, + labelSelector: "app=task-run", + fieldSelector: "status.phase=Succeeded", + limit: this.batchSize, + _continue: continuationToken, + gracePeriodSeconds: 0, + propagationPolicy: "Background", + timeoutSeconds: 30, + }); + + // Update continuation token for next batch + continuationToken = result.metadata?._continue; + + // Increment the deletion cycles counter + this.deletionCyclesTotal.inc({ + namespace: this.namespace, + batch_size: this.batchSize, + status: "succeeded", + }); + + this.logger.debug("Deleted batch of pods", { continuationToken }); + } catch (err) { + this.logger.error("Failed to delete batch of pods", { + err: err instanceof Error ? err.message : String(err), + }); + + this.deletionCyclesTotal.inc({ + namespace: this.namespace, + batch_size: this.batchSize, + status: "failed", + }); + break; + } + } while (continuationToken); + + this.lastDeletionTimestamp.set({ namespace: this.namespace }, Date.now()); + } + + // Method to expose metrics for testing + public getMetrics() { + return { + deletionCyclesTotal: this.deletionCyclesTotal, + lastDeletionTimestamp: this.lastDeletionTimestamp, + }; + } +} diff --git a/apps/supervisor/src/services/timerWheel.test.ts b/apps/supervisor/src/services/timerWheel.test.ts new file mode 100644 index 00000000000..e685a26b1b4 --- /dev/null +++ b/apps/supervisor/src/services/timerWheel.test.ts @@ -0,0 +1,271 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { TimerWheel } from "./timerWheel.js"; + +describe("TimerWheel", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("dispatches item after delay", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + wheel.submit("run-1", "snapshot-data"); + + // Not yet + vi.advanceTimersByTime(2900); + expect(dispatched).toEqual([]); + + // After delay + vi.advanceTimersByTime(200); + expect(dispatched).toEqual(["run-1"]); + + wheel.stop(); + }); + + it("cancels item before it fires", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + wheel.submit("run-1", "data"); + + vi.advanceTimersByTime(1000); + expect(wheel.cancel("run-1")).toBe(true); + + vi.advanceTimersByTime(5000); + expect(dispatched).toEqual([]); + expect(wheel.size).toBe(0); + + wheel.stop(); + }); + + it("peek returns the pending item without removing it", () => { + const wheel = new TimerWheel({ delayMs: 3000, onExpire: () => {} }); + + wheel.start(); + wheel.submit("run-1", "data"); + + expect(wheel.peek("run-1")).toEqual({ key: "run-1", data: "data" }); + expect(wheel.size).toBe(1); + expect(wheel.peek("run-2")).toBeUndefined(); + + // Dispatched items are no longer peekable + vi.advanceTimersByTime(3100); + expect(wheel.peek("run-1")).toBeUndefined(); + + wheel.stop(); + }); + + it("cancel returns false for unknown key", () => { + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: () => {}, + }); + expect(wheel.cancel("nonexistent")).toBe(false); + }); + + it("deduplicates: resubmitting same key replaces the entry", () => { + const dispatched: { key: string; data: string }[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push({ key: item.key, data: item.data }), + }); + + wheel.start(); + wheel.submit("run-1", "old-data"); + + vi.advanceTimersByTime(1000); + wheel.submit("run-1", "new-data"); + + // Original would have fired at t=3000, but was replaced + // New one fires at t=1000+3000=4000 + vi.advanceTimersByTime(2100); + expect(dispatched).toEqual([]); + + vi.advanceTimersByTime(1000); + expect(dispatched).toEqual([{ key: "run-1", data: "new-data" }]); + + wheel.stop(); + }); + + it("handles many concurrent items", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + + for (let i = 0; i < 1000; i++) { + wheel.submit(`run-${i}`, `data-${i}`); + } + expect(wheel.size).toBe(1000); + + vi.advanceTimersByTime(3100); + expect(dispatched.length).toBe(1000); + expect(wheel.size).toBe(0); + + wheel.stop(); + }); + + it("handles items submitted at different times", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + + wheel.submit("run-1", "data"); + vi.advanceTimersByTime(1000); + wheel.submit("run-2", "data"); + vi.advanceTimersByTime(1000); + wheel.submit("run-3", "data"); + + // t=2000: nothing yet + expect(dispatched).toEqual([]); + + // t=3100: run-1 fires + vi.advanceTimersByTime(1100); + expect(dispatched).toEqual(["run-1"]); + + // t=4100: run-2 fires + vi.advanceTimersByTime(1000); + expect(dispatched).toEqual(["run-1", "run-2"]); + + // t=5100: run-3 fires + vi.advanceTimersByTime(1000); + expect(dispatched).toEqual(["run-1", "run-2", "run-3"]); + + wheel.stop(); + }); + + it("setDelay changes delay for new items only", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + + wheel.submit("run-1", "data"); // 3s delay + + vi.advanceTimersByTime(500); + wheel.setDelay(1000); + wheel.submit("run-2", "data"); // 1s delay + + // t=1500: run-2 should have fired (submitted at t=500 with 1s delay) + vi.advanceTimersByTime(1100); + expect(dispatched).toEqual(["run-2"]); + + // t=3100: run-1 fires at its original 3s delay + vi.advanceTimersByTime(1500); + expect(dispatched).toEqual(["run-2", "run-1"]); + + wheel.stop(); + }); + + it("stop returns unprocessed items", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + wheel.submit("run-1", "data-1"); + wheel.submit("run-2", "data-2"); + wheel.submit("run-3", "data-3"); + + const remaining = wheel.stop(); + expect(dispatched).toEqual([]); + expect(wheel.size).toBe(0); + expect(remaining.length).toBe(3); + expect(remaining.map((r) => r.key).sort()).toEqual(["run-1", "run-2", "run-3"]); + expect(remaining.find((r) => r.key === "run-1")?.data).toBe("data-1"); + }); + + it("after stop, new submissions are silently dropped", () => { + const dispatched: string[] = []; + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + wheel.stop(); + + wheel.submit("run-late", "data"); + expect(dispatched).toEqual([]); + expect(wheel.size).toBe(0); + }); + + it("tracks size correctly through submit/cancel/dispatch", () => { + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: () => {}, + }); + + wheel.start(); + + wheel.submit("a", "data"); + wheel.submit("b", "data"); + expect(wheel.size).toBe(2); + + wheel.cancel("a"); + expect(wheel.size).toBe(1); + + vi.advanceTimersByTime(3100); + expect(wheel.size).toBe(0); + + wheel.stop(); + }); + + it("clamps delay to valid range", () => { + const dispatched: string[] = []; + + // Very small delay (should be at least 1 tick = 100ms) + const wheel = new TimerWheel({ + delayMs: 0, + onExpire: (item) => dispatched.push(item.key), + }); + + wheel.start(); + wheel.submit("run-1", "data"); + + vi.advanceTimersByTime(200); + expect(dispatched).toEqual(["run-1"]); + + wheel.stop(); + }); + + it("multiple cancel calls are safe", () => { + const wheel = new TimerWheel({ + delayMs: 3000, + onExpire: () => {}, + }); + + wheel.start(); + wheel.submit("run-1", "data"); + + expect(wheel.cancel("run-1")).toBe(true); + expect(wheel.cancel("run-1")).toBe(false); + + wheel.stop(); + }); +}); diff --git a/apps/supervisor/src/services/timerWheel.ts b/apps/supervisor/src/services/timerWheel.ts new file mode 100644 index 00000000000..cab5a5d7a25 --- /dev/null +++ b/apps/supervisor/src/services/timerWheel.ts @@ -0,0 +1,166 @@ +/** + * TimerWheel implements a hashed timer wheel for efficiently managing large numbers + * of delayed operations with O(1) submit, cancel, and per-item dispatch. + * + * Used by the supervisor to delay snapshot requests so that short-lived waitpoints + * (e.g. triggerAndWait that resolves in <3s) skip the snapshot entirely. + * + * The wheel is a ring buffer of slots. A single setInterval advances a cursor. + * When the cursor reaches a slot, all items in that slot are dispatched. + * + * Fixed capacity: 600 slots at 100ms tick = 60s max delay. + */ + +const TICK_MS = 100; +const NUM_SLOTS = 600; // 60s max delay at 100ms tick + +export type TimerWheelItem = { + key: string; + data: T; +}; + +export type TimerWheelOptions = { + /** Called when an item's delay expires. */ + onExpire: (item: TimerWheelItem) => void; + /** Delay in milliseconds before items fire. Clamped to [100, 60000]. */ + delayMs: number; +}; + +type Entry = { + key: string; + data: T; + slotIndex: number; +}; + +export class TimerWheel { + private slots: Set[]; + private entries: Map>; + private cursor: number; + private intervalId: ReturnType | null; + private onExpire: (item: TimerWheelItem) => void; + private delaySlots: number; + + constructor(opts: TimerWheelOptions) { + this.slots = Array.from({ length: NUM_SLOTS }, () => new Set()); + this.entries = new Map(); + this.cursor = 0; + this.intervalId = null; + this.onExpire = opts.onExpire; + this.delaySlots = Math.max(1, Math.min(NUM_SLOTS, Math.ceil(opts.delayMs / TICK_MS))); + } + + /** Start the timer wheel. Must be called before submitting items. */ + start(): void { + if (this.intervalId) return; + this.intervalId = setInterval(() => this.tick(), TICK_MS); + // Don't hold the process open just for the timer wheel + if (this.intervalId && typeof this.intervalId === "object" && "unref" in this.intervalId) { + this.intervalId.unref(); + } + } + + /** + * Stop the timer wheel and return all unprocessed items. + * The wheel keeps running normally during graceful shutdown - call stop() + * only when you're ready to tear down. Caller decides what to do with leftovers. + */ + stop(): TimerWheelItem[] { + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + const remaining: TimerWheelItem[] = []; + for (const [key, entry] of this.entries) { + remaining.push({ key, data: entry.data }); + } + + for (const slot of this.slots) { + slot.clear(); + } + this.entries.clear(); + + return remaining; + } + + /** + * Update the delay for future submissions. Already-queued items keep their original timing. + * Clamped to [TICK_MS, 60000ms]. + */ + setDelay(delayMs: number): void { + this.delaySlots = Math.max(1, Math.min(NUM_SLOTS, Math.ceil(delayMs / TICK_MS))); + } + + /** + * Submit an item to be dispatched after the configured delay. + * If an item with the same key already exists, it is replaced (dedup). + * No-op if the wheel is stopped. + */ + submit(key: string, data: T): void { + if (!this.intervalId) return; + + // Dedup: remove existing entry for this key + this.cancel(key); + + const slotIndex = (this.cursor + this.delaySlots) % NUM_SLOTS; + const entry: Entry = { key, data, slotIndex }; + + this.entries.set(key, entry); + this.slot(slotIndex).add(key); + } + + /** + * Cancel a pending item. Returns true if the item was found and removed. + */ + cancel(key: string): boolean { + const entry = this.entries.get(key); + if (!entry) return false; + + this.slot(entry.slotIndex).delete(key); + this.entries.delete(key); + return true; + } + + /** Look up a pending item without removing it. */ + peek(key: string): TimerWheelItem | undefined { + const entry = this.entries.get(key); + return entry ? { key, data: entry.data } : undefined; + } + + /** Number of pending items in the wheel. */ + get size(): number { + return this.entries.size; + } + + /** Whether the wheel is running. */ + get running(): boolean { + return this.intervalId !== null; + } + + /** Get a slot by index. The array is fully initialized so this always returns a Set. */ + private slot(index: number): Set { + const s = this.slots[index]; + if (!s) throw new Error(`TimerWheel: invalid slot index ${index}`); + return s; + } + + /** Advance the cursor and dispatch all items in the current slot. */ + private tick(): void { + this.cursor = (this.cursor + 1) % NUM_SLOTS; + const slot = this.slot(this.cursor); + + if (slot.size === 0) return; + + // Collect items to dispatch (copy keys since we mutate during iteration) + const keys = [...slot]; + slot.clear(); + + for (const key of keys) { + const entry = this.entries.get(key); + if (!entry) continue; + + this.entries.delete(key); + this.onExpire({ key, data: entry.data }); + } + } +} diff --git a/apps/supervisor/src/services/warmStartVerificationService.test.ts b/apps/supervisor/src/services/warmStartVerificationService.test.ts new file mode 100644 index 00000000000..4a5f58875c2 --- /dev/null +++ b/apps/supervisor/src/services/warmStartVerificationService.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from "vitest"; +import { setTimeout as sleep } from "node:timers/promises"; +import { WarmStartVerificationService } from "./warmStartVerificationService.js"; +import type { DequeuedMessage } from "@trigger.dev/core/v3"; +import type { SupervisorHttpClient } from "@trigger.dev/core/v3/workers"; + +// The TimerWheel ticks every 100ms, so a 1000ms delay (the env minimum) +// fires within ~1.1s. +const DELAY_MS = 1_000; +// Long enough that a pending verification would certainly have fired. +const SETTLE_MS = 1_600; + +const DEQUEUED_SNAPSHOT_ID = "snapshot_dequeued"; + +function makeMessage(runFriendlyId = "run_1"): DequeuedMessage { + return { + run: { friendlyId: runFriendlyId }, + snapshot: { friendlyId: DEQUEUED_SNAPSHOT_ID }, + } as unknown as DequeuedMessage; +} + +function createService(opts: { latestSnapshotId?: string; probeError?: boolean }) { + const getLatestSnapshot = vi.fn(async (_runId: string) => + opts.probeError + ? { success: false as const, error: "connection refused" } + : { + success: true as const, + data: { execution: { snapshot: { friendlyId: opts.latestSnapshotId } } }, + } + ); + + const createWorkload = vi.fn(async () => {}); + + const service = new WarmStartVerificationService({ + workerClient: { getLatestSnapshot } as unknown as SupervisorHttpClient, + delayMs: DELAY_MS, + createWorkload, + wideEventOpts: { service: "supervisor-test", env: {}, enabled: false }, + }); + + return { service, getLatestSnapshot, createWorkload }; +} + +describe("WarmStartVerificationService", () => { + it("falls back to a cold create when the snapshot is unchanged", async () => { + const { service, createWorkload } = createService({ + latestSnapshotId: DEQUEUED_SNAPSHOT_ID, + }); + try { + const message = makeMessage(); + const timings = { warmStartCheckMs: 12 }; + service.schedule(message, timings); + + await vi.waitFor(() => expect(createWorkload).toHaveBeenCalledTimes(1), { + timeout: 3_000, + }); + expect(createWorkload).toHaveBeenCalledWith(message, timings); + } finally { + service.stop(); + } + }); + + it("does nothing when the snapshot has moved on (delivered)", async () => { + const { service, getLatestSnapshot, createWorkload } = createService({ + latestSnapshotId: "snapshot_executing", + }); + try { + service.schedule(makeMessage(), { warmStartCheckMs: 12 }); + + await vi.waitFor(() => expect(getLatestSnapshot).toHaveBeenCalledTimes(1), { + timeout: 3_000, + }); + await sleep(100); + expect(createWorkload).not.toHaveBeenCalled(); + } finally { + service.stop(); + } + }); + + it("never falls back when the probe errors", async () => { + const { service, getLatestSnapshot, createWorkload } = createService({ probeError: true }); + try { + service.schedule(makeMessage(), { warmStartCheckMs: 12 }); + + await vi.waitFor(() => expect(getLatestSnapshot).toHaveBeenCalledTimes(1), { + timeout: 3_000, + }); + await sleep(100); + expect(createWorkload).not.toHaveBeenCalled(); + } finally { + service.stop(); + } + }); + + it("cancel before the delay prevents the probe entirely", async () => { + const { service, getLatestSnapshot, createWorkload } = createService({ + latestSnapshotId: DEQUEUED_SNAPSHOT_ID, + }); + try { + service.schedule(makeMessage(), { warmStartCheckMs: 12 }); + + expect(service.cancel("run_1")).toBe(true); + + await sleep(SETTLE_MS); + expect(getLatestSnapshot).not.toHaveBeenCalled(); + expect(createWorkload).not.toHaveBeenCalled(); + } finally { + service.stop(); + } + }); + + it("re-scheduling the same run replaces the pending verification", async () => { + const { service, getLatestSnapshot } = createService({ + latestSnapshotId: "snapshot_executing", + }); + try { + service.schedule(makeMessage(), { warmStartCheckMs: 1 }); + service.schedule(makeMessage(), { warmStartCheckMs: 2 }); + + await vi.waitFor(() => expect(getLatestSnapshot).toHaveBeenCalledTimes(1), { + timeout: 3_000, + }); + await sleep(SETTLE_MS); + expect(getLatestSnapshot).toHaveBeenCalledTimes(1); + } finally { + service.stop(); + } + }); +}); diff --git a/apps/supervisor/src/services/warmStartVerificationService.ts b/apps/supervisor/src/services/warmStartVerificationService.ts new file mode 100644 index 00000000000..ce0bbe4f162 --- /dev/null +++ b/apps/supervisor/src/services/warmStartVerificationService.ts @@ -0,0 +1,173 @@ +import pLimit from "p-limit"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import type { DequeuedMessage } from "@trigger.dev/core/v3"; +import type { SupervisorHttpClient } from "@trigger.dev/core/v3/workers"; +import { tryCatch } from "@trigger.dev/core"; +import { TimerWheel } from "./timerWheel.js"; +import { emitOneShot, type WideEventOptions } from "../wideEvents/index.js"; + +const PROBE_CONCURRENCY_LIMIT = 10; + +export type WarmStartTimings = { + dequeueResponseMs?: number; + pollingIntervalMs?: number; + warmStartCheckMs: number; +}; + +type PendingVerification = { + message: DequeuedMessage; + timings: WarmStartTimings; +}; + +export type WarmStartVerificationServiceOptions = { + workerClient: SupervisorHttpClient; + /** How long after a warm-start hit to verify the runner acted on it. */ + delayMs: number; + /** Cold-creates the workload for a dispatched-but-lost run. */ + createWorkload: (message: DequeuedMessage, timings: WarmStartTimings) => Promise; + wideEventOpts: WideEventOptions; +}; + +/** + * Verifies that warm-start dispatches were actually acted on. + * + * Firestarter's `didWarmStart: true` means "response written to a socket", + * not "runner received it". A silently dead poller (no FIN - e.g. a VM torn + * down mid-poll) leaves the dispatched run stuck in PENDING_EXECUTING until + * the run engine's heartbeat redrive minutes later, burning a queue + * redelivery each time (TRI-10659). + * + * After a hit, the dequeued message is retained for `delayMs`, then the + * platform is asked for the run's latest snapshot. If it is still the exact + * snapshot we dequeued, no runner ever started the attempt - fall through to + * the regular cold-create path with the original message. Double-starts are + * impossible: `startRunAttempt` runs under a per-run lock and rejects stale + * snapshot ids, so if the original runner revives and races the fallback + * workload, exactly one wins and the loser exits before executing anything. + * + * On a probe ERROR we deliberately do nothing: the runner's attempt-start + * goes through nested retries, so during platform brownouts a healthy runner + * can legitimately act late - falling back on uncertainty would stampede + * duplicate workloads exactly when the platform is degraded. The heartbeat + * redrive remains the backstop for that case (and for supervisor restarts, + * which drop the in-memory timers). + */ +export class WarmStartVerificationService { + private readonly logger = new SimpleStructuredLogger("warm-start-verification"); + + private readonly timerWheel: TimerWheel; + private readonly probeLimit: ReturnType; + + private readonly workerClient: SupervisorHttpClient; + private readonly delayMs: number; + private readonly createWorkload: WarmStartVerificationServiceOptions["createWorkload"]; + private readonly wideEventOpts: WideEventOptions; + + constructor(opts: WarmStartVerificationServiceOptions) { + this.workerClient = opts.workerClient; + this.delayMs = opts.delayMs; + this.createWorkload = opts.createWorkload; + this.wideEventOpts = opts.wideEventOpts; + + this.probeLimit = pLimit(PROBE_CONCURRENCY_LIMIT); + this.timerWheel = new TimerWheel({ + delayMs: opts.delayMs, + onExpire: (item) => { + this.probeLimit(() => this.verify(item.data)).catch((error) => { + this.logger.error("Verification failed", { + runId: item.data.message.run.friendlyId, + error, + }); + }); + }, + }); + this.timerWheel.start(); + } + + /** Schedule delivery verification for a warm-start hit. */ + schedule(message: DequeuedMessage, timings: WarmStartTimings) { + this.timerWheel.submit(message.run.friendlyId, { message, timings }); + this.logger.debug("Verification scheduled", { + runId: message.run.friendlyId, + snapshotId: message.snapshot.friendlyId, + delayMs: this.delayMs, + }); + } + + /** + * Cancel a pending verification, e.g. when the runner connects to this + * supervisor. Purely an optimization: the matched runner often lives on a + * different node and connects to that node's supervisor, so most healthy + * deliveries are confirmed by the probe, not by this. + */ + cancel(runFriendlyId: string): boolean { + return this.timerWheel.cancel(runFriendlyId); + } + + /** Stop the timer wheel, dropping pending verifications. The run engine's + * heartbeat redrive covers anything dropped here. */ + stop() { + const remaining = this.timerWheel.stop(); + if (remaining.length > 0) { + this.logger.info("Stopped, dropped pending verifications", { count: remaining.length }); + } + } + + private async verify({ message, timings }: PendingVerification) { + const runFriendlyId = message.run.friendlyId; + + const result = await this.workerClient.getLatestSnapshot(runFriendlyId); + + if (!result.success) { + // Never fall back on uncertainty - see class docs. + this.emitOutcome(message, "probe_error", String(result.error)); + this.logger.warn("Verification probe failed, skipping", { + runId: runFriendlyId, + error: result.error, + }); + return; + } + + const latestSnapshotId = result.data.execution.snapshot.friendlyId; + + if (latestSnapshotId !== message.snapshot.friendlyId) { + // Something acted on the run (attempt started, or it was cancelled or + // requeued) - the dispatch is no longer ours to worry about. + this.emitOutcome(message, "delivered"); + return; + } + + this.emitOutcome(message, "fallback"); + this.logger.warn("Warm start dispatch was never acted on, cold starting", { + runId: runFriendlyId, + snapshotId: message.snapshot.friendlyId, + }); + + const [error] = await tryCatch(this.createWorkload(message, timings)); + if (error) { + this.logger.error("Fallback workload create failed", { + runId: runFriendlyId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + private emitOutcome( + message: DequeuedMessage, + outcome: "delivered" | "fallback" | "probe_error", + error?: string + ) { + emitOneShot({ + ...this.wideEventOpts, + op: "warmstart.verify", + kind: "event", + populate: (state) => { + state.meta.run_id = message.run.friendlyId; + state.meta.snapshot_id = message.snapshot.friendlyId; + state.extras.outcome = outcome; + state.extras.delay_ms = this.delayMs; + if (error) state.extras.error = error; + }, + }); + } +} diff --git a/apps/supervisor/src/util.ts b/apps/supervisor/src/util.ts new file mode 100644 index 00000000000..d14dd99bfe1 --- /dev/null +++ b/apps/supervisor/src/util.ts @@ -0,0 +1,44 @@ +import { isMacOS, isWindows } from "std-env"; + +export function normalizeDockerHostUrl(url: string) { + const $url = new URL(url); + + if ($url.hostname === "localhost") { + $url.hostname = getDockerHostDomain(); + } + + return $url.toString(); +} + +export function getDockerHostDomain() { + return isMacOS || isWindows ? "host.docker.internal" : "localhost"; +} + +/** Extract the W3C traceparent string from an untyped trace context record */ +export function extractTraceparent(traceContext?: Record): string | undefined { + if ( + traceContext && + "traceparent" in traceContext && + typeof traceContext.traceparent === "string" + ) { + return traceContext.traceparent; + } + return undefined; +} + +export function getRunnerId(runId: string, attemptNumber?: number) { + const parts = ["runner", runId.replace("run_", "")]; + + if (attemptNumber && attemptNumber > 1) { + parts.push(`attempt-${attemptNumber}`); + } + + return parts.join("-"); +} + +/** Derive a unique runnerId for a restore cycle using the checkpoint suffix */ +export function getRestoreRunnerId(runFriendlyId: string, checkpointId: string) { + const runIdShort = runFriendlyId.replace("run_", ""); + const checkpointSuffix = checkpointId.slice(-8); + return `runner-${runIdShort}-${checkpointSuffix}`; +} diff --git a/apps/supervisor/src/wideEvents/baggage.test.ts b/apps/supervisor/src/wideEvents/baggage.test.ts new file mode 100644 index 00000000000..0579533345e --- /dev/null +++ b/apps/supervisor/src/wideEvents/baggage.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from "vitest"; +import { encodeBaggage } from "./baggage.js"; + +describe("encodeBaggage", () => { + it("returns empty string for an empty map", () => { + expect(encodeBaggage({})).toBe(""); + }); + + it("encodes a single entry as k=v", () => { + expect(encodeBaggage({ run_id: "run-1" })).toBe("run_id=run-1"); + }); + + it("sorts keys for stable output across hops", () => { + expect(encodeBaggage({ b: "2", a: "1", c: "3" })).toBe("a=1,b=2,c=3"); + }); + + it("skips empty keys and empty values", () => { + expect(encodeBaggage({ "": "v", k: "", real: "x" })).toBe("real=x"); + }); + + it("truncates values longer than the cap", () => { + const long = "x".repeat(1024); + const got = encodeBaggage({ k: long }); + const value = got.slice("k=".length); + expect(value.length).toBe(256); + }); + + it("caps multibyte values by UTF-8 bytes, not code units", () => { + const long = "ใ‚".repeat(512); // 3 UTF-8 bytes each + const got = encodeBaggage({ k: long }); + const value = got.slice("k=".length); + expect(Buffer.byteLength(value, "utf8")).toBeLessThanOrEqual(256); + }); + + it("caps the number of entries", () => { + const meta: Record = {}; + for (let i = 0; i < 50; i++) { + // Sortable two-digit keys so we know which 32 survive. + meta[`k${String(i).padStart(2, "0")}`] = "v"; + } + const got = encodeBaggage(meta); + expect(got.split(",").length).toBe(32); + }); +}); diff --git a/apps/supervisor/src/wideEvents/baggage.ts b/apps/supervisor/src/wideEvents/baggage.ts new file mode 100644 index 00000000000..7750ac79303 --- /dev/null +++ b/apps/supervisor/src/wideEvents/baggage.ts @@ -0,0 +1,45 @@ +/** + * W3C Baggage (https://www.w3.org/TR/baggage/) encoding for outbound peer + * calls. Serialises a State's `meta` map into a `Baggage` header value so + * the downstream service auto-stamps the same labels onto its own wide + * events - even on early-error paths that bail before parsing the request + * body. + * + * Outbound discipline: only call this on peer-to-peer hops within the trust + * boundary. External-endpoint calls (image registries, cloud-provider + * APIs, third-party webhooks) must not include the Baggage header. + */ + +import { truncateUtf8 } from "./truncate.js"; + +/** + * Cap the number of entries serialised onto the header. A misbehaving + * caller's `meta` map shouldn't blow up downstream event width. + */ +const MAX_BAGGAGE_ENTRIES = 32; + +/** + * Cap each value's length. Defense against an upstream that stuffs + * unbounded payloads into a meta value. + */ +const MAX_BAGGAGE_VALUE_BYTES = 256; + +/** + * Encode a `meta` map as a Baggage header value (`k1=v1,k2=v2`). Keys are + * sorted for stable output across hops; an empty input yields the empty + * string so the caller can skip emitting the header entirely. + */ +export function encodeBaggage(meta: Record): string { + const entries = Object.entries(meta).filter(([k, v]) => k && v); + if (entries.length === 0) return ""; + + entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)); + + const out: string[] = []; + for (const [k, raw] of entries) { + if (out.length >= MAX_BAGGAGE_ENTRIES) break; + const v = truncateUtf8(raw, MAX_BAGGAGE_VALUE_BYTES); + out.push(`${k}=${v}`); + } + return out.join(","); +} diff --git a/apps/supervisor/src/wideEvents/context.ts b/apps/supervisor/src/wideEvents/context.ts new file mode 100644 index 00000000000..a89859c2707 --- /dev/null +++ b/apps/supervisor/src/wideEvents/context.ts @@ -0,0 +1,14 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import type { State } from "./state.js"; + +/** + * AsyncLocalStorage threading per-operation `State` through the call stack. + * Wrappers enter a state via `wideEventStorage.run(state, () => fn())` and + * any code in the async call tree retrieves it via `fromContext()`. + */ +export const wideEventStorage = new AsyncLocalStorage(); + +/** Returns the State attached to the current async context, or null. */ +export function fromContext(): State | null { + return wideEventStorage.getStore() ?? null; +} diff --git a/apps/supervisor/src/wideEvents/emit.test.ts b/apps/supervisor/src/wideEvents/emit.test.ts new file mode 100644 index 00000000000..0daefa64873 --- /dev/null +++ b/apps/supervisor/src/wideEvents/emit.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { emit, EmitMessage } from "./emit.js"; +import { newState } from "./new.js"; + +function captureEmit(state: Parameters[0]): Record { + const captured: string[] = []; + const origWrite = process.stdout.write; + process.stdout.write = ((chunk: unknown) => { + captured.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + emit(state); + } finally { + process.stdout.write = origWrite; + } + expect(captured).toHaveLength(1); + const line = captured[0]; + if (!line) throw new Error("no captured line"); + return JSON.parse(line) as Record; +} + +describe("emit", () => { + it("emits a single line with the stable message + request_id", () => { + const s = newState({ service: "supervisor", env: {} }); + s.statusCode = 200; + s.ok = true; + s.durationMs = 5; + const out = captureEmit(s); + expect(out.msg).toBe(EmitMessage); + expect(out.request_id).toBe(s.requestId); + expect(out.service).toBe("supervisor"); + expect(out.ok).toBe(true); + expect(out.status).toBe(200); + expect(out.duration_ms).toBe(5); + }); + + it("emits start_time as an ISO timestamp set by newState", () => { + const s = newState({ service: "supervisor", env: {} }); + s.statusCode = 200; + s.ok = true; + const out = captureEmit(s); + expect(typeof out.start_time).toBe("string"); + // Microsecond-precision RFC3339 (6 fractional digits), parseable as a date. + expect(out.start_time).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{6}Z$/); + expect(Number.isNaN(new Date(out.start_time as string).getTime())).toBe(false); + }); + + it("omits start_time when unset", () => { + const s = newState({ service: "supervisor", env: {} }); + delete s.startTime; + s.statusCode = 200; + s.ok = true; + const out = captureEmit(s); + expect(out).not.toHaveProperty("start_time"); + }); + + it("omits empty optional fields", () => { + const s = newState({ service: "supervisor", env: {} }); + s.statusCode = 200; + s.ok = true; + const out = captureEmit(s); + expect(out).not.toHaveProperty("trace_id"); + expect(out).not.toHaveProperty("version"); + expect(out).not.toHaveProperty("commit_sha"); + expect(out).not.toHaveProperty("error.code"); + }); + + it("flattens meta keys as meta.", () => { + const s = newState({ service: "supervisor", env: {} }); + s.statusCode = 200; + s.ok = true; + s.meta.run_id = "run_abc"; + s.meta.deployment_id = "dep_xyz"; + const out = captureEmit(s); + expect(out["meta.run_id"]).toBe("run_abc"); + expect(out["meta.deployment_id"]).toBe("dep_xyz"); + expect(out).not.toHaveProperty("meta"); + }); + + it("flattens phases as phase..", () => { + const s = newState({ service: "supervisor", env: {} }); + s.statusCode = 200; + s.ok = true; + s.phases.push({ name: "warm_start", durationMs: 12, ok: true, attempts: 1 }); + s.phases.push({ + name: "workload_create", + durationMs: 3, + ok: false, + attempts: 2, + errorCode: "Error", + errorMsg: "boom", + sub: { create_ms: 1 }, + }); + const out = captureEmit(s); + expect(out["phase.warm_start.duration_ms"]).toBe(12); + expect(out["phase.warm_start.ok"]).toBe(true); + expect(out["phase.warm_start.attempts"]).toBe(1); + expect(out["phase.workload_create.duration_ms"]).toBe(3); + expect(out["phase.workload_create.ok"]).toBe(false); + expect(out["phase.workload_create.attempts"]).toBe(2); + expect(out["phase.workload_create.error_code"]).toBe("Error"); + expect(out["phase.workload_create.error_message"]).toBe("boom"); + expect(out["phase.workload_create.create_ms"]).toBe(1); + }); + + it("includes error.code/message/kind when state.error is set", () => { + const s = newState({ service: "supervisor", env: {} }); + s.statusCode = 500; + s.error = { code: "InternalError", message: "kaboom", kind: "internal" }; + const out = captureEmit(s); + expect(out["error.code"]).toBe("InternalError"); + expect(out["error.message"]).toBe("kaboom"); + expect(out["error.kind"]).toBe("internal"); + }); + + it("truncates very long error messages", () => { + const s = newState({ service: "supervisor", env: {} }); + s.error = { code: "Big", message: "x".repeat(2000), kind: "internal" }; + const out = captureEmit(s); + expect((out["error.message"] as string).length).toBe(512); + }); + + it("flattens extras at the top level", () => { + const s = newState({ service: "supervisor", env: {} }); + s.statusCode = 200; + s.ok = true; + s.extras.route = "/health"; + s.extras["dispatch.result"] = "hit"; + const out = captureEmit(s); + expect(out.route).toBe("/health"); + expect(out["dispatch.result"]).toBe("hit"); + }); +}); diff --git a/apps/supervisor/src/wideEvents/emit.ts b/apps/supervisor/src/wideEvents/emit.ts new file mode 100644 index 00000000000..bfb03ad36c4 --- /dev/null +++ b/apps/supervisor/src/wideEvents/emit.ts @@ -0,0 +1,84 @@ +import type { State } from "./state.js"; +import { truncateUtf8 } from "./truncate.js"; + +/** + * Stable slog message string for every wide event. Downstream filters (jq, + * Axiom queries, Vector pipelines) pin to this constant. The `service` field + * disambiguates which service emitted it. + */ +export const EmitMessage = "wide_event"; + +const MAX_ERROR_MSG_BYTES = 512; + +/** + * Serializes a State as a single flat-keyed JSON line on stdout. Keys are + * flat (no nested objects) to keep jq filtering and Axiom indexing cheap. + * Empty optional fields are omitted. + */ +export function emit(state: State): void { + // Best-effort: an observability failure (serialization, stdout write) must + // never break or mask the caller's operation. Every call site relies on this. + try { + const out: Record = { + msg: EmitMessage, + request_id: state.requestId, + }; + + if (state.traceId) out.trace_id = state.traceId; + appendIfSet(out, "start_time", state.startTime); + appendIfSet(out, "service", state.service); + appendIfSet(out, "version", state.version); + appendIfSet(out, "commit_sha", state.commitSha); + appendIfSet(out, "region", state.region); + appendIfSet(out, "node_id", state.nodeId); + + appendIfSet(out, "op", state.op); + appendIfSet(out, "kind", state.kind); + + out.ok = state.ok; + if (state.statusCode !== 0) out.status = state.statusCode; + out.duration_ms = state.durationMs; + + if (state.error) { + appendIfSet(out, "error.code", state.error.code); + appendIfSet(out, "error.message", truncateUtf8(state.error.message, MAX_ERROR_MSG_BYTES)); + appendIfSet(out, "error.kind", state.error.kind); + } + + for (const [k, v] of Object.entries(state.meta)) { + out["meta." + k] = v; + } + + for (const p of state.phases) { + const prefix = "phase." + p.name + "."; + out[prefix + "duration_ms"] = p.durationMs; + out[prefix + "ok"] = p.ok; + out[prefix + "attempts"] = p.attempts; + if (p.errorCode) out[prefix + "error_code"] = p.errorCode; + if (p.errorMsg) out[prefix + "error_message"] = p.errorMsg; + if (p.sub) { + for (const [sk, sv] of Object.entries(p.sub)) { + out[prefix + sk] = sv; + } + } + } + + for (const [k, v] of Object.entries(state.extras)) { + out[k] = v; + } + + process.stdout.write(JSON.stringify(out) + "\n"); + } catch (err) { + try { + process.stderr.write( + `wide_event_emit_failed: ${err instanceof Error ? err.message : String(err)}\n` + ); + } catch { + // last resort - drop the event rather than throw + } + } +} + +function appendIfSet(out: Record, key: string, value: string | undefined): void { + if (value) out[key] = value; +} diff --git a/apps/supervisor/src/wideEvents/index.ts b/apps/supervisor/src/wideEvents/index.ts new file mode 100644 index 00000000000..6e61d85896f --- /dev/null +++ b/apps/supervisor/src/wideEvents/index.ts @@ -0,0 +1,24 @@ +/** + * Wide-event observability surface for the supervisor. One flat-keyed JSON + * line per natural unit of work (HTTP request, dequeue iteration, socket + * lifecycle event). Events join across services via `trace_id` (parsed from + * the inbound W3C `traceparent`) and `meta.run_id`. + * + * Off by default behind a kill switch - the dispatch hotpath runs at high + * QPS, so logging pressure must be cleanly removable. + */ +export { type Env, isValidRequestId, newState, type NewStateOptions } from "./new.js"; +export { emit, EmitMessage } from "./emit.js"; +export { parseTraceId } from "./traceparent.js"; +export { fromContext, wideEventStorage } from "./context.js"; +export { type PhaseOpt, recordPhase, recordPhaseSince, timePhase } from "./record.js"; +export { + emitOneShot, + runWideEvent, + setExtra, + setMeta, + type WideEventLifecycleOptions, + type WideEventOptions, +} from "./middleware.js"; +export type { ErrorInfo, PhaseRecord, State } from "./state.js"; +export { encodeBaggage } from "./baggage.js"; diff --git a/apps/supervisor/src/wideEvents/middleware.test.ts b/apps/supervisor/src/wideEvents/middleware.test.ts new file mode 100644 index 00000000000..a431df4ada5 --- /dev/null +++ b/apps/supervisor/src/wideEvents/middleware.test.ts @@ -0,0 +1,206 @@ +import { describe, it, expect } from "vitest"; +import { fromContext } from "./context.js"; +import { emitOneShot, runWideEvent, setMeta } from "./middleware.js"; + +function captureStdout(fn: () => Promise | unknown): Promise { + const captured: string[] = []; + const orig = process.stdout.write; + process.stdout.write = ((chunk: unknown) => { + captured.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + return Promise.resolve(fn()) + .finally(() => { + process.stdout.write = orig; + }) + .then(() => captured); +} + +describe("runWideEvent", () => { + it("emits one event with ok=true when no statusCode is set", async () => { + const lines = await captureStdout(async () => { + await runWideEvent( + { service: "supervisor", env: {}, enabled: true, op: "test", route: "/x", method: "POST" }, + async () => undefined + ); + }); + expect(lines).toHaveLength(1); + const line = lines[0]; + if (!line) throw new Error("no line"); + const ev = JSON.parse(line) as Record; + expect(ev.ok).toBe(true); + expect(ev.service).toBe("supervisor"); + expect(ev.route).toBe("/x"); + expect(ev.method).toBe("POST"); + expect(typeof ev.duration_ms).toBe("number"); + expect(typeof ev.request_id).toBe("string"); + }); + + it("derives ok from statusCode set via finalize", async () => { + const lines = await captureStdout(async () => { + await runWideEvent( + { service: "supervisor", env: {}, enabled: true, op: "test" }, + async () => undefined, + (state) => { + state.statusCode = 200; + } + ); + }); + const line = lines[0]; + if (!line) throw new Error("no line"); + const ev = JSON.parse(line) as Record; + expect(ev.ok).toBe(true); + expect(ev.status).toBe(200); + }); + + it("treats 4xx as ok=false", async () => { + const lines = await captureStdout(async () => { + await runWideEvent( + { service: "supervisor", env: {}, enabled: true, op: "test" }, + async () => undefined, + (state) => { + state.statusCode = 400; + } + ); + }); + const line = lines[0]; + if (!line) throw new Error("no line"); + const ev = JSON.parse(line) as Record; + expect(ev.ok).toBe(false); + expect(ev.status).toBe(400); + }); + + it("emits ok=false with error.kind=internal on throw", async () => { + const lines = await captureStdout(async () => { + await runWideEvent( + { service: "supervisor", env: {}, enabled: true, op: "test" }, + async () => { + throw new Error("boom"); + } + ).catch(() => undefined); + }); + const line = lines[0]; + if (!line) throw new Error("no line"); + const ev = JSON.parse(line) as Record; + expect(ev.ok).toBe(false); + expect(ev.status).toBe(500); + expect(ev["error.kind"]).toBe("internal"); + expect(ev["error.message"]).toBe("boom"); + }); + + it("threads state through AsyncLocalStorage", async () => { + const lines = await captureStdout(async () => { + await runWideEvent( + { service: "supervisor", env: {}, enabled: true, op: "test" }, + async () => { + setMeta(fromContext(), "run_id", "run_abc"); + } + ); + }); + const line = lines[0]; + if (!line) throw new Error("no line"); + const ev = JSON.parse(line) as Record; + expect(ev["meta.run_id"]).toBe("run_abc"); + expect(ev.ok).toBe(true); + }); + + it("picks up inbound traceparent for trace_id", async () => { + const tp = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + const lines = await captureStdout(async () => { + await runWideEvent( + { service: "supervisor", env: {}, enabled: true, op: "test", traceparent: tp }, + async () => undefined + ); + }); + const line = lines[0]; + if (!line) throw new Error("no line"); + const ev = JSON.parse(line) as Record; + expect(ev.trace_id).toBe("4bf92f3577b34da6a3ce929d0e0e4736"); + }); + + it("honours setup() to attach meta and extras before fn runs", async () => { + const lines = await captureStdout(async () => { + await runWideEvent( + { + service: "supervisor", + env: {}, + enabled: true, + op: "test", + setup: (state) => { + state.meta.run_id = "run_abc"; + state.extras.iteration = "dequeue"; + }, + }, + async () => undefined + ); + }); + const line = lines[0]; + if (!line) throw new Error("no line"); + const ev = JSON.parse(line) as Record; + expect(ev["meta.run_id"]).toBe("run_abc"); + expect(ev.iteration).toBe("dequeue"); + }); + + it("short-circuits to pass-through when enabled=false", async () => { + let seenState: ReturnType = null; + const lines = await captureStdout(async () => { + await runWideEvent( + { service: "supervisor", env: {}, enabled: false, op: "test" }, + async () => { + seenState = fromContext(); + } + ); + }); + expect(lines).toHaveLength(0); + expect(seenState).toBe(null); + }); + + it("isolates state across concurrent invocations", async () => { + const lines = await captureStdout(async () => { + await Promise.all( + ["a", "b", "c"].map((tag) => + runWideEvent({ service: "supervisor", env: {}, enabled: true, op: "test" }, async () => { + const s = fromContext(); + if (!s) throw new Error("no state"); + s.meta.tag = tag; + await new Promise((r) => setTimeout(r, 5)); + expect(s.meta.tag).toBe(tag); + }) + ) + ); + }); + const tags = lines.map((l) => (JSON.parse(l) as Record)["meta.tag"]); + expect(tags.sort()).toEqual(["a", "b", "c"]); + }); +}); + +describe("emitOneShot", () => { + it("emits a single event with populated meta when enabled", async () => { + const lines = await captureStdout(() => { + emitOneShot({ + service: "supervisor", + env: {}, + enabled: true, + op: "test", + populate: (s) => { + s.meta.run_id = "run_abc"; + s.extras.event = "run:start"; + }, + }); + }); + expect(lines).toHaveLength(1); + const line = lines[0]; + if (!line) throw new Error("no line"); + const ev = JSON.parse(line) as Record; + expect(ev.ok).toBe(true); + expect(ev["meta.run_id"]).toBe("run_abc"); + expect(ev.event).toBe("run:start"); + }); + + it("emits nothing when disabled", async () => { + const lines = await captureStdout(() => { + emitOneShot({ service: "supervisor", env: {}, enabled: false, op: "test" }); + }); + expect(lines).toHaveLength(0); + }); +}); diff --git a/apps/supervisor/src/wideEvents/middleware.ts b/apps/supervisor/src/wideEvents/middleware.ts new file mode 100644 index 00000000000..034c136414f --- /dev/null +++ b/apps/supervisor/src/wideEvents/middleware.ts @@ -0,0 +1,132 @@ +import { emit } from "./emit.js"; +import { newState, type Env } from "./new.js"; +import { wideEventStorage } from "./context.js"; +import type { State } from "./state.js"; + +/** Options common to every wide-event lifecycle. */ +export type WideEventOptions = { + service: string; + env: Env; + /** + * Kill switch. When false, lifecycles degenerate into transparent + * pass-through - no State allocation, no AsyncLocalStorage run, no emit. + * Important for the dispatch hotpath where logging pressure must be + * cleanly removable. + */ + enabled: boolean; +}; + +/** Per-invocation options layered on top of `WideEventOptions`. */ +export type WideEventLifecycleOptions = WideEventOptions & { + /** Operation discriminator (`instance.create`, `dequeue`, ...). Required. */ + op: string; + /** Event shape: `inbound` | `outbound` | `event` | `scheduled`. Optional. */ + kind?: string; + /** Route template (HTTP only) captured into `extras.route`. */ + route?: string; + /** HTTP method captured into `extras.method`. */ + method?: string; + /** Inbound W3C traceparent (HTTP header, queue message field). */ + traceparent?: string; + /** Inbound request id (e.g. `x-request-id` header). */ + inboundRequestId?: string; + /** Runs after the state is built, before the wrapped fn. Use to attach meta. */ + setup?: (state: State) => void; +}; + +/** + * Runs `fn` inside an AsyncLocalStorage state and emits one wide event on + * completion or error. `finalize` runs after `fn` returns but before emit - + * use it to read out-of-band outcome info (e.g. `res.statusCode` for an HTTP + * route) and assign to `state.statusCode`. The wrapper computes `ok` from + * `statusCode` if it's set; otherwise it defaults to true on success. + * + * Returns the original `fn` result. When `enabled=false`, `fn` runs unchanged + * with no event emitted. + */ +export async function runWideEvent( + opts: WideEventLifecycleOptions, + fn: () => Promise | T, + finalize?: (state: State) => void +): Promise { + if (!opts.enabled) { + return fn(); + } + + const state = newState({ + service: opts.service, + env: opts.env, + inboundRequestId: opts.inboundRequestId, + traceparent: opts.traceparent, + op: opts.op, + kind: opts.kind, + }); + if (opts.route) state.extras.route = opts.route; + if (opts.method) state.extras.method = opts.method; + + const start = performance.now(); + try { + if (opts.setup) opts.setup(state); + const result = await wideEventStorage.run(state, () => Promise.resolve(fn())); + state.durationMs = Math.round(performance.now() - start); + if (finalize) finalize(state); + if (state.statusCode !== 0) { + state.ok = state.statusCode >= 200 && state.statusCode < 300; + } else { + state.ok = true; + } + emit(state); + return result; + } catch (err) { + state.durationMs = Math.round(performance.now() - start); + const e = err instanceof Error ? err : new Error(String(err)); + if (state.statusCode === 0) state.statusCode = 500; + state.ok = false; + state.error = { + code: e.name || "Error", + message: e.message, + kind: "internal", + }; + emit(state); + throw err; + } +} + +/** + * One-shot wide event with no wrapped operation. Use for socket lifecycle + * events (`run:start`, `run:stop`) where there is no surrounding async unit + * of work to time. `populate` runs synchronously to attach meta/extras + * before emit. + */ +export function emitOneShot( + opts: WideEventOptions & { + op: string; + kind?: string; + traceparent?: string; + populate?: (state: State) => void; + } +): void { + if (!opts.enabled) return; + const state = newState({ + service: opts.service, + env: opts.env, + traceparent: opts.traceparent, + op: opts.op, + kind: opts.kind, + }); + if (opts.populate) opts.populate(state); + state.ok = true; + emit(state); +} + +/** Convenience accessor for in-handler meta mutation. */ +export function setMeta(state: State | null, key: string, value: string): void { + if (!state) return; + state.meta[key] = value; +} + +/** Convenience for free-form fields (did_warm_start, dispatch.result, ...). */ +export function setExtra(state: State | null, key: string, value: unknown): void { + if (!state) return; + state.extras[key] = value; +} diff --git a/apps/supervisor/src/wideEvents/new.test.ts b/apps/supervisor/src/wideEvents/new.test.ts new file mode 100644 index 00000000000..476c49c3d0e --- /dev/null +++ b/apps/supervisor/src/wideEvents/new.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { isValidRequestId, newState } from "./new.js"; + +describe("isValidRequestId", () => { + it("accepts visible ASCII", () => { + expect(isValidRequestId("req-abc-123_456.7")).toBe(true); + expect(isValidRequestId("a")).toBe(true); + }); + + it("rejects empty string", () => { + expect(isValidRequestId("")).toBe(false); + }); + + it("rejects overlong strings (>128 bytes)", () => { + expect(isValidRequestId("a".repeat(128))).toBe(true); + expect(isValidRequestId("a".repeat(129))).toBe(false); + }); + + it("rejects whitespace, newlines, control chars", () => { + expect(isValidRequestId("has space")).toBe(false); + expect(isValidRequestId("has\ttab")).toBe(false); + expect(isValidRequestId("has\nnewline")).toBe(false); + expect(isValidRequestId("\x00null")).toBe(false); + }); + + it("rejects high-bit / non-ASCII", () => { + expect(isValidRequestId("cafรฉ")).toBe(false); + expect(isValidRequestId("a\x7f")).toBe(false); + }); +}); + +describe("newState", () => { + const env = { version: "1.0.0", commitSha: "abc123", region: "us-east-1", nodeId: "node-1" }; + + it("populates service identity from env", () => { + const s = newState({ service: "supervisor", env }); + expect(s.service).toBe("supervisor"); + expect(s.version).toBe("1.0.0"); + expect(s.commitSha).toBe("abc123"); + expect(s.region).toBe("us-east-1"); + expect(s.nodeId).toBe("node-1"); + }); + + it("mints a fresh request id when none provided", () => { + const s = newState({ service: "test", env: {} }); + expect(s.requestId).toMatch(/^req-[0-9a-f]{32}$/); + }); + + it("honours a valid inbound request id", () => { + const s = newState({ service: "test", env: {}, inboundRequestId: "trace-abc-123" }); + expect(s.requestId).toBe("trace-abc-123"); + }); + + it("rejects unsafe inbound request id and mints a fresh one", () => { + const s = newState({ service: "test", env: {}, inboundRequestId: "has space" }); + expect(s.requestId).toMatch(/^req-[0-9a-f]{32}$/); + }); + + it("parses traceparent into traceId and preserves the raw header", () => { + const tp = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"; + const s = newState({ service: "test", env: {}, traceparent: tp }); + expect(s.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736"); + expect(s.traceparent).toBe(tp); + }); + + it("leaves traceId empty when no traceparent provided", () => { + const s = newState({ service: "test", env: {} }); + expect(s.traceId).toBe(""); + expect(s.traceparent).toBe(""); + }); + + it("initialises empty meta/extras/phases", () => { + const s = newState({ service: "test", env: {} }); + expect(s.meta).toEqual({}); + expect(s.extras).toEqual({}); + expect(s.phases).toEqual([]); + expect(s.ok).toBe(false); + expect(s.statusCode).toBe(0); + expect(s.durationMs).toBe(0); + }); +}); diff --git a/apps/supervisor/src/wideEvents/new.ts b/apps/supervisor/src/wideEvents/new.ts new file mode 100644 index 00000000000..7a4dba8a09c --- /dev/null +++ b/apps/supervisor/src/wideEvents/new.ts @@ -0,0 +1,96 @@ +import { randomBytes } from "node:crypto"; +import { parseTraceId } from "./traceparent.js"; +import type { State } from "./state.js"; + +const MAX_REQUEST_ID_LEN = 128; + +/** + * Validates an inbound request id. Non-empty, no longer than 128 bytes, + * composed entirely of visible ASCII (0x21..0x7E). Rejects newlines, control + * characters, whitespace, DEL, high-bit bytes - any of which could poison the + * log pipeline if echoed back verbatim. + */ +export function isValidRequestId(s: string): boolean { + if (s.length === 0 || s.length > MAX_REQUEST_ID_LEN) return false; + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + if (c < 0x21 || c > 0x7e) return false; + } + return true; +} + +/** + * Service-level identity that's constant for the lifetime of the process. + * Populated once at startup, copied into every State. + */ +export type Env = { + version?: string; + commitSha?: string; + region?: string; + nodeId?: string; +}; + +export type NewStateOptions = { + service: string; + env: Env; + /** Optional inbound request id (e.g. from `x-request-id`). If unsafe or absent, a fresh `req-` is minted. */ + inboundRequestId?: string; + /** Optional inbound W3C traceparent (HTTP header, queue message field). */ + traceparent?: string; + /** Operation discriminator. Dotted `noun.verb`. Defaults to empty (set later). */ + op?: string; + /** Event shape: `inbound` | `outbound` | `event` | `scheduled`. Defaults to empty. */ + kind?: string; +}; + +/** + * Builds a State for a wide-event lifecycle. + * + * - requestId: honours `inboundRequestId` if present and safe; otherwise + * mints a fresh `req-` id. + * - traceId: parsed from the provided traceparent (graceful empty if + * absent or malformed). + * - traceparent: preserved verbatim for downstream propagation. + */ +export function newState(opts: NewStateOptions): State { + const traceparent = opts.traceparent ?? ""; + const inbound = opts.inboundRequestId ?? ""; + const requestId = isValidRequestId(inbound) ? inbound : newRequestId(); + + return { + startTime: nowRfc3339(), + requestId, + traceId: parseTraceId(traceparent), + traceparent, + service: opts.service, + version: opts.env.version, + commitSha: opts.env.commitSha, + region: opts.env.region, + nodeId: opts.env.nodeId, + op: opts.op ?? "", + kind: opts.kind ?? "", + meta: {}, + phases: [], + ok: false, + statusCode: 0, + durationMs: 0, + extras: {}, + }; +} + +function newRequestId(): string { + return "req-" + randomBytes(16).toString("hex"); +} + +/** + * Current wall-clock time as an RFC3339 string with microsecond precision. + * `Date.toISOString()` only has millisecond resolution, which is too coarse to + * order multiple wide events emitted within the same millisecond. + * `performance.timeOrigin + performance.now()` gives a sub-millisecond wall-clock + * reading; we append the microsecond digits to the millisecond ISO string. + */ +function nowRfc3339(): string { + const ms = performance.timeOrigin + performance.now(); + const micros = Math.floor((ms % 1) * 1000); // microseconds within the millisecond (0..999) + return new Date(ms).toISOString().slice(0, -1) + String(micros).padStart(3, "0") + "Z"; +} diff --git a/apps/supervisor/src/wideEvents/record.test.ts b/apps/supervisor/src/wideEvents/record.test.ts new file mode 100644 index 00000000000..beeb0fff221 --- /dev/null +++ b/apps/supervisor/src/wideEvents/record.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from "vitest"; +import { fromContext, wideEventStorage } from "./context.js"; +import { recordPhase, recordPhaseSince, timePhase } from "./record.js"; +import { newState } from "./new.js"; +import type { State } from "./state.js"; + +function makeState(): State { + return newState({ service: "test", env: {} }); +} + +describe("recordPhase", () => { + it("appends a successful phase", () => { + const s = makeState(); + recordPhase(s, "lookup", performance.now() - 50, undefined); + expect(s.phases).toHaveLength(1); + const phase = s.phases[0]; + if (!phase) throw new Error("missing phase"); + expect(phase.name).toBe("lookup"); + expect(phase.ok).toBe(true); + expect(phase.attempts).toBe(1); + expect(phase.durationMs).toBeGreaterThanOrEqual(45); + }); + + it("appends a failed phase with error code/message", () => { + const s = makeState(); + recordPhase(s, "dispatch", performance.now(), new Error("nope")); + const phase = s.phases[0]; + if (!phase) throw new Error("missing phase"); + expect(phase.ok).toBe(false); + expect(phase.errorCode).toBe("Error"); + expect(phase.errorMsg).toBe("nope"); + }); + + it("truncates very long error messages", () => { + const s = makeState(); + recordPhase(s, "x", performance.now(), new Error("y".repeat(2000))); + const phase = s.phases[0]; + if (!phase) throw new Error("missing phase"); + expect(phase.errorMsg?.length).toBe(512); + }); + + it("honours opts.attempts", () => { + const s = makeState(); + recordPhase(s, "retry", performance.now(), undefined, { attempts: 3 }); + expect(s.phases[0]?.attempts).toBe(3); + }); + + it("attaches sub-timings", () => { + const s = makeState(); + recordPhase(s, "complex", performance.now(), undefined, { sub: { setup_ms: 10, work_ms: 5 } }); + expect(s.phases[0]?.sub).toEqual({ setup_ms: 10, work_ms: 5 }); + }); + + it("is a no-op when state is null", () => { + expect(() => recordPhase(null, "x", performance.now(), undefined)).not.toThrow(); + }); +}); + +describe("timePhase + AsyncLocalStorage threading", () => { + it("records via fromContext on success", async () => { + const s = makeState(); + const value = await wideEventStorage.run(s, () => timePhase("work", async () => 42)); + expect(value).toBe(42); + expect(s.phases).toHaveLength(1); + expect(s.phases[0]?.ok).toBe(true); + }); + + it("records via fromContext on error and rethrows", async () => { + const s = makeState(); + await expect( + wideEventStorage.run(s, () => + timePhase("work", async () => { + throw new Error("boom"); + }) + ) + ).rejects.toThrow("boom"); + expect(s.phases).toHaveLength(1); + expect(s.phases[0]?.ok).toBe(false); + expect(s.phases[0]?.errorMsg).toBe("boom"); + }); + + it("runs fn unchanged when no state on context", async () => { + const value = await timePhase("work", async () => "ok"); + expect(value).toBe("ok"); + }); +}); + +describe("recordPhaseSince", () => { + it("records using a caller-captured start time", async () => { + const s = makeState(); + await wideEventStorage.run(s, async () => { + const start = performance.now(); + await new Promise((r) => setTimeout(r, 10)); + recordPhaseSince("spanning", start, undefined); + }); + expect(s.phases).toHaveLength(1); + expect(s.phases[0]?.durationMs).toBeGreaterThanOrEqual(8); + }); +}); + +describe("fromContext", () => { + it("returns null when no state attached", () => { + expect(fromContext()).toBe(null); + }); + + it("returns the state when inside wideEventStorage.run", () => { + const s = makeState(); + wideEventStorage.run(s, () => { + expect(fromContext()).toBe(s); + }); + }); +}); diff --git a/apps/supervisor/src/wideEvents/record.ts b/apps/supervisor/src/wideEvents/record.ts new file mode 100644 index 00000000000..b7b59089a0e --- /dev/null +++ b/apps/supervisor/src/wideEvents/record.ts @@ -0,0 +1,82 @@ +import { fromContext } from "./context.js"; +import type { PhaseRecord, State } from "./state.js"; +import { truncateUtf8 } from "./truncate.js"; + +const MAX_ERROR_MSG_BYTES = 512; + +/** Optional knobs for a phase record. */ +export type PhaseOpt = { + /** Attempt count for the phase (default 1). */ + attempts?: number; + /** Sub-timings to fold into `phase..`. */ + sub?: Record; +}; + +/** + * Appends a phase outcome to `state.phases`. Safe to call on success + * (`err === undefined`) and error paths. `errorMsg` is truncated to 512 bytes + * to keep the wide event compact. No-op if state is null. + */ +export function recordPhase( + state: State | null, + name: string, + startMs: number, + err: Error | undefined, + opts: PhaseOpt = {} +): void { + if (!state) return; + const p: PhaseRecord = { + name, + durationMs: Math.round(performance.now() - startMs), + ok: err === undefined, + attempts: opts.attempts ?? 1, + }; + if (err) { + p.errorCode = err.name || "Error"; + p.errorMsg = truncateUtf8(err.message, MAX_ERROR_MSG_BYTES); + } + if (opts.sub) p.sub = opts.sub; + state.phases.push(p); +} + +/** + * Runs `fn` and appends a phase outcome to the State attached to the current + * async context. If no state is on context (test paths, background work), + * `fn` runs unchanged. The phase is recorded on both success and error paths + * so failed phases still appear in the wide event with duration_ms + + * error_code. + */ +export async function timePhase( + name: string, + fn: () => Promise | T, + opts: PhaseOpt = {} +): Promise { + const start = performance.now(); + try { + const result = await fn(); + recordPhase(fromContext(), name, start, undefined, opts); + return result; + } catch (err) { + recordPhase(fromContext(), name, start, asError(err), opts); + throw err; + } +} + +/** + * Appends a phase outcome to the State attached to the current async context + * using a `startMs` captured by the caller. Use when the phase boundary spans + * multiple calls with intermediate error handling that can't fit inside a + * single `timePhase` closure. Nil-state safe. + */ +export function recordPhaseSince( + name: string, + startMs: number, + err: Error | undefined, + opts: PhaseOpt = {} +): void { + recordPhase(fromContext(), name, startMs, err, opts); +} + +function asError(e: unknown): Error { + return e instanceof Error ? e : new Error(String(e)); +} diff --git a/apps/supervisor/src/wideEvents/state.ts b/apps/supervisor/src/wideEvents/state.ts new file mode 100644 index 00000000000..dece3a3f5fd --- /dev/null +++ b/apps/supervisor/src/wideEvents/state.ts @@ -0,0 +1,84 @@ +/** + * Per-event accumulator backing a single wide event. The supervisor emits one + * flat-keyed JSON line per natural unit of work (dequeue iteration, HTTP + * request, socket lifecycle event). Optional fields are omitted on emit so + * events stay compact. + */ +export type State = { + /** + * Wall-clock time the event began, as an ISO-8601 string. Emitted as + * `start_time` so log collection orders events by when work started rather + * than by the collector's ingestion time. + */ + startTime?: string; + + // Cross-stack correlation. + requestId: string; + traceId: string; + /** + * Raw inbound W3C `traceparent`, preserved verbatim so outbound calls can + * propagate the same trace context without losing the parent span-id. + * Empty when no inbound traceparent was set. + */ + traceparent: string; + + // Service identity (set by `newState` from Env). + service: string; + version?: string; + commitSha?: string; + region?: string; + nodeId?: string; + + /** + * Operation discriminator. Dotted `noun.verb` (e.g. `instance.create`, + * `snapshot.dispatch`). Low cardinality - bounded set per service, not + * unbounded. Empty allowed during construction but expected to be set + * before emit. + */ + op: string; + + /** + * Event shape. `inbound` for received requests, `outbound` for outgoing + * calls, `event` for ambient occurrences with no meaningful duration, + * `scheduled` for timer-driven work. Empty allowed; omitted from emit + * when empty. + */ + kind: string; + + // Caller-attached opaque metadata, flattened to `meta.` on emit. + meta: Record; + + // Per-phase outcomes, in completion order. + phases: PhaseRecord[]; + + // Top-level outcome (set after the wrapped operation returns). + ok: boolean; + statusCode: number; + durationMs: number; + error?: ErrorInfo; + + // Free-form ad-hoc additions (route, method, did_warm_start, ...). + extras: Record; +}; + +/** + * Single named phase outcome. Retries collapse into `attempts > 1` with the + * last error reflected in errorCode/errorMsg. + */ +export type PhaseRecord = { + name: string; + durationMs: number; + ok: boolean; + attempts: number; + errorCode?: string; + errorMsg?: string; + sub?: Record; +}; + +/** Top-level error summary for a failed operation. */ +export type ErrorInfo = { + code: string; + message: string; + /** Coarse classification - "client" | "upstream" | "internal" | "timeout". */ + kind: string; +}; diff --git a/apps/supervisor/src/wideEvents/traceparent.test.ts b/apps/supervisor/src/wideEvents/traceparent.test.ts new file mode 100644 index 00000000000..85ed31c3b6f --- /dev/null +++ b/apps/supervisor/src/wideEvents/traceparent.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from "vitest"; +import { parseTraceId } from "./traceparent.js"; + +describe("parseTraceId", () => { + const validTraceId = "4bf92f3577b34da6a3ce929d0e0e4736"; + const validHeader = `00-${validTraceId}-00f067aa0ba902b7-01`; + + it("extracts the trace-id from a valid W3C traceparent", () => { + expect(parseTraceId(validHeader)).toBe(validTraceId); + }); + + it("returns empty string for empty/null/undefined input", () => { + expect(parseTraceId("")).toBe(""); + expect(parseTraceId(null)).toBe(""); + expect(parseTraceId(undefined)).toBe(""); + }); + + it("returns empty for wrong segment count", () => { + expect(parseTraceId("00-abc-def")).toBe(""); + expect(parseTraceId("00-abc-def-01-extra")).toBe(""); + }); + + it("returns empty for non-zero version byte", () => { + expect(parseTraceId(`01-${validTraceId}-00f067aa0ba902b7-01`)).toBe(""); + }); + + it("returns empty for wrong-length trace-id", () => { + expect(parseTraceId("00-abc-00f067aa0ba902b7-01")).toBe(""); + }); + + it("returns empty for non-hex trace-id", () => { + expect(parseTraceId("00-zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz-00f067aa0ba902b7-01")).toBe(""); + }); + + it("returns empty for all-zero trace-id", () => { + expect(parseTraceId("00-00000000000000000000000000000000-00f067aa0ba902b7-01")).toBe(""); + }); + + it("accepts uppercase hex", () => { + const tid = "4BF92F3577B34DA6A3CE929D0E0E4736"; + expect(parseTraceId(`00-${tid}-00f067aa0ba902b7-01`)).toBe(tid); + }); +}); diff --git a/apps/supervisor/src/wideEvents/traceparent.ts b/apps/supervisor/src/wideEvents/traceparent.ts new file mode 100644 index 00000000000..9e84294f067 --- /dev/null +++ b/apps/supervisor/src/wideEvents/traceparent.ts @@ -0,0 +1,39 @@ +/** + * Extracts the trace-id from a W3C `traceparent` header. Returns "" when the + * header is absent, malformed, or carries an all-zero trace-id. + * + * Format: `---` + * version : 2 hex chars, must be "00" + * trace-id: 32 hex chars, non-zero + * span-id : 16 hex chars (not validated - we only need trace-id) + * flags : 2 hex chars (not validated) + */ +export function parseTraceId(header: string | null | undefined): string { + if (!header) return ""; + const parts = header.split("-"); + if (parts.length !== 4) return ""; + if (parts[0] !== "00") return ""; + const tid = parts[1]; + if (!tid || tid.length !== 32) return ""; + if (!isHex(tid)) return ""; + if (isAllZero(tid)) return ""; + return tid; +} + +function isHex(s: string): boolean { + for (let i = 0; i < s.length; i++) { + const c = s.charCodeAt(i); + const isDigit = c >= 0x30 && c <= 0x39; + const isLower = c >= 0x61 && c <= 0x66; + const isUpper = c >= 0x41 && c <= 0x46; + if (!isDigit && !isLower && !isUpper) return false; + } + return true; +} + +function isAllZero(s: string): boolean { + for (let i = 0; i < s.length; i++) { + if (s.charCodeAt(i) !== 0x30) return false; + } + return true; +} diff --git a/apps/supervisor/src/wideEvents/truncate.test.ts b/apps/supervisor/src/wideEvents/truncate.test.ts new file mode 100644 index 00000000000..4eb272f1e60 --- /dev/null +++ b/apps/supervisor/src/wideEvents/truncate.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from "vitest"; +import { truncateUtf8 } from "./truncate.js"; + +describe("truncateUtf8", () => { + it("returns short ASCII unchanged", () => { + expect(truncateUtf8("hello", 512)).toBe("hello"); + }); + + it("truncates ASCII to the byte cap", () => { + expect(truncateUtf8("x".repeat(1024), 256)).toBe("x".repeat(256)); + }); + + it("never exceeds the byte cap for multibyte input", () => { + // "ใ‚" is 3 UTF-8 bytes; 200 of them = 600 bytes. + const got = truncateUtf8("ใ‚".repeat(200), 256); + expect(Buffer.byteLength(got, "utf8")).toBeLessThanOrEqual(256); + }); + + it("does not split a multibyte sequence", () => { + // 256 / 3 bytes = 85 whole chars (255 bytes), the 86th would overflow. + expect(truncateUtf8("ใ‚".repeat(200), 256)).toBe("ใ‚".repeat(85)); + }); + + it("does not split a surrogate pair", () => { + // "๐Ÿ˜€" is 2 UTF-16 units / 4 UTF-8 bytes; only one fits under a 5-byte cap. + const got = truncateUtf8("๐Ÿ˜€๐Ÿ˜€", 5); + expect(got).toBe("๐Ÿ˜€"); + expect(Buffer.byteLength(got, "utf8")).toBe(4); + }); +}); diff --git a/apps/supervisor/src/wideEvents/truncate.ts b/apps/supervisor/src/wideEvents/truncate.ts new file mode 100644 index 00000000000..417735fe77d --- /dev/null +++ b/apps/supervisor/src/wideEvents/truncate.ts @@ -0,0 +1,20 @@ +/** + * Truncate `value` to at most `maxBytes` UTF-8 bytes without splitting a + * multi-byte sequence or surrogate pair. Plain `.slice()` counts UTF-16 code + * units, so multibyte text can blow past a byte cap and cutting mid-pair + * leaves a lone surrogate that downstream JSON / Postgres consumers reject. + */ +export function truncateUtf8(value: string, maxBytes: number): string { + if (Buffer.byteLength(value, "utf8") <= maxBytes) return value; + + let bytes = 0; + let end = 0; + // `for..of` yields whole code points, so a surrogate pair is never split. + for (const ch of value) { + const size = Buffer.byteLength(ch, "utf8"); + if (bytes + size > maxBytes) break; + bytes += size; + end += ch.length; + } + return value.slice(0, end); +} diff --git a/apps/supervisor/src/workerToken.ts b/apps/supervisor/src/workerToken.ts new file mode 100644 index 00000000000..1142796a7a3 --- /dev/null +++ b/apps/supervisor/src/workerToken.ts @@ -0,0 +1,29 @@ +import { readFileSync } from "fs"; +import { env } from "./env.js"; + +export function getWorkerToken() { + if (!env.TRIGGER_WORKER_TOKEN.startsWith("file://")) { + return env.TRIGGER_WORKER_TOKEN; + } + + const tokenPath = env.TRIGGER_WORKER_TOKEN.replace("file://", ""); + + console.debug( + JSON.stringify({ + message: "๐Ÿ”‘ Reading worker token from file", + tokenPath, + }) + ); + + try { + const token = readFileSync(tokenPath, "utf8").trim(); + return token; + } catch (error) { + console.error(`Failed to read worker token from file: ${tokenPath}`, error); + throw new Error( + `Unable to read worker token from file: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } +} diff --git a/apps/supervisor/src/workloadManager/compute.test.ts b/apps/supervisor/src/workloadManager/compute.test.ts new file mode 100644 index 00000000000..95a9ca2f3b0 --- /dev/null +++ b/apps/supervisor/src/workloadManager/compute.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { ComputeClientError } from "@internal/compute"; +import { isRetryableCreateError, runnerNameForAttempt } from "./compute.js"; + +describe("runnerNameForAttempt", () => { + it("keeps the unsuffixed name for the first attempt", () => { + expect(runnerNameForAttempt("runner-abc123", 1)).toBe("runner-abc123"); + }); + + it("suffixes retry attempts deterministically", () => { + expect(runnerNameForAttempt("runner-abc123", 2)).toBe("runner-abc123-r2"); + expect(runnerNameForAttempt("runner-abc123", 3)).toBe("runner-abc123-r3"); + }); +}); + +describe("isRetryableCreateError", () => { + it("retries statuses where the create definitely did not commit", () => { + expect(isRetryableCreateError(new ComputeClientError(500, "tap busy", "http://gw"))).toBe(true); + expect(isRetryableCreateError(new ComputeClientError(503, "no placement", "http://gw"))).toBe( + true + ); + }); + + it("does not retry lost-response statuses (create may have committed)", () => { + expect(isRetryableCreateError(new ComputeClientError(502, "bad gateway", "http://gw"))).toBe( + false + ); + expect( + isRetryableCreateError(new ComputeClientError(504, "gateway timeout", "http://gw")) + ).toBe(false); + }); + + it("does not retry 4xx responses", () => { + expect(isRetryableCreateError(new ComputeClientError(400, "bad request", "http://gw"))).toBe( + false + ); + expect(isRetryableCreateError(new ComputeClientError(409, "conflict", "http://gw"))).toBe( + false + ); + }); + + it("does not retry timeouts (instance may still be provisioning)", () => { + expect(isRetryableCreateError(new DOMException("timed out", "TimeoutError"))).toBe(false); + }); + + it("retries network-level fetch failures", () => { + expect(isRetryableCreateError(new TypeError("fetch failed"))).toBe(true); + }); + + it("does not retry unknown errors", () => { + expect(isRetryableCreateError(new Error("something else"))).toBe(false); + expect(isRetryableCreateError("string error")).toBe(false); + }); +}); diff --git a/apps/supervisor/src/workloadManager/compute.ts b/apps/supervisor/src/workloadManager/compute.ts new file mode 100644 index 00000000000..857e129a83f --- /dev/null +++ b/apps/supervisor/src/workloadManager/compute.ts @@ -0,0 +1,511 @@ +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import { parseTraceparent } from "@trigger.dev/core/v3/isomorphic"; +import { flattenAttributes } from "@trigger.dev/core/v3/utils/flattenAttributes"; +import { + type WorkloadManager, + type WorkloadManagerCreateOptions, + type WorkloadManagerOptions, +} from "./types.js"; +import { ComputeClient, ComputeClientError, stripImageDigest } from "@internal/compute"; +import { setTimeout as sleep } from "node:timers/promises"; +import { extractTraceparent, getRunnerId } from "../util.js"; +import type { OtlpTraceService } from "../services/otlpTraceService.js"; +import { tryCatch } from "@trigger.dev/core"; +import { encodeBaggage, fromContext } from "../wideEvents/index.js"; + +const DEFAULT_CREATE_MAX_ATTEMPTS = 3; +const DEFAULT_CREATE_RETRY_BASE_DELAY_MS = 250; + +/** + * TEMPORARY (TRI-10293): a failed create can leave its instance name + * registered gateway/fcrun-side until async cleanup runs, so a same-name + * retry can 409 against our own residue. Until the gateway cleans up + * failed-create registrations properly, retry attempts get a deterministic + * suffix. Attempt 1 keeps the unsuffixed name so the non-retry path is + * unchanged; the suffixed name flows into both the instance name and + * TRIGGER_RUNNER_ID, which downstream flows treat as one opaque + * self-reported token. Only attempts following a ComputeClientError are + * suffixed - network-failure retries keep the same name on purpose, because + * the gateway's name-collision 409 is their safety net against + * double-creating an instance whose create response was lost. + */ +export function runnerNameForAttempt(runnerId: string, attempt: number): string { + return attempt === 1 ? runnerId : `${runnerId}-r${attempt}`; +} + +/** + * Whether a failed instance create is worth retrying. Only statuses where + * the create definitely did NOT commit are retried: 500 means the agent or + * fcrun returned a create error (e.g. a netns slot holding the tap busy, a + * full node disk - placement may differ on retry), 503 means the gateway + * had nowhere to place it. 502/504 are excluded: the gateway emits those + * when it fails to reach the node or read its response, which can happen + * AFTER the agent committed the create - and the gateway only records the + * instance name on a clean 201, so a same-name retry would miss the + * collision check and could double-create the VM on another node. 4xx won't + * heal on retry, and timeouts may still be provisioning. Network-level + * fetch failures are safe: if the gateway processed the create, its name + * index is populated and the retry 409s harmlessly. + */ +export function isRetryableCreateError(error: unknown): boolean { + if (error instanceof ComputeClientError) { + return error.status === 500 || error.status === 503; + } + if (error instanceof DOMException && error.name === "TimeoutError") { + return false; + } + // Network-level fetch failures (gateway briefly unreachable) + return error instanceof TypeError; +} + +type ComputeWorkloadManagerOptions = WorkloadManagerOptions & { + gateway: { + url: string; + authToken?: string; + timeoutMs: number; + }; + snapshots: { + enabled: boolean; + delayMs: number; + dispatchLimit: number; + callbackUrl: string; + }; + tracing?: OtlpTraceService; + runner: { + instanceName: string; + otelEndpoint: string; + prettyLogs: boolean; + sendRunDebugLogs: boolean; + }; + createRetry?: { + maxAttempts: number; + baseDelayMs: number; + }; +}; + +export class ComputeWorkloadManager implements WorkloadManager { + private readonly logger = new SimpleStructuredLogger("compute-workload-manager"); + private readonly compute: ComputeClient; + private readonly createMaxAttempts: number; + private readonly createRetryBaseDelayMs: number; + + constructor(private opts: ComputeWorkloadManagerOptions) { + this.createMaxAttempts = opts.createRetry?.maxAttempts ?? DEFAULT_CREATE_MAX_ATTEMPTS; + this.createRetryBaseDelayMs = + opts.createRetry?.baseDelayMs ?? DEFAULT_CREATE_RETRY_BASE_DELAY_MS; + + if (opts.workloadApiDomain) { + this.logger.warn("โš ๏ธ Custom workload API domain", { + domain: opts.workloadApiDomain, + }); + } + + this.compute = new ComputeClient({ + gatewayUrl: opts.gateway.url, + authToken: opts.gateway.authToken, + timeoutMs: opts.gateway.timeoutMs, + // Forward the current wide-event scope's traceparent + request_id so the + // downstream service continues the same trace and joins its own wide + // events to ours. Additionally serialize caller-supplied meta labels + // into the W3C Baggage header so the downstream service auto-stamps + // them even on early-error paths that bail before parsing the body. + // When called outside a wide-event scope (or when wide events are + // disabled), `fromContext` returns undefined and propagation is skipped. + getPropagationHeaders: () => { + const state = fromContext(); + if (!state) return {}; + const headers: Record = { "x-request-id": state.requestId }; + if (state.traceparent) { + headers.traceparent = state.traceparent; + } + const baggage = encodeBaggage(state.meta); + if (baggage) { + headers.baggage = baggage; + } + return headers; + }, + }); + } + + get snapshotsEnabled(): boolean { + return this.opts.snapshots.enabled; + } + + get snapshotDelayMs(): number { + return this.opts.snapshots.delayMs; + } + + get snapshotDispatchLimit(): number { + return this.opts.snapshots.dispatchLimit; + } + + get traceSpansEnabled(): boolean { + return !!this.opts.tracing; + } + + async create(opts: WorkloadManagerCreateOptions) { + const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber); + + const envVars: Record = { + OTEL_EXPORTER_OTLP_ENDPOINT: this.opts.runner.otelEndpoint, + TRIGGER_DEQUEUED_AT_MS: String(opts.dequeuedAt.getTime()), + TRIGGER_POD_SCHEDULED_AT_MS: String(Date.now()), + TRIGGER_ENV_ID: opts.envId, + TRIGGER_DEPLOYMENT_ID: opts.deploymentFriendlyId, + TRIGGER_DEPLOYMENT_VERSION: opts.deploymentVersion, + TRIGGER_RUN_ID: opts.runFriendlyId, + TRIGGER_SNAPSHOT_ID: opts.snapshotFriendlyId, + TRIGGER_SUPERVISOR_API_PROTOCOL: this.opts.workloadApiProtocol, + TRIGGER_SUPERVISOR_API_PORT: String(this.opts.workloadApiPort), + TRIGGER_SUPERVISOR_API_DOMAIN: this.opts.workloadApiDomain ?? "", + TRIGGER_WORKER_INSTANCE_NAME: this.opts.runner.instanceName, + TRIGGER_RUNNER_ID: runnerId, + TRIGGER_MACHINE_CPU: String(opts.machine.cpu), + TRIGGER_MACHINE_MEMORY: String(opts.machine.memory), + PRETTY_LOGS: String(this.opts.runner.prettyLogs), + TRIGGER_SEND_RUN_DEBUG_LOGS: String(this.opts.runner.sendRunDebugLogs), + }; + + if (this.opts.warmStartUrl) { + envVars.TRIGGER_WARM_START_URL = this.opts.warmStartUrl; + } + + if (this.snapshotsEnabled && this.opts.metadataUrl) { + envVars.TRIGGER_METADATA_URL = this.opts.metadataUrl; + } + + if (this.opts.heartbeatIntervalSeconds) { + envVars.TRIGGER_HEARTBEAT_INTERVAL_SECONDS = String(this.opts.heartbeatIntervalSeconds); + } + + if (this.opts.snapshotPollIntervalSeconds) { + envVars.TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS = String( + this.opts.snapshotPollIntervalSeconds + ); + } + + if (this.opts.additionalEnvVars) { + Object.assign(envVars, this.opts.additionalEnvVars); + } + + // Strip image digest - resolve by tag, not digest + const imageRef = stripImageDigest(opts.image); + + // Labels forwarded to the compute provider for network-policy selection. + // `org` is always set so every run carries its org identity. + const labels: Record = { + org: opts.orgId, + }; + + // Wide event: single canonical log line emitted in finally + const event: Record = { + // High-cardinality identifiers + runId: opts.runFriendlyId, + runnerId, + envId: opts.envId, + envType: opts.envType, + orgId: opts.orgId, + projectId: opts.projectId, + deploymentVersion: opts.deploymentVersion, + machine: opts.machine.name, + // Environment + instanceName: this.opts.runner.instanceName, + // Supervisor timing + dequeueResponseMs: opts.dequeueResponseMs, + pollingIntervalMs: opts.pollingIntervalMs, + warmStartCheckMs: opts.warmStartCheckMs, + // Request + image: imageRef, + }; + + const startMs = performance.now(); + + try { + const createRequest = { + name: runnerId, + image: imageRef, + env: envVars, + cpu: opts.machine.cpu, + memory_gb: opts.machine.memory, + metadata: { + runId: opts.runFriendlyId, + envId: opts.envId, + envType: opts.envType, + orgId: opts.orgId, + projectId: opts.projectId, + deploymentVersion: opts.deploymentVersion, + machine: opts.machine.name, + }, + ...(Object.keys(labels).length > 0 ? { labels } : {}), + }; + + // Retry transient placement failures instead of abandoning the run: a + // swallowed create error leaves the run waiting for the run engine's + // PENDING_EXECUTING timeout (minutes) before it is redriven, while a + // retried create typically succeeds in under a second (TRI-10293). + let error: unknown; + let data: Awaited> | null | undefined; + let attempt = 1; + // Set after a ComputeClientError: the failed create may have left its + // name registered, so subsequent attempts use a suffixed name. + let suffixAttempts = false; + for (; attempt <= this.createMaxAttempts; attempt++) { + const attemptRunnerId = suffixAttempts ? runnerNameForAttempt(runnerId, attempt) : runnerId; + [error, data] = await tryCatch( + this.compute.instances.create( + attemptRunnerId === runnerId + ? createRequest + : { + ...createRequest, + name: attemptRunnerId, + env: { ...envVars, TRIGGER_RUNNER_ID: attemptRunnerId }, + } + ) + ); + + if (!error) { + event.runnerId = attemptRunnerId; + break; + } + + if (error instanceof ComputeClientError) { + suffixAttempts = true; + } + + this.logger.warn("create instance attempt failed", { + runnerId: attemptRunnerId, + attempt, + error: error instanceof Error ? error.message : String(error), + }); + + if (!isRetryableCreateError(error) || attempt === this.createMaxAttempts) break; + await sleep(this.createRetryBaseDelayMs * attempt); + } + event.createAttempts = attempt; + + if (error || !data) { + event.error = error instanceof Error ? error.message : String(error); + event.errorType = + error instanceof DOMException && error.name === "TimeoutError" ? "timeout" : "fetch"; + // Intentional: errors are captured in the wide event, not thrown. This matches + // the Docker/K8s managers. The run will eventually time out if scheduling fails. + return; + } + + event.instanceId = data.id; + event.ok = true; + + // Parse timing data from compute response (optional - requires gateway timing flag) + if (data._timing) { + event.timing = data._timing; + } + + this.#emitProvisionSpan(opts, startMs, data._timing); + } finally { + event.durationMs = Math.round(performance.now() - startMs); + event.ok ??= false; + this.logger.debug("create instance", event); + } + } + + async snapshot(opts: { runnerId: string; metadata: Record }): Promise { + const [error] = await tryCatch( + this.compute.instances.snapshot(opts.runnerId, { + callback: { + url: this.opts.snapshots.callbackUrl, + metadata: opts.metadata, + }, + }) + ); + + if (error) { + this.logger.error("snapshot request failed", { + runnerId: opts.runnerId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + + this.logger.debug("snapshot request accepted", { runnerId: opts.runnerId }); + return true; + } + + async deleteInstance(runnerId: string): Promise { + const [error] = await tryCatch(this.compute.instances.delete(runnerId)); + + if (error) { + this.logger.error("delete instance failed", { + runnerId, + error: error instanceof Error ? error.message : String(error), + }); + return false; + } + + this.logger.debug("delete instance success", { runnerId }); + return true; + } + + #emitProvisionSpan(opts: WorkloadManagerCreateOptions, startMs: number, timing?: unknown) { + if (!this.traceSpansEnabled) return; + + const parsed = parseTraceparent(extractTraceparent(opts.traceContext)); + if (!parsed) return; + + const endMs = performance.now(); + const now = Date.now(); + const provisionStartEpochMs = now - (endMs - startMs); + const endEpochMs = now; + + // Span starts at dequeue time so events (dequeue) render in the thin-line section + // before "Started". The actual provision call time is in provisionStartEpochMs. + // Subtract 1ms so compute span always sorts before the attempt span (same dequeue time) + const startEpochMs = opts.dequeuedAt.getTime() - 1; + + const spanAttributes: Record = { + "compute.type": "create", + "compute.provision_start_ms": provisionStartEpochMs, + ...(timing + ? (flattenAttributes(timing, "compute") as Record) + : {}), + }; + + if (opts.dequeueResponseMs !== undefined) { + spanAttributes["supervisor.dequeue_response_ms"] = opts.dequeueResponseMs; + } + if (opts.warmStartCheckMs !== undefined) { + spanAttributes["supervisor.warm_start_check_ms"] = opts.warmStartCheckMs; + } + + // Use the platform API URL, not the runner OTLP endpoint (which may be a VM gateway IP) + this.opts.tracing?.emit({ + traceId: parsed.traceId, + parentSpanId: parsed.spanId, + spanName: "compute.provision", + startTimeMs: startEpochMs, + endTimeMs: endEpochMs, + resourceAttributes: { + "ctx.environment.id": opts.envId, + "ctx.organization.id": opts.orgId, + "ctx.project.id": opts.projectId, + "ctx.run.id": opts.runFriendlyId, + }, + spanAttributes, + }); + } + + async restore(opts: { + snapshotId: string; + runnerId: string; + runFriendlyId: string; + snapshotFriendlyId: string; + machine: { cpu: number; memory: number }; + // Trace context for OTel span emission + traceContext?: Record; + envId?: string; + orgId?: string; + projectId?: string; + hasPrivateLink?: boolean; + dequeuedAt?: Date; + }): Promise { + const metadata: Record = { + TRIGGER_RUNNER_ID: opts.runnerId, + TRIGGER_RUN_ID: opts.runFriendlyId, + TRIGGER_SNAPSHOT_ID: opts.snapshotFriendlyId, + TRIGGER_SUPERVISOR_API_PROTOCOL: this.opts.workloadApiProtocol, + TRIGGER_SUPERVISOR_API_PORT: String(this.opts.workloadApiPort), + TRIGGER_SUPERVISOR_API_DOMAIN: this.opts.workloadApiDomain ?? "", + TRIGGER_WORKER_INSTANCE_NAME: this.opts.runner.instanceName, + }; + + // Resupply labels on restore (the provider doesn't persist them across a + // snapshot). orgId is optional on the restore opts type, so guard it. + const labels: Record = {}; + if (opts.orgId) { + labels.org = opts.orgId; + } + + this.logger.verbose("restore request body", { + snapshotId: opts.snapshotId, + runnerId: opts.runnerId, + }); + + const startMs = performance.now(); + + const [error] = await tryCatch( + this.compute.snapshots.restore(opts.snapshotId, { + name: opts.runnerId, + metadata, + cpu: opts.machine.cpu, + memory_gb: opts.machine.memory, + ...(Object.keys(labels).length > 0 ? { labels } : {}), + }) + ); + + const durationMs = Math.round(performance.now() - startMs); + + if (error) { + this.logger.error("restore request failed", { + snapshotId: opts.snapshotId, + runnerId: opts.runnerId, + error: error instanceof Error ? error.message : String(error), + durationMs, + }); + return false; + } + + this.logger.debug("restore request success", { + snapshotId: opts.snapshotId, + runnerId: opts.runnerId, + durationMs, + }); + + this.#emitRestoreSpan(opts, startMs); + + return true; + } + + #emitRestoreSpan( + opts: { + snapshotId: string; + runnerId: string; + runFriendlyId: string; + traceContext?: Record; + envId?: string; + orgId?: string; + projectId?: string; + dequeuedAt?: Date; + }, + startMs: number + ) { + if (!this.traceSpansEnabled) return; + + const parsed = parseTraceparent(extractTraceparent(opts.traceContext)); + if (!parsed || !opts.envId || !opts.orgId || !opts.projectId) return; + + const endMs = performance.now(); + const now = Date.now(); + const restoreStartEpochMs = now - (endMs - startMs); + const endEpochMs = now; + + // Subtract 1ms so restore span always sorts before the attempt span + const startEpochMs = (opts.dequeuedAt?.getTime() ?? restoreStartEpochMs) - 1; + + this.opts.tracing?.emit({ + traceId: parsed.traceId, + parentSpanId: parsed.spanId, + spanName: "compute.restore", + startTimeMs: startEpochMs, + endTimeMs: endEpochMs, + resourceAttributes: { + "ctx.environment.id": opts.envId, + "ctx.organization.id": opts.orgId, + "ctx.project.id": opts.projectId, + "ctx.run.id": opts.runFriendlyId, + }, + spanAttributes: { + "compute.type": "restore", + "compute.snapshot_id": opts.snapshotId, + }, + }); + } +} diff --git a/apps/supervisor/src/workloadManager/docker.ts b/apps/supervisor/src/workloadManager/docker.ts new file mode 100644 index 00000000000..1d8fec1df78 --- /dev/null +++ b/apps/supervisor/src/workloadManager/docker.ts @@ -0,0 +1,305 @@ +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import { + type WorkloadManager, + type WorkloadManagerCreateOptions, + type WorkloadManagerOptions, +} from "./types.js"; +import { env } from "../env.js"; +import { getDockerHostDomain, getRunnerId, normalizeDockerHostUrl } from "../util.js"; +import Docker from "dockerode"; +import { tryCatch } from "@trigger.dev/core"; +import { ECRAuthService } from "./ecrAuth.js"; + +export class DockerWorkloadManager implements WorkloadManager { + private readonly logger = new SimpleStructuredLogger("docker-workload-manager"); + private readonly docker: Docker; + + private readonly runnerNetworks: string[]; + private readonly staticAuth?: Docker.AuthConfig; + private readonly platformOverride?: string; + private readonly ecrAuthService?: ECRAuthService; + + constructor(private opts: WorkloadManagerOptions) { + this.docker = new Docker({ + version: env.DOCKER_API_VERSION, + }); + + if (opts.workloadApiDomain) { + this.logger.warn("โš ๏ธ Custom workload API domain", { + domain: opts.workloadApiDomain, + }); + } + + this.runnerNetworks = env.DOCKER_RUNNER_NETWORKS.split(","); + + this.platformOverride = env.DOCKER_PLATFORM; + if (this.platformOverride) { + this.logger.info("๐Ÿ–ฅ๏ธ Platform override", { + targetPlatform: this.platformOverride, + hostPlatform: process.arch, + }); + } + + if (env.DOCKER_REGISTRY_USERNAME && env.DOCKER_REGISTRY_PASSWORD && env.DOCKER_REGISTRY_URL) { + this.logger.info("๐Ÿ‹ Using Docker registry credentials", { + username: env.DOCKER_REGISTRY_USERNAME, + url: env.DOCKER_REGISTRY_URL, + }); + + this.staticAuth = { + username: env.DOCKER_REGISTRY_USERNAME, + password: env.DOCKER_REGISTRY_PASSWORD, + serveraddress: env.DOCKER_REGISTRY_URL, + }; + } else if (ECRAuthService.hasAWSCredentials()) { + this.logger.info("๐Ÿ‹ AWS credentials found, initializing ECR auth service"); + this.ecrAuthService = new ECRAuthService(); + } else { + this.logger.warn( + "๐Ÿ‹ No Docker registry credentials or AWS credentials provided, skipping auth" + ); + } + } + + async create(opts: WorkloadManagerCreateOptions) { + this.logger.verbose("create()", { opts }); + + const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber); + + // Build environment variables + const envVars: string[] = [ + `OTEL_EXPORTER_OTLP_ENDPOINT=${env.OTEL_EXPORTER_OTLP_ENDPOINT}`, + `TRIGGER_DEQUEUED_AT_MS=${opts.dequeuedAt.getTime()}`, + `TRIGGER_POD_SCHEDULED_AT_MS=${Date.now()}`, + `TRIGGER_ENV_ID=${opts.envId}`, + `TRIGGER_DEPLOYMENT_ID=${opts.deploymentFriendlyId}`, + `TRIGGER_DEPLOYMENT_VERSION=${opts.deploymentVersion}`, + `TRIGGER_RUN_ID=${opts.runFriendlyId}`, + `TRIGGER_SNAPSHOT_ID=${opts.snapshotFriendlyId}`, + `TRIGGER_SUPERVISOR_API_PROTOCOL=${this.opts.workloadApiProtocol}`, + `TRIGGER_SUPERVISOR_API_PORT=${this.opts.workloadApiPort}`, + `TRIGGER_SUPERVISOR_API_DOMAIN=${this.opts.workloadApiDomain ?? getDockerHostDomain()}`, + `TRIGGER_WORKER_INSTANCE_NAME=${env.TRIGGER_WORKER_INSTANCE_NAME}`, + `TRIGGER_RUNNER_ID=${runnerId}`, + `TRIGGER_MACHINE_CPU=${opts.machine.cpu}`, + `TRIGGER_MACHINE_MEMORY=${opts.machine.memory}`, + `PRETTY_LOGS=${env.RUNNER_PRETTY_LOGS}`, + `TRIGGER_SEND_RUN_DEBUG_LOGS=${env.SEND_RUN_DEBUG_LOGS}`, + ]; + + if (this.opts.warmStartUrl) { + envVars.push(`TRIGGER_WARM_START_URL=${normalizeDockerHostUrl(this.opts.warmStartUrl)}`); + } + + if (this.opts.metadataUrl) { + envVars.push(`TRIGGER_METADATA_URL=${this.opts.metadataUrl}`); + } + + if (this.opts.heartbeatIntervalSeconds) { + envVars.push(`TRIGGER_HEARTBEAT_INTERVAL_SECONDS=${this.opts.heartbeatIntervalSeconds}`); + } + + if (this.opts.snapshotPollIntervalSeconds) { + envVars.push( + `TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS=${this.opts.snapshotPollIntervalSeconds}` + ); + } + + if (this.opts.additionalEnvVars) { + Object.entries(this.opts.additionalEnvVars).forEach(([key, value]) => { + envVars.push(`${key}=${value}`); + }); + } + + const hostConfig: Docker.HostConfig = { + AutoRemove: !!this.opts.dockerAutoremove, + }; + + const [firstNetwork, ...remainingNetworks] = this.runnerNetworks; + + // Always attach the first network at container creation time. This has the following benefits: + // - If there is only a single network to attach, this will prevent having to make a separate request. + // - If there are multiple networks to attach, this will ensure the runner won't also be connected to the bridge network + hostConfig.NetworkMode = firstNetwork; + + if (env.DOCKER_ENFORCE_MACHINE_PRESETS) { + hostConfig.NanoCpus = opts.machine.cpu * 1e9; + hostConfig.Memory = opts.machine.memory * 1024 * 1024 * 1024; + } + + let imageRef = opts.image; + + if (env.DOCKER_STRIP_IMAGE_DIGEST) { + imageRef = opts.image.split("@")[0]!; + } + + const containerCreateOpts: Docker.ContainerCreateOptions = { + name: runnerId, + Hostname: runnerId, + HostConfig: hostConfig, + Image: imageRef, + AttachStdout: false, + AttachStderr: false, + AttachStdin: false, + }; + + if (this.platformOverride) { + containerCreateOpts.platform = this.platformOverride; + } + + const logger = this.logger.child({ opts, containerCreateOpts }); + + const [inspectError, inspectResult] = await tryCatch(this.docker.getImage(imageRef).inspect()); + + let shouldPull = !!inspectError; + if (this.platformOverride) { + const imageArchitecture = inspectResult?.Architecture; + + // When the image architecture doesn't match the platform, we need to pull the image + if (imageArchitecture && !this.platformOverride.includes(imageArchitecture)) { + shouldPull = true; + } + } + + // If the image is not present, try to pull it + if (shouldPull) { + logger.info("Pulling image", { + error: inspectError, + image: opts.image, + targetPlatform: this.platformOverride, + imageArchitecture: inspectResult?.Architecture, + }); + + // Get auth config (static or ECR) + const authConfig = await this.getAuthConfig(); + + // Ensure the image is present + const [createImageError, imageResponseReader] = await tryCatch( + this.docker.createImage(authConfig, { + fromImage: imageRef, + ...(this.platformOverride ? { platform: this.platformOverride } : {}), + }) + ); + if (createImageError) { + logger.error("Failed to pull image", { error: createImageError }); + return; + } + + const [imageReadError, imageResponse] = await tryCatch(readAllChunks(imageResponseReader)); + if (imageReadError) { + logger.error("failed to read image response", { error: imageReadError }); + return; + } + + logger.debug("pulled image", { image: opts.image, imageResponse }); + } else { + // Image is present, so we can use it to create the container + } + + // Create container + const [createContainerError, container] = await tryCatch( + this.docker.createContainer({ + ...containerCreateOpts, + // Add env vars here so they're not logged + Env: envVars, + }) + ); + + if (createContainerError) { + logger.error("Failed to create container", { error: createContainerError }); + return; + } + + // If there are multiple networks to attach to we need to attach the remaining ones after creation + if (remainingNetworks.length > 0) { + await this.attachContainerToNetworks({ + containerId: container.id, + networkNames: remainingNetworks, + }); + } + + // Start container + const [startError, startResult] = await tryCatch(container.start()); + + if (startError) { + logger.error("Failed to start container", { error: startError, containerId: container.id }); + return; + } + + logger.debug("create succeeded", { startResult, containerId: container.id }); + } + + /** + * Get authentication config for Docker operations + * Uses static credentials if available, otherwise attempts ECR auth + */ + private async getAuthConfig(): Promise { + // Use static credentials if available + if (this.staticAuth) { + return this.staticAuth; + } + + // Use ECR auth if service is available + if (this.ecrAuthService) { + const ecrAuth = await this.ecrAuthService.getAuthConfig(); + return ecrAuth || undefined; + } + + // No auth available + return undefined; + } + + private async attachContainerToNetworks({ + containerId, + networkNames, + }: { + containerId: string; + networkNames: string[]; + }) { + this.logger.debug("Attaching container to networks", { containerId, networkNames }); + + const [error, networkResults] = await tryCatch( + this.docker.listNetworks({ + filters: { + // Full name matches only to prevent unexpected results + name: networkNames.map((name) => `^${name}$`), + }, + }) + ); + + if (error) { + this.logger.error("Failed to list networks", { networkNames }); + return; + } + + const results = await Promise.allSettled( + networkResults.map((networkInfo) => { + const network = this.docker.getNetwork(networkInfo.Id); + return network.connect({ Container: containerId }); + }) + ); + + if (results.some((r) => r.status === "rejected")) { + this.logger.error("Failed to attach container to some networks", { + containerId, + networkNames, + results, + }); + return; + } + + this.logger.debug("Attached container to networks", { + containerId, + networkNames, + results, + }); + } +} + +async function readAllChunks(reader: NodeJS.ReadableStream) { + const chunks = []; + for await (const chunk of reader) { + chunks.push(chunk.toString()); + } + return chunks; +} diff --git a/apps/supervisor/src/workloadManager/ecrAuth.ts b/apps/supervisor/src/workloadManager/ecrAuth.ts new file mode 100644 index 00000000000..851249898b4 --- /dev/null +++ b/apps/supervisor/src/workloadManager/ecrAuth.ts @@ -0,0 +1,144 @@ +import { ECRClient, GetAuthorizationTokenCommand } from "@aws-sdk/client-ecr"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import { tryCatch } from "@trigger.dev/core"; +import type Docker from "dockerode"; + +interface ECRTokenCache { + token: string; + username: string; + serverAddress: string; + expiresAt: Date; +} + +export class ECRAuthService { + private readonly logger = new SimpleStructuredLogger("ecr-auth-service"); + private readonly ecrClient: ECRClient; + private tokenCache: ECRTokenCache | null = null; + + constructor() { + this.ecrClient = new ECRClient(); + + this.logger.info("๐Ÿ” ECR Auth Service initialized", { + region: this.ecrClient.config.region, + }); + } + + /** + * Check if we have AWS credentials configured + */ + static hasAWSCredentials(): boolean { + if (process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY) { + return true; + } + + if ( + process.env.AWS_PROFILE || + process.env.AWS_ROLE_ARN || + process.env.AWS_WEB_IDENTITY_TOKEN_FILE + ) { + return true; + } + + return false; + } + + /** + * Check if the current token is still valid with a 10-minute buffer + */ + private isTokenValid(): boolean { + if (!this.tokenCache) { + return false; + } + + const now = new Date(); + const bufferMs = 10 * 60 * 1000; // 10 minute buffer before expiration + return now < new Date(this.tokenCache.expiresAt.getTime() - bufferMs); + } + + /** + * Get a fresh ECR authorization token from AWS + */ + private async fetchNewToken(): Promise { + const [error, response] = await tryCatch( + this.ecrClient.send(new GetAuthorizationTokenCommand({})) + ); + + if (error) { + this.logger.error("Failed to get ECR authorization token", { error }); + return null; + } + + const authData = response.authorizationData?.[0]; + if (!authData?.authorizationToken || !authData.proxyEndpoint) { + this.logger.error("Invalid ECR authorization response", { authData }); + return null; + } + + // Decode the base64 token to get username:password + const decoded = Buffer.from(authData.authorizationToken, "base64").toString("utf-8"); + const [username, password] = decoded.split(":", 2); + + if (!username || !password) { + this.logger.error("Failed to parse ECR authorization token"); + return null; + } + + const expiresAt = authData.expiresAt || new Date(Date.now() + 12 * 60 * 60 * 1000); // Default 12 hours + + const tokenCache: ECRTokenCache = { + token: password, + username, + serverAddress: authData.proxyEndpoint, + expiresAt, + }; + + this.logger.info("๐Ÿ” Successfully fetched ECR token", { + username, + serverAddress: authData.proxyEndpoint, + expiresAt: expiresAt.toISOString(), + }); + + return tokenCache; + } + + /** + * Get ECR auth config for Docker operations + * Returns cached token if valid, otherwise fetches a new one + */ + async getAuthConfig(): Promise { + // Check if cached token is still valid + if (this.isTokenValid()) { + this.logger.debug("Using cached ECR token"); + return { + username: this.tokenCache!.username, + password: this.tokenCache!.token, + serveraddress: this.tokenCache!.serverAddress, + }; + } + + // Fetch new token + this.logger.info("Fetching new ECR authorization token"); + const newToken = await this.fetchNewToken(); + + if (!newToken) { + return null; + } + + // Cache the new token + this.tokenCache = newToken; + + return { + username: newToken.username, + password: newToken.token, + serveraddress: newToken.serverAddress, + }; + } + + /** + * Clear the cached token (useful for testing or forcing refresh) + */ + clearCache(): void { + this.tokenCache = null; + this.logger.debug("ECR token cache cleared"); + } +} diff --git a/apps/supervisor/src/workloadManager/kubernetes.test.ts b/apps/supervisor/src/workloadManager/kubernetes.test.ts new file mode 100644 index 00000000000..85ad3cbebff --- /dev/null +++ b/apps/supervisor/src/workloadManager/kubernetes.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { + BLOCK_IO_URING_SECCOMP_PROFILE, + withBlockIoUringSeccompProfile, +} from "./kubernetesPodSpec.js"; + +const basePodSpec = { + restartPolicy: "Never" as const, + automountServiceAccountToken: false, + securityContext: { + runAsNonRoot: true, + runAsUser: 1000, + fsGroup: 1000, + }, +}; + +describe("withBlockIoUringSeccompProfile", () => { + it("adds the Localhost io_uring profile for node-24 and above, preserving pod security defaults", () => { + for (const runtime of ["node-24", "node-26", "node-30", "experimental-node-24"]) { + const podSpec = withBlockIoUringSeccompProfile(basePodSpec, runtime); + + expect(podSpec).toMatchObject({ + ...basePodSpec, + securityContext: { + ...basePodSpec.securityContext, + seccompProfile: { + type: "Localhost", + localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE, + }, + }, + }); + } + }); + + it("leaves the pod spec unchanged for runtimes that do not create io_uring fds", () => { + for (const runtime of ["node", "node-22", "bun", undefined, null, ""]) { + expect(withBlockIoUringSeccompProfile(basePodSpec, runtime)).toEqual(basePodSpec); + } + }); +}); diff --git a/apps/supervisor/src/workloadManager/kubernetes.ts b/apps/supervisor/src/workloadManager/kubernetes.ts new file mode 100644 index 00000000000..282ac80b4fd --- /dev/null +++ b/apps/supervisor/src/workloadManager/kubernetes.ts @@ -0,0 +1,588 @@ +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import { + type WorkloadManager, + type WorkloadManagerCreateOptions, + type WorkloadManagerOptions, +} from "./types.js"; +import type { + EnvironmentType, + MachinePreset, + MachinePresetName, + PlacementTag, +} from "@trigger.dev/core/v3"; +import { PlacementTagProcessor } from "@trigger.dev/core/v3/serverOnly"; +import { env } from "../env.js"; +import { type K8sApi, createK8sApi, type k8s } from "../clients/kubernetes.js"; +import { getRunnerId } from "../util.js"; +import { withBlockIoUringSeccompProfile } from "./kubernetesPodSpec.js"; + +type ResourceQuantities = { + [K in "cpu" | "memory" | "ephemeral-storage"]?: string; +}; + +const cpuRequestRatioByMachinePreset: Record = { + micro: env.KUBERNETES_CPU_REQUEST_RATIO_MICRO, + "small-1x": env.KUBERNETES_CPU_REQUEST_RATIO_SMALL_1X, + "small-2x": env.KUBERNETES_CPU_REQUEST_RATIO_SMALL_2X, + "medium-1x": env.KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_1X, + "medium-2x": env.KUBERNETES_CPU_REQUEST_RATIO_MEDIUM_2X, + "large-1x": env.KUBERNETES_CPU_REQUEST_RATIO_LARGE_1X, + "large-2x": env.KUBERNETES_CPU_REQUEST_RATIO_LARGE_2X, +}; + +const memoryRequestRatioByMachinePreset: Record = { + micro: env.KUBERNETES_MEMORY_REQUEST_RATIO_MICRO, + "small-1x": env.KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_1X, + "small-2x": env.KUBERNETES_MEMORY_REQUEST_RATIO_SMALL_2X, + "medium-1x": env.KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_1X, + "medium-2x": env.KUBERNETES_MEMORY_REQUEST_RATIO_MEDIUM_2X, + "large-1x": env.KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_1X, + "large-2x": env.KUBERNETES_MEMORY_REQUEST_RATIO_LARGE_2X, +}; + +export class KubernetesWorkloadManager implements WorkloadManager { + private readonly logger = new SimpleStructuredLogger("kubernetes-workload-provider"); + private k8s: K8sApi; + private namespace = env.KUBERNETES_NAMESPACE; + private placementTagProcessor: PlacementTagProcessor; + + // Resource settings + private readonly cpuRequestMinCores = env.KUBERNETES_CPU_REQUEST_MIN_CORES; + private readonly cpuRequestRatio = env.KUBERNETES_CPU_REQUEST_RATIO; + private readonly memoryRequestMinGb = env.KUBERNETES_MEMORY_REQUEST_MIN_GB; + private readonly memoryRequestRatio = env.KUBERNETES_MEMORY_REQUEST_RATIO; + private readonly memoryOverheadGb = env.KUBERNETES_MEMORY_OVERHEAD_GB; + + constructor(private opts: WorkloadManagerOptions) { + this.k8s = createK8sApi(); + this.placementTagProcessor = new PlacementTagProcessor({ + enabled: env.PLACEMENT_TAGS_ENABLED, + prefix: env.PLACEMENT_TAGS_PREFIX, + }); + + if (opts.workloadApiDomain) { + this.logger.warn("[KubernetesWorkloadManager] โš ๏ธ Custom workload API domain", { + domain: opts.workloadApiDomain, + }); + } + } + + private addPlacementTags( + podSpec: Omit, + placementTags?: PlacementTag[] + ): Omit { + const nodeSelector = this.placementTagProcessor.convertToNodeSelector( + placementTags, + podSpec.nodeSelector + ); + + return { + ...podSpec, + nodeSelector, + }; + } + + private stripImageDigest(imageRef: string): string { + if (!env.KUBERNETES_STRIP_IMAGE_DIGEST) { + return imageRef; + } + + const atIndex = imageRef.lastIndexOf("@"); + + if (atIndex === -1) { + return imageRef; + } + + return imageRef.substring(0, atIndex); + } + + private clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); + } + + async create(opts: WorkloadManagerCreateOptions) { + this.logger.verbose("[KubernetesWorkloadManager] Creating container", { opts }); + + const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber); + + try { + const basePodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags); + const podSpec = this.opts.checkpointsEnabled + ? withBlockIoUringSeccompProfile(basePodSpec, opts.runtime) + : basePodSpec; + + await this.k8s.core.createNamespacedPod({ + namespace: this.namespace, + body: { + metadata: { + name: runnerId, + namespace: this.namespace, + labels: { + ...this.#getSharedLabels(opts), + app: "task-run", + "app.kubernetes.io/part-of": "trigger-worker", + "app.kubernetes.io/component": "create", + }, + }, + spec: { + ...podSpec, + affinity: this.#getAffinity(opts), + tolerations: this.#getScheduleTolerations(this.#isScheduledRun(opts)), + terminationGracePeriodSeconds: 60 * 60, + containers: [ + { + name: "run-controller", + image: this.stripImageDigest(opts.image), + ports: [ + { + containerPort: 8000, + }, + ], + resources: this.#getResourcesForMachine(opts.machine), + env: [ + { + name: "TRIGGER_DEQUEUED_AT_MS", + value: opts.dequeuedAt.getTime().toString(), + }, + { + name: "TRIGGER_POD_SCHEDULED_AT_MS", + value: Date.now().toString(), + }, + { + name: "TRIGGER_RUN_ID", + value: opts.runFriendlyId, + }, + { + name: "TRIGGER_ENV_ID", + value: opts.envId, + }, + { + name: "TRIGGER_DEPLOYMENT_ID", + value: opts.deploymentFriendlyId, + }, + { + name: "TRIGGER_DEPLOYMENT_VERSION", + value: opts.deploymentVersion, + }, + { + name: "TRIGGER_SNAPSHOT_ID", + value: opts.snapshotFriendlyId, + }, + { + name: "TRIGGER_SUPERVISOR_API_PROTOCOL", + value: this.opts.workloadApiProtocol, + }, + { + name: "TRIGGER_SUPERVISOR_API_PORT", + value: `${this.opts.workloadApiPort}`, + }, + { + name: "TRIGGER_SUPERVISOR_API_DOMAIN", + ...(this.opts.workloadApiDomain + ? { + value: this.opts.workloadApiDomain, + } + : { + valueFrom: { + fieldRef: { + fieldPath: "status.hostIP", + }, + }, + }), + }, + { + name: "TRIGGER_WORKER_INSTANCE_NAME", + valueFrom: { + fieldRef: { + fieldPath: "spec.nodeName", + }, + }, + }, + { + name: "OTEL_EXPORTER_OTLP_ENDPOINT", + value: env.OTEL_EXPORTER_OTLP_ENDPOINT, + }, + { + name: "TRIGGER_RUNNER_ID", + value: runnerId, + }, + { + name: "TRIGGER_MACHINE_CPU", + value: `${opts.machine.cpu}`, + }, + { + name: "TRIGGER_MACHINE_MEMORY", + value: `${opts.machine.memory}`, + }, + { + name: "TRIGGER_SEND_RUN_DEBUG_LOGS", + value: `${env.SEND_RUN_DEBUG_LOGS}`, + }, + { + name: "LIMITS_CPU", + valueFrom: { + resourceFieldRef: { + resource: "limits.cpu", + }, + }, + }, + { + name: "LIMITS_MEMORY", + valueFrom: { + resourceFieldRef: { + resource: "limits.memory", + }, + }, + }, + ...(this.opts.warmStartUrl + ? [{ name: "TRIGGER_WARM_START_URL", value: this.opts.warmStartUrl }] + : []), + ...(this.opts.metadataUrl + ? [{ name: "TRIGGER_METADATA_URL", value: this.opts.metadataUrl }] + : []), + ...(this.opts.heartbeatIntervalSeconds + ? [ + { + name: "TRIGGER_HEARTBEAT_INTERVAL_SECONDS", + value: `${this.opts.heartbeatIntervalSeconds}`, + }, + ] + : []), + ...(this.opts.snapshotPollIntervalSeconds + ? [ + { + name: "TRIGGER_SNAPSHOT_POLL_INTERVAL_SECONDS", + value: `${this.opts.snapshotPollIntervalSeconds}`, + }, + ] + : []), + ...(this.opts.additionalEnvVars + ? Object.entries(this.opts.additionalEnvVars).map(([key, value]) => ({ + name: key, + value: value, + })) + : []), + ], + }, + ], + }, + }, + }); + } catch (err: unknown) { + this.#handleK8sError(err); + } + } + + #throwUnlessRecord(candidate: unknown): asserts candidate is Record { + if (typeof candidate !== "object" || candidate === null) { + throw candidate; + } + } + + #handleK8sError(err: unknown) { + this.#throwUnlessRecord(err); + + if ("body" in err && err.body) { + this.logger.error("[KubernetesWorkloadManager] Create failed", { rawError: err.body }); + this.#throwUnlessRecord(err.body); + + if (typeof err.body.message === "string") { + throw new Error(err.body?.message); + } else { + throw err.body; + } + } else { + this.logger.error("[KubernetesWorkloadManager] Create failed", { rawError: err }); + throw err; + } + } + + #envTypeToLabelValue(type: EnvironmentType) { + switch (type) { + case "PRODUCTION": + return "prod"; + case "STAGING": + return "stg"; + case "DEVELOPMENT": + return "dev"; + case "PREVIEW": + return "preview"; + } + } + + private getImagePullSecrets(): k8s.V1LocalObjectReference[] | undefined { + return this.opts.imagePullSecrets?.map((name) => ({ name })); + } + + get #defaultPodSpec(): Omit { + return { + restartPolicy: "Never", + automountServiceAccountToken: false, + imagePullSecrets: this.getImagePullSecrets(), + ...(env.KUBERNETES_SCHEDULER_NAME + ? { + schedulerName: env.KUBERNETES_SCHEDULER_NAME, + } + : {}), + ...(env.KUBERNETES_WORKER_NODETYPE_LABEL + ? { + nodeSelector: { + nodetype: env.KUBERNETES_WORKER_NODETYPE_LABEL, + }, + } + : {}), + ...(env.KUBERNETES_POD_DNS_NDOTS_OVERRIDE_ENABLED + ? { + dnsConfig: { + options: [{ name: "ndots", value: `${env.KUBERNETES_POD_DNS_NDOTS}` }], + }, + } + : {}), + }; + } + + get #defaultResourceRequests(): ResourceQuantities { + return { + "ephemeral-storage": env.KUBERNETES_EPHEMERAL_STORAGE_SIZE_REQUEST, + }; + } + + get #defaultResourceLimits(): ResourceQuantities { + return { + "ephemeral-storage": env.KUBERNETES_EPHEMERAL_STORAGE_SIZE_LIMIT, + }; + } + + #isScheduledRun(opts: WorkloadManagerCreateOptions): boolean { + return opts.annotations?.rootTriggerSource === "schedule"; + } + + #getSharedLabels(opts: WorkloadManagerCreateOptions): Record { + const labels: Record = { + env: opts.envId, + envtype: this.#envTypeToLabelValue(opts.envType), + org: opts.orgId, + project: opts.projectId, + machine: opts.machine.name, + // We intentionally use a boolean label rather than exposing the full trigger source + // (e.g. sdk, api, cli, mcp, schedule) to keep label cardinality low in metrics. + // The schedule vs non-schedule distinction is all we need for the current metrics + // and pool-level scheduling decisions; finer-grained source breakdowns live in run annotations. + scheduled: String(this.#isScheduledRun(opts)), + }; + + // Add privatelink label for CiliumNetworkPolicy matching + if (opts.hasPrivateLink) { + labels.privatelink = opts.orgId; + } + + return labels; + } + + #getResourceRequestsForMachine(preset: MachinePreset): ResourceQuantities { + const cpuRatio = cpuRequestRatioByMachinePreset[preset.name] ?? this.cpuRequestRatio; + const memoryRatio = memoryRequestRatioByMachinePreset[preset.name] ?? this.memoryRequestRatio; + + const cpuRequest = preset.cpu * cpuRatio; + const memoryRequest = preset.memory * memoryRatio; + + // Clamp between min and max + const clampedCpu = this.clamp(cpuRequest, this.cpuRequestMinCores, preset.cpu); + const clampedMemory = this.clamp(memoryRequest, this.memoryRequestMinGb, preset.memory); + + return { + cpu: `${clampedCpu}`, + memory: `${clampedMemory}G`, + }; + } + + #getResourceLimitsForMachine(preset: MachinePreset): ResourceQuantities { + const memoryLimit = this.memoryOverheadGb + ? preset.memory + this.memoryOverheadGb + : preset.memory; + + return { + cpu: `${preset.cpu}`, + memory: `${memoryLimit}G`, + }; + } + + #getResourcesForMachine(preset: MachinePreset): k8s.V1ResourceRequirements { + return { + requests: { + ...this.#defaultResourceRequests, + ...this.#getResourceRequestsForMachine(preset), + }, + limits: { + ...this.#defaultResourceLimits, + ...this.#getResourceLimitsForMachine(preset), + }, + }; + } + + #isLargeMachine(preset: MachinePreset): boolean { + return preset.name.startsWith("large-"); + } + + #getAffinity(opts: WorkloadManagerCreateOptions): k8s.V1Affinity | undefined { + const largeNodeAffinity = this.#getNodeAffinityRules(opts.machine); + const scheduleNodeAffinity = this.#getScheduleNodeAffinityRules(this.#isScheduledRun(opts)); + const podAffinity = this.#getProjectPodAffinity(opts.projectId); + + // Merge node affinity rules from multiple sources + const preferred = [ + ...(largeNodeAffinity?.preferredDuringSchedulingIgnoredDuringExecution ?? []), + ...(scheduleNodeAffinity?.preferredDuringSchedulingIgnoredDuringExecution ?? []), + ]; + // Only large machine affinity produces hard requirements (non-large runs must stay off the large pool). + // Schedule affinity is soft both ways. + const required = [ + ...(largeNodeAffinity?.requiredDuringSchedulingIgnoredDuringExecution?.nodeSelectorTerms ?? + []), + ]; + + const hasNodeAffinity = preferred.length > 0 || required.length > 0; + + if (!hasNodeAffinity && !podAffinity) { + return undefined; + } + + return { + ...(hasNodeAffinity && { + nodeAffinity: { + ...(preferred.length > 0 && { + preferredDuringSchedulingIgnoredDuringExecution: preferred, + }), + ...(required.length > 0 && { + requiredDuringSchedulingIgnoredDuringExecution: { nodeSelectorTerms: required }, + }), + }, + }), + ...(podAffinity && { podAffinity }), + }; + } + + #getNodeAffinityRules(preset: MachinePreset): k8s.V1NodeAffinity | undefined { + if (!env.KUBERNETES_LARGE_MACHINE_AFFINITY_ENABLED) { + return undefined; + } + + if (this.#isLargeMachine(preset)) { + // soft preference for the large-machine pool, falls back to standard if unavailable + return { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: env.KUBERNETES_LARGE_MACHINE_AFFINITY_WEIGHT, + preference: { + matchExpressions: [ + { + key: env.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_KEY, + operator: "In", + values: [env.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_VALUE], + }, + ], + }, + }, + ], + }; + } + + // not schedulable in the large-machine pool + return { + requiredDuringSchedulingIgnoredDuringExecution: { + nodeSelectorTerms: [ + { + matchExpressions: [ + { + key: env.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_KEY, + operator: "NotIn", + values: [env.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_VALUE], + }, + ], + }, + ], + }, + }; + } + + #getScheduleNodeAffinityRules(isScheduledRun: boolean): k8s.V1NodeAffinity | undefined { + if ( + !env.KUBERNETES_SCHEDULED_RUN_AFFINITY_ENABLED || + !env.KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_VALUE + ) { + return undefined; + } + + if (isScheduledRun) { + // soft preference for the schedule pool + return { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: env.KUBERNETES_SCHEDULED_RUN_AFFINITY_WEIGHT, + preference: { + matchExpressions: [ + { + key: env.KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_KEY, + operator: "In", + values: [env.KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_VALUE], + }, + ], + }, + }, + ], + }; + } + + // soft anti-affinity: non-schedule runs prefer to avoid the schedule pool + return { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: env.KUBERNETES_SCHEDULED_RUN_ANTI_AFFINITY_WEIGHT, + preference: { + matchExpressions: [ + { + key: env.KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_KEY, + operator: "NotIn", + values: [env.KUBERNETES_SCHEDULED_RUN_AFFINITY_POOL_LABEL_VALUE], + }, + ], + }, + }, + ], + }; + } + + #getScheduleTolerations(isScheduledRun: boolean): k8s.V1Toleration[] | undefined { + if (!isScheduledRun || !env.KUBERNETES_SCHEDULED_RUN_TOLERATIONS?.length) { + return undefined; + } + + return env.KUBERNETES_SCHEDULED_RUN_TOLERATIONS; + } + + #getProjectPodAffinity(projectId: string): k8s.V1PodAffinity | undefined { + if (!env.KUBERNETES_PROJECT_AFFINITY_ENABLED) { + return undefined; + } + + return { + preferredDuringSchedulingIgnoredDuringExecution: [ + { + weight: env.KUBERNETES_PROJECT_AFFINITY_WEIGHT, + podAffinityTerm: { + labelSelector: { + matchExpressions: [ + { + key: "project", + operator: "In", + values: [projectId], + }, + ], + }, + topologyKey: env.KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY, + }, + }, + ], + }; + } +} diff --git a/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts new file mode 100644 index 00000000000..32c4410ca43 --- /dev/null +++ b/apps/supervisor/src/workloadManager/kubernetesPodSpec.ts @@ -0,0 +1,33 @@ +import type { k8s } from "../clients/kubernetes.js"; + +/** + * Relative path (kubelet seccomp root) of the profile blocking only io_uring + * syscalls. Must match the profile deployed to worker nodes. + */ +export const BLOCK_IO_URING_SECCOMP_PROFILE = "profiles/block-io-uring.json"; + +/** + * Node >= 24 always creates io_uring fds, which can't be checkpointed. Blocking + * io_uring_setup makes libuv fall back to epoll. Other runtimes don't need this, + * so the profile is only applied for node-24+. Tolerates an "experimental-" prefix. + */ +export function withBlockIoUringSeccompProfile( + podSpec: Omit, + runtime: string | null | undefined +): Omit { + const match = runtime ? /^(?:experimental-)?node-(\d+)$/.exec(runtime) : null; + if (!match || Number(match[1]) < 24) { + return podSpec; + } + + return { + ...podSpec, + securityContext: { + ...podSpec.securityContext, + seccompProfile: { + type: "Localhost", + localhostProfile: BLOCK_IO_URING_SECCOMP_PROFILE, + }, + }, + }; +} diff --git a/apps/supervisor/src/workloadManager/types.ts b/apps/supervisor/src/workloadManager/types.ts new file mode 100644 index 00000000000..4945b1382d9 --- /dev/null +++ b/apps/supervisor/src/workloadManager/types.ts @@ -0,0 +1,56 @@ +import type { + EnvironmentType, + MachinePreset, + PlacementTag, + RunAnnotations, +} from "@trigger.dev/core/v3"; + +export interface WorkloadManagerOptions { + workloadApiProtocol: "http" | "https"; + workloadApiDomain?: string; // If unset, will use orchestrator-specific default + workloadApiPort: number; + warmStartUrl?: string; + metadataUrl?: string; + imagePullSecrets?: string[]; + heartbeatIntervalSeconds?: number; + snapshotPollIntervalSeconds?: number; + additionalEnvVars?: Record; + dockerAutoremove?: boolean; + // Whether CRIU checkpoint/restore is enabled for this deployment + checkpointsEnabled?: boolean; +} + +export interface WorkloadManager { + create: (opts: WorkloadManagerCreateOptions) => Promise; +} + +export interface WorkloadManagerCreateOptions { + image: string; + machine: MachinePreset; + version: string; + nextAttemptNumber?: number; + dequeuedAt: Date; + placementTags?: PlacementTag[]; + // Timing context (populated by supervisor handler, included in wide event) + dequeueResponseMs?: number; + pollingIntervalMs?: number; + warmStartCheckMs?: number; + // identifiers + envId: string; + envType: EnvironmentType; + orgId: string; + projectId: string; + deploymentFriendlyId: string; + deploymentVersion: string; + // Canonical runtime identifier (e.g. "node", "node-22", "node-24") + runtime?: string; + runId: string; + runFriendlyId: string; + snapshotId: string; + snapshotFriendlyId: string; + // Trace context for OTel span emission (W3C format: { traceparent: "00-...", tracestate?: "..." }) + traceContext?: Record; + annotations?: RunAnnotations; + // private networking + hasPrivateLink?: boolean; +} diff --git a/apps/supervisor/src/workloadServer/index.ts b/apps/supervisor/src/workloadServer/index.ts new file mode 100644 index 00000000000..bacdbbe06d9 --- /dev/null +++ b/apps/supervisor/src/workloadServer/index.ts @@ -0,0 +1,883 @@ +import { SnapshotCallbackPayloadSchema } from "@internal/compute"; +import { type CheckpointClient, HttpServer } from "@trigger.dev/core/v3/serverOnly"; +import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger"; +import { + type WorkloadRunSnapshotsSinceResponseBody, + type SupervisorHttpClient, + WORKLOAD_HEADERS, + type WorkloadClientSocketData, + type WorkloadClientToServerEvents, + type WorkloadContinueRunExecutionResponseBody, + WorkloadDebugLogRequestBody, + type WorkloadDequeueFromVersionResponseBody, + WorkloadHeartbeatRequestBody, + type WorkloadHeartbeatResponseBody, + WorkloadRunAttemptCompleteRequestBody, + type WorkloadRunAttemptCompleteResponseBody, + WorkloadRunAttemptStartRequestBody, + type WorkloadRunAttemptStartResponseBody, + type WorkloadServerToClientEvents, + type WorkloadSuspendRunResponseBody, +} from "@trigger.dev/core/v3/workers"; +import EventEmitter from "node:events"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import { type Namespace, Server, type Socket } from "socket.io"; +import { z } from "zod"; +import { env } from "../env.js"; +import { register } from "../metrics.js"; +import { + ComputeSnapshotService, + type RunTraceContext, +} from "../services/computeSnapshotService.js"; +import type { OtlpTraceService } from "../services/otlpTraceService.js"; +import { + emitOneShot, + runWideEvent, + setMeta, + type State, + type WideEventOptions, +} from "../wideEvents/index.js"; +import type { ComputeWorkloadManager } from "../workloadManager/compute.js"; + +// Use the official export when upgrading to socket.io@4.8.0 +interface DefaultEventsMap { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + [event: string]: (...args: any[]) => void; +} + +const WorkloadActionParams = z.object({ + runFriendlyId: z.string(), + snapshotFriendlyId: z.string(), +}); + +// Workloads bundled into customer task images before CLI v4.4.4 use a strict +// zod enum for checkpoint type that only allows DOCKER and KUBERNETES. The +// workload never reads this field - it only validates the response shape - so +// rewriting it to a known value keeps older runners working without affecting +// the value stored in the database or seen by internal services. +function legacifyCheckpointType(item: T): T { + if (item.checkpoint?.type === "COMPUTE") { + return { ...item, checkpoint: { ...item.checkpoint, type: "KUBERNETES" } } as T; + } + return item; +} + +type WorkloadServerEvents = { + runConnected: [ + { + run: { + friendlyId: string; + }; + }, + ]; + runDisconnected: [ + { + run: { + friendlyId: string; + }; + }, + ]; +}; + +type WorkloadServerOptions = { + port: number; + host?: string; + workerClient: SupervisorHttpClient; + checkpointClient?: CheckpointClient; + computeManager?: ComputeWorkloadManager; + tracing?: OtlpTraceService; + wideEventOpts: WideEventOptions; + /** When true, high-frequency HTTP routes also emit wide events. */ + wideEventsNoisyRoutes: boolean; +}; + +export class WorkloadServer extends EventEmitter { + private checkpointClient?: CheckpointClient; + private readonly snapshotService?: ComputeSnapshotService; + + private readonly logger = new SimpleStructuredLogger("workload-server"); + private readonly wideEventOpts: WideEventOptions; + private readonly wideEventsNoisyRoutes: boolean; + + private readonly httpServer: HttpServer; + private readonly websocketServer: Namespace< + WorkloadClientToServerEvents, + WorkloadServerToClientEvents, + DefaultEventsMap, + WorkloadClientSocketData + >; + + private readonly runSockets = new Map< + string, + Socket< + WorkloadClientToServerEvents, + WorkloadServerToClientEvents, + DefaultEventsMap, + WorkloadClientSocketData + > + >(); + + private readonly workerClient: SupervisorHttpClient; + + constructor(opts: WorkloadServerOptions) { + super(); + + const host = opts.host ?? "0.0.0.0"; + const port = opts.port; + + this.workerClient = opts.workerClient; + this.checkpointClient = opts.checkpointClient; + this.wideEventOpts = opts.wideEventOpts; + this.wideEventsNoisyRoutes = opts.wideEventsNoisyRoutes; + + if (opts.computeManager?.snapshotsEnabled) { + this.snapshotService = new ComputeSnapshotService({ + computeManager: opts.computeManager, + workerClient: opts.workerClient, + tracing: opts.tracing, + wideEventOpts: this.wideEventOpts, + }); + } + + this.httpServer = this.createHttpServer({ host, port }); + this.websocketServer = this.createWebsocketServer(); + } + + private headerValueFromRequest(req: IncomingMessage, headerName: string): string | undefined { + const value = req.headers[headerName]; + + if (Array.isArray(value)) { + return value[0]; + } + + return value; + } + + private runnerIdFromRequest(req: IncomingMessage): string | undefined { + return this.headerValueFromRequest(req, WORKLOAD_HEADERS.RUNNER_ID); + } + + private deploymentIdFromRequest(req: IncomingMessage): string | undefined { + return this.headerValueFromRequest(req, WORKLOAD_HEADERS.DEPLOYMENT_ID); + } + + private deploymentVersionFromRequest(req: IncomingMessage): string | undefined { + return this.headerValueFromRequest(req, WORKLOAD_HEADERS.DEPLOYMENT_VERSION); + } + + private projectRefFromRequest(req: IncomingMessage): string | undefined { + return this.headerValueFromRequest(req, WORKLOAD_HEADERS.PROJECT_REF); + } + + /** + * Sets common route meta on the wide-event state from URL params. + */ + private attachRouteMeta(state: State, params: unknown): void { + if (!params || typeof params !== "object") return; + const p = params as Record; + if (typeof p.runFriendlyId === "string") setMeta(state, "run_id", p.runFriendlyId); + if (typeof p.snapshotFriendlyId === "string") { + setMeta(state, "snapshot_id", p.snapshotFriendlyId); + } + if (typeof p.deploymentId === "string") setMeta(state, "deployment_id", p.deploymentId); + } + + /** + * Wraps an HTTP route handler body with the wide-event lifecycle. Reads + * `traceparent` and `x-request-id` from `req.headers`, attaches `run_id` / + * `snapshot_id` / `deployment_id` meta from `params` when present, and + * captures the response status from `res.statusCode` after `fn` returns. + * + * Pass `highFrequency: true` for noisy routes (heartbeat, polling). Those + * still go through the wrapper but only emit when + * `TRIGGER_WIDE_EVENTS_NOISY_ROUTES` is on, so prod can keep them dark + * while test envs capture full-fidelity traffic for debugging. + */ + private wideRoute( + ctx: { req: IncomingMessage; res: ServerResponse; params?: unknown }, + op: string, + route: string, + method: string, + fn: () => Promise | T, + routeOpts: { highFrequency?: boolean } = {} + ): Promise { + const enabled = + this.wideEventOpts.enabled && (!routeOpts.highFrequency || this.wideEventsNoisyRoutes); + return runWideEvent( + { + ...this.wideEventOpts, + enabled, + op, + kind: "inbound", + route, + method, + traceparent: this.headerValueFromRequest(ctx.req, "traceparent"), + inboundRequestId: this.headerValueFromRequest(ctx.req, "x-request-id"), + setup: (state) => this.attachRouteMeta(state, ctx.params), + }, + fn, + (state) => { + state.statusCode = ctx.res.statusCode; + } + ); + } + + private createHttpServer({ host, port }: { host: string; port: number }) { + const httpServer = new HttpServer({ + port, + host, + metrics: { + register, + expose: false, + }, + }) + .route("/health", "GET", { + handler: async ({ reply }) => { + reply.text("OK"); + }, + }) + .route( + "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/attempts/start", + "POST", + { + paramsSchema: WorkloadActionParams, + bodySchema: WorkloadRunAttemptStartRequestBody, + handler: async (ctx) => + this.wideRoute( + ctx, + "attempt.start", + "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/attempts/start", + "POST", + async () => { + const { req, reply, params, body } = ctx; + const startResponse = await this.workerClient.startRunAttempt( + params.runFriendlyId, + params.snapshotFriendlyId, + body, + this.runnerIdFromRequest(req) + ); + + if (!startResponse.success) { + this.logger.error("Failed to start run", { + params, + error: startResponse.error, + }); + reply.empty(500); + return; + } + + reply.json(startResponse.data satisfies WorkloadRunAttemptStartResponseBody); + return; + } + ), + } + ) + .route( + "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/attempts/complete", + "POST", + { + paramsSchema: WorkloadActionParams, + bodySchema: WorkloadRunAttemptCompleteRequestBody, + handler: async (ctx) => + this.wideRoute( + ctx, + "attempt.complete", + "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/attempts/complete", + "POST", + async () => { + const { req, reply, params, body } = ctx; + const runnerId = this.runnerIdFromRequest(req); + + // A completion attempt invalidates any pending delayed snapshot + // regardless of outcome: the runner has finished executing, so the + // suspended state the snapshot was scheduled to capture no longer + // exists. Cancel BEFORE the async completion call - the timer + // wheel can tick during the await, so cancelling after it leaves + // a real window for a due snapshot to dispatch and pause a VM + // that has moved on. The runnerId guard keeps a stale duplicate + // runner's completion from cancelling a fresh runner's snapshot, + // and the runner can't schedule a new suspend until it receives + // this route's reply, so nothing legitimate can be cancelled here. + this.snapshotService?.cancel(params.runFriendlyId, runnerId); + + const completeResponse = await this.workerClient.completeRunAttempt( + params.runFriendlyId, + params.snapshotFriendlyId, + body, + runnerId + ); + + if (!completeResponse.success) { + this.logger.error("Failed to complete run", { + params, + error: completeResponse.error, + }); + reply.empty(500); + return; + } + + reply.json(completeResponse.data satisfies WorkloadRunAttemptCompleteResponseBody); + return; + } + ), + } + ) + .route( + "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/heartbeat", + "POST", + { + paramsSchema: WorkloadActionParams, + bodySchema: WorkloadHeartbeatRequestBody, + handler: async (ctx) => + this.wideRoute( + ctx, + "heartbeat", + "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/heartbeat", + "POST", + async () => { + const { req, reply, params, body } = ctx; + const heartbeatResponse = await this.workerClient.heartbeatRun( + params.runFriendlyId, + params.snapshotFriendlyId, + body, + this.runnerIdFromRequest(req) + ); + + if (!heartbeatResponse.success) { + this.logger.error("Failed to heartbeat run", { + params, + error: heartbeatResponse.error, + }); + reply.empty(500); + return; + } + + reply.json({ + ok: true, + } satisfies WorkloadHeartbeatResponseBody); + }, + { highFrequency: true } + ), + } + ) + .route( + "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/suspend", + "GET", + { + paramsSchema: WorkloadActionParams, + handler: async (ctx) => + this.wideRoute( + ctx, + "suspend", + "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/suspend", + "GET", + async () => { + const { reply, params, req } = ctx; + const runnerId = this.runnerIdFromRequest(req); + const deploymentVersion = this.deploymentVersionFromRequest(req); + const projectRef = this.projectRefFromRequest(req); + + this.logger.debug("Suspend request", { + params, + runnerId, + deploymentVersion, + projectRef, + }); + + if (!runnerId || !deploymentVersion || !projectRef) { + this.logger.error("Invalid headers for suspend request", { + ...params, + runnerId, + deploymentVersion, + projectRef, + }); + reply.json( + { + ok: false, + error: "Invalid headers", + } satisfies WorkloadSuspendRunResponseBody, + false, + 400 + ); + return; + } + + if (this.snapshotService) { + // Compute mode: delay snapshot to avoid wasted work on short-lived waitpoints. + // If the run continues before the delay expires, the snapshot is cancelled. + reply.json({ ok: true } satisfies WorkloadSuspendRunResponseBody, false, 202); + + this.snapshotService.schedule(params.runFriendlyId, { + runnerId, + runFriendlyId: params.runFriendlyId, + snapshotFriendlyId: params.snapshotFriendlyId, + }); + + return; + } + + if (!this.checkpointClient) { + reply.json( + { + ok: false, + error: "Checkpoints disabled", + } satisfies WorkloadSuspendRunResponseBody, + false, + 400 + ); + return; + } + + reply.json( + { + ok: true, + } satisfies WorkloadSuspendRunResponseBody, + false, + 202 + ); + + const suspendResult = await this.checkpointClient.suspendRun({ + runFriendlyId: params.runFriendlyId, + snapshotFriendlyId: params.snapshotFriendlyId, + body: { + runnerId, + runId: params.runFriendlyId, + snapshotId: params.snapshotFriendlyId, + projectRef, + deploymentVersion, + }, + }); + + if (!suspendResult) { + this.logger.error("Failed to suspend run", { params }); + return; + } + } + ), + } + ) + .route( + "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/continue", + "GET", + { + paramsSchema: WorkloadActionParams, + handler: async (ctx) => + this.wideRoute( + ctx, + "continue", + "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/:snapshotFriendlyId/continue", + "GET", + async () => { + const { req, reply, params } = ctx; + this.logger.debug("Run continuation request", { params }); + + // Cancel any pending delayed snapshot for this run + this.snapshotService?.cancel(params.runFriendlyId); + + const continuationResult = await this.workerClient.continueRunExecution( + params.runFriendlyId, + params.snapshotFriendlyId, + this.runnerIdFromRequest(req) + ); + + if (!continuationResult.success) { + this.logger.error("Failed to continue run execution", { params }); + reply.json( + { + ok: false, + error: "Failed to continue run execution", + }, + false, + 400 + ); + return; + } + + reply.json(continuationResult.data as WorkloadContinueRunExecutionResponseBody); + } + ), + } + ) + .route( + "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/since/:snapshotFriendlyId", + "GET", + { + paramsSchema: WorkloadActionParams, + handler: async (ctx) => + this.wideRoute( + ctx, + "snapshots.since", + "/api/v1/workload-actions/runs/:runFriendlyId/snapshots/since/:snapshotFriendlyId", + "GET", + async () => { + const { req, reply, params } = ctx; + const sinceSnapshotResponse = await this.workerClient.getSnapshotsSince( + params.runFriendlyId, + params.snapshotFriendlyId, + this.runnerIdFromRequest(req) + ); + + if (!sinceSnapshotResponse.success) { + this.logger.error("Failed to get snapshots since", { + runId: params.runFriendlyId, + error: sinceSnapshotResponse.error, + }); + reply.empty(500); + return; + } + + reply.json({ + snapshots: sinceSnapshotResponse.data.snapshots.map(legacifyCheckpointType), + } satisfies WorkloadRunSnapshotsSinceResponseBody); + }, + { highFrequency: true } + ), + } + ) + .route("/api/v1/workload-actions/deployments/:deploymentId/dequeue", "GET", { + paramsSchema: z.object({ + deploymentId: z.string(), + }), + + handler: async (ctx) => + this.wideRoute( + ctx, + "deployment.dequeue", + "/api/v1/workload-actions/deployments/:deploymentId/dequeue", + "GET", + async () => { + const { req, reply, params } = ctx; + const dequeueResponse = await this.workerClient.dequeueFromVersion( + params.deploymentId, + 1, + this.runnerIdFromRequest(req) + ); + + if (!dequeueResponse.success) { + this.logger.error("Failed to get latest snapshot", { + deploymentId: params.deploymentId, + error: dequeueResponse.error, + }); + reply.empty(500); + return; + } + + reply.json( + dequeueResponse.data.map( + legacifyCheckpointType + ) satisfies WorkloadDequeueFromVersionResponseBody + ); + } + ), + }); + + if (env.SEND_RUN_DEBUG_LOGS) { + httpServer.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", { + paramsSchema: WorkloadActionParams.pick({ runFriendlyId: true }), + bodySchema: WorkloadDebugLogRequestBody, + handler: async (ctx) => + this.wideRoute( + ctx, + "logs.debug", + "/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", + "POST", + async () => { + const { req, reply, params, body } = ctx; + reply.empty(204); + + await this.workerClient.sendDebugLog( + params.runFriendlyId, + body, + this.runnerIdFromRequest(req) + ); + }, + { highFrequency: true } + ), + }); + } else { + // Disabled: drop immediately without reading/parsing the body and without + // any log we can't switch off. Older runners still POST per log line; the + // route stays registered (an unregistered route would log "No route match" + // per request) but sheds the request at minimal cost. Request metrics still + // count it. 204 is non-retryable on the runner client, so no retry storm. + httpServer.route("/api/v1/workload-actions/runs/:runFriendlyId/logs/debug", "POST", { + skipBodyParsing: true, + handler: async (ctx) => { + ctx.reply.empty(204); + }, + }); + } + + // Snapshot callback endpoint (inbound from compute path) + httpServer.route("/api/v1/compute/snapshot-complete", "POST", { + bodySchema: SnapshotCallbackPayloadSchema, + handler: async (ctx) => + this.wideRoute( + ctx, + "snapshot.callback", + "/api/v1/compute/snapshot-complete", + "POST", + async () => { + const { reply, body } = ctx; + if (!this.snapshotService) { + reply.empty(404); + return; + } + + const result = await this.snapshotService.handleCallback(body); + reply.empty(result.status); + } + ), + }); + + return httpServer; + } + + private createWebsocketServer() { + const io = new Server(this.httpServer.server); + + const websocketServer: Namespace< + WorkloadClientToServerEvents, + WorkloadServerToClientEvents, + DefaultEventsMap, + WorkloadClientSocketData + > = io.of("/workload"); + + websocketServer.on("disconnect", (socket) => { + this.logger.verbose("[WS] disconnect", socket.id); + }); + websocketServer.use(async (socket, next) => { + const setSocketDataFromHeader = ( + dataKey: keyof typeof socket.data, + headerName: string, + required: boolean = true + ) => { + const value = socket.handshake.headers[headerName]; + + if (value) { + if (Array.isArray(value)) { + if (value[0]) { + socket.data[dataKey] = value[0]; + return; + } + } else { + socket.data[dataKey] = value; + return; + } + } + + if (required) { + this.logger.error("[WS] missing required header", { headerName }); + throw new Error("missing header"); + } + }; + + try { + setSocketDataFromHeader("deploymentId", WORKLOAD_HEADERS.DEPLOYMENT_ID); + setSocketDataFromHeader("runnerId", WORKLOAD_HEADERS.RUNNER_ID); + } catch (error) { + this.logger.error("[WS] setSocketDataFromHeader error", { error }); + socket.disconnect(true); + return; + } + + this.logger.debug("[WS] auth success", socket.data); + + next(); + }); + websocketServer.on("connection", (socket) => { + const socketLogger = this.logger.child({ + socketId: socket.id, + socketData: socket.data, + }); + + const getSocketMetadata = () => { + return { + deploymentId: socket.data.deploymentId, + runId: socket.data.runFriendlyId, + snapshotId: socket.data.snapshotId, + runnerId: socket.data.runnerId, + }; + }; + + const emitSocketLifecycle = ( + event: "run_connected" | "run_disconnected", + friendlyId: string, + disconnectReason?: string + ) => { + emitOneShot({ + ...this.wideEventOpts, + op: event === "run_connected" ? "socket.run.connected" : "socket.run.disconnected", + kind: "event", + populate: (state) => { + state.extras.event = event; + setMeta(state, "run_id", friendlyId); + if (socket.data.deploymentId) { + setMeta(state, "deployment_id", socket.data.deploymentId); + } + if (socket.data.runnerId) setMeta(state, "runner_id", socket.data.runnerId); + state.extras.socket_id = socket.id; + if (disconnectReason) state.extras.disconnect_reason = disconnectReason; + }, + }); + }; + + const runConnected = (friendlyId: string) => { + socketLogger.debug("runConnected", { ...getSocketMetadata() }); + + // If there's already a run ID set, we should "disconnect" it from this socket + if (socket.data.runFriendlyId && socket.data.runFriendlyId !== friendlyId) { + socketLogger.debug("runConnected: disconnecting existing run", { + ...getSocketMetadata(), + newRunId: friendlyId, + oldRunId: socket.data.runFriendlyId, + }); + runDisconnected(socket.data.runFriendlyId, "socket_run_replaced"); + } + + this.runSockets.set(friendlyId, socket); + this.emit("runConnected", { run: { friendlyId } }); + socket.data.runFriendlyId = friendlyId; + emitSocketLifecycle("run_connected", friendlyId); + }; + + const runDisconnected = (friendlyId: string, reason: string) => { + socketLogger.debug("runDisconnected", { ...getSocketMetadata() }); + + // The run is gone from this runner (crash, exit, or replaced by a new + // run), so a pending delayed snapshot for it is stale. Genuine + // waitpoint suspensions keep the socket connected, so this doesn't + // cancel a snapshot that's still wanted; the runnerId match guards + // against a stale duplicate runner cancelling a fresh runner's + // snapshot after the run was reassigned. Caveat: socket.data.runnerId + // is frozen at the websocket handshake, so after a same-supervisor + // restore (new runner id, socket not recreated) this guard refuses + // the cancel - a missed cancel, never a wrong one. The + // attempt.complete cancel uses the runner's current HTTP header id + // and is unaffected. + this.snapshotService?.cancel(friendlyId, socket.data.runnerId); + + this.runSockets.delete(friendlyId); + this.emit("runDisconnected", { run: { friendlyId } }); + socket.data.runFriendlyId = undefined; + emitSocketLifecycle("run_disconnected", friendlyId, reason); + }; + + socketLogger.debug("wsServer socket connected", { ...getSocketMetadata() }); + + // FIXME: where does this get set? + if (socket.data.runFriendlyId) { + runConnected(socket.data.runFriendlyId); + } + + socket.on("disconnecting", (reason, description) => { + socketLogger.verbose("Socket disconnecting", { + ...getSocketMetadata(), + reason, + description, + }); + + if (socket.data.runFriendlyId) { + runDisconnected(socket.data.runFriendlyId, `socket_disconnecting:${reason}`); + } + }); + + socket.on("disconnect", (reason, description) => { + socketLogger.debug("Socket disconnected", { ...getSocketMetadata(), reason, description }); + }); + + socket.on("error", (error) => { + socketLogger.error("Socket error", { + ...getSocketMetadata(), + error: { + name: error.name, + message: error.message, + stack: error.stack, + }, + }); + }); + + socket.on("run:start", async (message) => { + const log = socketLogger.child({ + eventName: "run:start", + ...getSocketMetadata(), + ...message, + }); + + log.debug("Handling run:start"); + + try { + runConnected(message.run.friendlyId); + } catch (error) { + log.error("run:start error", { error }); + } + }); + + socket.on("run:stop", async (message) => { + const log = socketLogger.child({ + eventName: "run:stop", + ...getSocketMetadata(), + ...message, + }); + + log.debug("Handling run:stop"); + + try { + runDisconnected(message.run.friendlyId, "run_stop_message"); + // Don't delete trace context here - run:stop fires after each snapshot/shutdown + // but the run may be restored on a new VM and snapshot again. Trace context is + // re-populated on dequeue, and entries are small (4 strings per run). + } catch (error) { + log.error("run:stop error", { error }); + } + }); + }); + + return websocketServer; + } + + notifyRun({ run }: { run: { friendlyId: string } }) { + try { + const runSocket = this.runSockets.get(run.friendlyId); + + if (!runSocket) { + this.logger.debug("notifyRun: Run socket not found", { run }); + + this.workerClient.sendDebugLog(run.friendlyId, { + time: new Date(), + message: "run:notify socket not found on supervisor", + }); + + return; + } + + runSocket.emit("run:notify", { version: "1", run }); + this.logger.debug("run:notify sent", { run }); + + this.workerClient.sendDebugLog(run.friendlyId, { + time: new Date(), + message: "run:notify supervisor -> runner", + }); + } catch (error) { + this.logger.error("Error in notifyRun", { run, error }); + + this.workerClient.sendDebugLog(run.friendlyId, { + time: new Date(), + message: "run:notify error on supervisor", + }); + } + } + + registerRunTraceContext(runFriendlyId: string, ctx: RunTraceContext) { + this.snapshotService?.registerTraceContext(runFriendlyId, ctx); + } + + async start() { + await this.httpServer.start(); + } + + async stop() { + this.snapshotService?.stop(); + await this.httpServer.stop(); + } +} diff --git a/apps/supervisor/tsconfig.json b/apps/supervisor/tsconfig.json new file mode 100644 index 00000000000..bd9b391e1b6 --- /dev/null +++ b/apps/supervisor/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../.configs/tsconfig.base.json", + "include": ["src/**/*.ts"], + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + } +} diff --git a/apps/webapp/.env b/apps/webapp/.env new file mode 120000 index 00000000000..c7360fb82d2 --- /dev/null +++ b/apps/webapp/.env @@ -0,0 +1 @@ +../../.env \ No newline at end of file diff --git a/apps/webapp/.env.example b/apps/webapp/.env.example deleted file mode 100644 index 2811fad0297..00000000000 --- a/apps/webapp/.env.example +++ /dev/null @@ -1,24 +0,0 @@ -DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres -SESSION_SECRET= -MAGIC_LINK_SECRET= -GITHUB_CLIENT_ID= -GITHUB_SECRET= -FROM_EMAIL= -REPLY_TO_EMAIL= -RESEND_API_KEY= -MERGENT_KEY= -LOGIN_ORIGIN=http://localhost:3000 -APP_ORIGIN=http://localhost:3000 - -# Production Pulsar -# PULSAR_SERVICE_URL="pulsar+ssl://..snio.cloud:6651" -# PULSAR_CLIENT_ID="" -# PULSAR_CLIENT_SECRET="" -# PULSAR_ISSUER_URL="https://auth.streamnative.cloud/" -# PULSAR_AUDIENCE="urn:sn:pulsar::" -# PULSAR_TENANT="triggerdotdev" -# PULSAR_WORKFLOWS_NAMESPACE="workflows" -# PULSAR_QUEUES_NAMESPACE="queues" - -# Dev Pulsar -PULSAR_SERVICE_URL="pulsar://localhost:6650" \ No newline at end of file diff --git a/apps/webapp/.eslintrc b/apps/webapp/.eslintrc deleted file mode 100644 index 778ebab8a36..00000000000 --- a/apps/webapp/.eslintrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": [ - "@remix-run/eslint-config", - "@remix-run/eslint-config/node", - "prettier" - ] -} diff --git a/apps/webapp/.gitignore b/apps/webapp/.gitignore index ec7688ea21e..595ab180e15 100644 --- a/apps/webapp/.gitignore +++ b/apps/webapp/.gitignore @@ -7,4 +7,17 @@ node_modules /cypress/screenshots /cypress/videos -/app/styles/tailwind.css \ No newline at end of file +/app/styles/tailwind.css + +# Ensure the .env symlink is not removed by accident +!.env + +# Storybook build outputs +build-storybook.log +.out +.storybook-out +storybook-static + +/prisma/seed.js +/prisma/populate.js +.memory-snapshots \ No newline at end of file diff --git a/apps/webapp/.prettierignore b/apps/webapp/.prettierignore deleted file mode 100644 index 835d1a6cdd7..00000000000 --- a/apps/webapp/.prettierignore +++ /dev/null @@ -1,11 +0,0 @@ -node_modules - -/build -/public/build -.env - -/cypress/screenshots -/cypress/videos -/postgres-data - -/app/styles/tailwind.css \ No newline at end of file diff --git a/apps/webapp/.server-changes/task-filter-icons.md b/apps/webapp/.server-changes/task-filter-icons.md new file mode 100644 index 00000000000..1bfb63e1cf0 --- /dev/null +++ b/apps/webapp/.server-changes/task-filter-icons.md @@ -0,0 +1,6 @@ +--- +area: webapp +type: fix +--- + +Use the "all tasks" icon (`TasksIcon`) for the Tasks/Task type filter buttons on the Runs, Logs, Errors, and metrics dashboard pages (and the task-derived queue indicators), and show the clock icon for Scheduled runs in the run inspector header instead of the standard task icon. diff --git a/apps/webapp/CLAUDE.md b/apps/webapp/CLAUDE.md new file mode 100644 index 00000000000..b65482fb782 --- /dev/null +++ b/apps/webapp/CLAUDE.md @@ -0,0 +1,131 @@ +# Webapp + +Remix 2.17.4 app serving as the main API, dashboard, and orchestration engine. Uses an Express server (`server.ts`). + +## Verifying Changes + +**Never run `pnpm run build --filter webapp` to verify changes.** Building proves almost nothing about correctness. The webapp is an app, not a public package โ€” use typecheck from the repo root: + +```bash +pnpm run typecheck --filter webapp # ~1-2 minutes +``` + +Only run typecheck after major changes (new files, significant refactors, schema changes). For small edits, trust the types and let CI catch issues. + +Note: Public packages (`packages/*`) use `build` instead. See the root CLAUDE.md for details. + +## Testing Dashboard Changes with Chrome DevTools MCP + +Use the `chrome-devtools` MCP server to visually verify local dashboard changes. The webapp must be running (`pnpm run dev --filter webapp` from repo root). + +### Login + +``` +1. mcp__chrome-devtools__new_page(url: "http://localhost:3030") + โ†’ Redirects to /login +2. mcp__chrome-devtools__click the "Continue with Email" link +3. mcp__chrome-devtools__fill the email field with "local@trigger.dev" +4. mcp__chrome-devtools__click "Send a magic link" + โ†’ Auto-logs in and redirects to the dashboard (no email verification needed locally) +``` + +### Navigating and Verifying + +- **take_snapshot**: Get an a11y tree of the page (text content, element UIDs for interaction). Prefer this over screenshots for understanding page structure. +- **take_screenshot**: Capture what the page looks like visually. Use to verify styling, layout, and visual changes. +- **navigate_page**: Go to specific URLs, e.g. `http://localhost:3030/orgs/references-bc08/projects/hello-world-SiWs/env/dev/runs` +- **click / fill**: Interact with elements using UIDs from `take_snapshot`. +- **evaluate_script**: Run JS in the browser console for debugging. +- **list_console_messages**: Check for console errors after navigating. + +### Tips + +- Snapshots can be very large on complex pages (200K+ chars). Use `take_screenshot` first to orient, then `take_snapshot` only when you need element UIDs to interact. +- The local seeded user email is `local@trigger.dev`. +- Dashboard URL pattern: `http://localhost:3030/orgs/{orgSlug}/projects/{projectSlug}/env/{envSlug}/{section}` + +## Key File Locations + +- **Trigger API**: `app/routes/api.v1.tasks.$taskId.trigger.ts` +- **Batch trigger**: `app/routes/api.v1.tasks.batch.ts` +- **OTEL endpoints**: `app/routes/otel.v1.logs.ts`, `app/routes/otel.v1.traces.ts` +- **Prisma setup**: `app/db.server.ts` +- **Run engine config**: `app/v3/runEngine.server.ts` +- **Services**: `app/v3/services/**/*.server.ts` +- **Presenters**: `app/v3/presenters/**/*.server.ts` + +## Route Convention + +Routes use Remix flat-file convention with dot-separated segments: +`api.v1.tasks.$taskId.trigger.ts` -> `/api/v1/tasks/:taskId/trigger` + +## Abort Signals + +**Never use `request.signal`** for detecting client disconnects. It is broken due to a Node.js bug ([nodejs/node#55428](https://github.com/nodejs/node/issues/55428)) where the AbortSignal chain is severed when Remix internally clones the Request object. Instead, use `getRequestAbortSignal()` from `app/services/httpAsyncStorage.server.ts`, which is wired directly to Express `res.on("close")` and fires reliably. + +```typescript +import { getRequestAbortSignal } from "~/services/httpAsyncStorage.server"; + +// In route handlers, SSE streams, or any server-side code: +const signal = getRequestAbortSignal(); +``` + +## Environment Variables + +Access via `env` export from `app/env.server.ts`. **Never use `process.env` directly.** + +For testable code, **never import env.server.ts** in test files. Pass configuration as options instead: +- `realtime/nativeRealtimeClient.server.ts` (testable service, takes config as constructor arg) +- `realtime/nativeRealtimeClientInstance.server.ts` (creates singleton with env config) + +## Run Engine 2.0 + +The webapp integrates `@internal/run-engine` via `app/v3/runEngine.server.ts`. This is the singleton engine instance. Services in `app/v3/services/` call engine methods for all run lifecycle operations (triggering, completing, cancelling, etc.). + +The `engineVersion.server.ts` file determines V1 vs V2 for a given environment. New code should always target V2. + +## Background Workers + +Background job workers use `@trigger.dev/redis-worker`: +- `app/v3/commonWorker.server.ts` +- `app/v3/alertsWorker.server.ts` +- `app/v3/batchTriggerWorker.server.ts` + +## Real-time + +- Socket.io: `app/v3/handleSocketIo.server.ts`, `app/v3/handleWebsockets.server.ts` +- Electric SQL: Powers real-time data sync for the dashboard + +## v3 (engine V1) removed + +v3 (engine V1: MarQS + Graphile worker) is end-of-life and its execution code is gone. The `app/v3/` directory name is historical; everything under it now serves V2. There is no V1 execution path: a `RunEngineVersion` `V1` branch (e.g. in `triggerTask.server.ts`, `cancelTaskRun.server.ts`) only rejects/finalizes gracefully so v3 clients get a clean 4xx, never a 5xx. Do not reintroduce V1. See `.claude/rules/legacy-v3-code.md` for the deprecation boundary. + +## Performance: Trigger Hot Path + +The `triggerTask.server.ts` service is the **highest-throughput code path** in the system. Every API trigger call goes through it. Keep it fast: + +- **Do NOT add database queries** to `triggerTask.server.ts` or `batchTriggerV3.server.ts`. Task defaults (TTL, etc.) are resolved via `backgroundWorkerTask.findFirst()` in the queue concern (`queues.server.ts`) - one query per request, in mutually exclusive branches depending on locked/non-locked path. Piggyback on the existing query instead of adding new ones. +- **Two-stage resolution pattern**: Task metadata is resolved in two stages by design: + 1. **Trigger time** (`triggerTask.server.ts`): Only TTL is resolved from task defaults. Everything else uses whatever the caller provides. + 2. **Dequeue time** (`dequeueSystem.ts`): Full `BackgroundWorkerTask` is loaded and retry config, machine config, maxDuration, etc. are resolved against task defaults. +- If you need to add a new task-level default, **add it to the existing `select` clause** in the `backgroundWorkerTask.findFirst()` query โ€” do NOT add a second query. If the default doesn't need to be known at trigger time, resolve it at dequeue time instead. +- Batch triggers (`batchTriggerV3.server.ts`) follow the same pattern โ€” keep batch paths equally fast. + +## Prisma Query Patterns + +- **Always use `findFirst` instead of `findUnique`.** Prisma's `findUnique` has an implicit DataLoader that batches concurrent calls into a single `IN` query. This batching cannot be disabled and has active bugs even in Prisma 6.x: uppercase UUIDs returning null (#25484, confirmed 6.4.1), composite key SQL correctness issues (#22202), and 5-10x worse performance than manual DataLoader (#6573, open since 2021). `findFirst` is never batched and avoids this entire class of issues. + +## Transactions + +- **Always use the `$transaction` helper from `~/db.server`, never `prisma.$transaction` (or `$replica.$transaction`) directly.** The helper wraps the raw call with tracing (an OTEL span + an `isolation_level` attribute) and boundary logging for infrastructure errors (e.g. `PrismaClientInitializationError`) that the raw client swallows. Signature: `$transaction(prisma, name?, async (tx) => { ... }, options?)`. +- Pass the isolation level via options as a string: `{ isolationLevel: "Serializable" }`. Reach for `Serializable` when a read-then-write must be atomic against concurrent transactions (e.g. a count-then-delete invariant); the loser of a race fails and can retry, which is the right trade for rare, correctness-critical paths. +- The helper returns `R | undefined` โ€” guard the result (`if (!result) throw ...`) when callers need a definite value. + +## PAT-authenticated API routes + +- **A PAT route must resolve its target org/project scoped to the caller's membership** (`members: { some: { userId } }`, or a helper like `findProjectByRef` / `resolveOrganizationForApiUser`). A PAT is user-scoped and can name any org/project by id/slug, and the OSS RBAC fallback ability is permissive โ€” so `ability.can(...)` alone does NOT reject a non-member on self-hosted. The RBAC `authorization` gate enforces the *role*; the membership-scoped query is the *tenant* floor. Skipping it opens cross-org access on OSS. + +## React Patterns + +- Only use `useCallback`/`useMemo` for context provider values, expensive derived data that is a dependency elsewhere, or stable refs required by a dependency array. Don't wrap ordinary event handlers or trivial computations. +- Use named constants for sentinel/placeholder values (e.g. `const UNSET_VALUE = "__unset__"`) instead of raw string literals scattered across comparisons. diff --git a/apps/webapp/Dockerfile b/apps/webapp/Dockerfile deleted file mode 100644 index c93fc6f5f31..00000000000 --- a/apps/webapp/Dockerfile +++ /dev/null @@ -1,73 +0,0 @@ -FROM node:16-bullseye AS pruner -RUN apt-get update && apt-get install openssl -y -WORKDIR /app -RUN npm install turbo@1.7.0 -g -COPY . . -RUN turbo prune --scope=webapp --docker -RUN find . -name "node_modules" -type d -prune -exec rm -rf '{}' + - -# Base strategy to have layer caching -FROM node:16-bullseye AS base -RUN apt-get update && apt-get install openssl ca-certificates g++ make wget python3 -y -ENV PULSAR_CPP_CLIENT_VERSION=2.10.3 -RUN wget https://archive.apache.org/dist/pulsar/pulsar-${PULSAR_CPP_CLIENT_VERSION}/DEB/apache-pulsar-client.deb -q -RUN wget https://archive.apache.org/dist/pulsar/pulsar-${PULSAR_CPP_CLIENT_VERSION}/DEB/apache-pulsar-client-dev.deb -q -RUN dpkg -i ./apache-pulsar-client*.deb -WORKDIR /app -COPY .gitignore .gitignore -COPY --from=pruner /app/out/json/ . -COPY --from=pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml -COPY --from=pruner /app/out/pnpm-workspace.yaml ./pnpm-workspace.yaml - -FROM base AS production-deps -WORKDIR /app -RUN npm install turbo@1.7.0 -g -RUN corepack enable -ENV NODE_ENV production -RUN npm config set python /usr/bin/python3 -COPY --from=pruner /app/out/full/apps/webapp/prisma/schema.prisma /app/apps/webapp/prisma/schema.prisma -RUN pnpm install --prod --frozen-lockfile -RUN pnpx prisma generate --schema /app/apps/webapp/prisma/schema.prisma - -FROM base AS builder -WORKDIR /app -RUN npm install turbo@1.7.0 -g -COPY turbo.json turbo.json -RUN corepack enable -COPY --from=pruner /app/out/full/ . -RUN npm config set python /usr/bin/python3 -ENV NODE_ENV development -RUN pnpm install --ignore-scripts --frozen-lockfile -ENV NODE_ENV production -RUN pnpm run generate -RUN pnpm run build --filter=webapp... -RUN pnpx prisma migrate deploy --schema apps/webapp/prisma/schema.prisma - -# Runner -FROM node:16-bullseye AS runner -RUN apt-get update && apt-get install openssl ca-certificates g++ make wget python3 -y -ENV PULSAR_CPP_CLIENT_VERSION=2.10.3 -RUN wget https://archive.apache.org/dist/pulsar/pulsar-${PULSAR_CPP_CLIENT_VERSION}/DEB/apache-pulsar-client.deb -q -RUN wget https://archive.apache.org/dist/pulsar/pulsar-${PULSAR_CPP_CLIENT_VERSION}/DEB/apache-pulsar-client-dev.deb -q -RUN dpkg -i ./apache-pulsar-client*.deb -RUN npm install turbo -g -WORKDIR /app -RUN corepack enable -ENV NODE_ENV production -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 remixjs -RUN chown -R remixjs:nodejs /app -USER remixjs - -COPY --from=pruner --chown=remixjs:nodejs /app/out/full/ . -COPY --from=production-deps --chown=remixjs:nodejs /app . -COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/app/styles/tailwind.css ./apps/webapp/app/styles/tailwind.css -COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/build/server.js ./apps/webapp/build/server.js -COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/build ./apps/webapp/build -COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/public ./apps/webapp/public -COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/prisma/schema.prisma ./apps/webapp/build/schema.prisma -COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/prisma/migrations ./apps/webapp/build/migrations -COPY --from=builder --chown=remixjs:nodejs /app/apps/webapp/node_modules/.prisma/client/libquery_engine-debian-openssl-1.1.x.so.node ./apps/webapp/build/libquery_engine-debian-openssl-1.1.x.so.node - -# release_command = "pnpx prisma migrate deploy --schema apps/webapp/prisma/schema.prisma" -ENTRYPOINT ["pnpm", "--filter", "webapp", "run", "start"] \ No newline at end of file diff --git a/apps/webapp/app/api/versions.ts b/apps/webapp/app/api/versions.ts new file mode 100644 index 00000000000..250d214b07e --- /dev/null +++ b/apps/webapp/app/api/versions.ts @@ -0,0 +1,57 @@ +import { + API_VERSION_HEADER_NAME, + API_VERSION as CORE_API_VERSION, +} from "@trigger.dev/core/v3/serverOnly"; +import { z } from "zod"; + +export const CURRENT_API_VERSION = CORE_API_VERSION; + +export const NON_SPECIFIC_API_VERSION = "none"; + +export type API_VERSIONS = typeof CURRENT_API_VERSION | typeof NON_SPECIFIC_API_VERSION; + +export function getApiVersion(request: Request): API_VERSIONS { + const apiVersion = request.headers.get(API_VERSION_HEADER_NAME); + + if (apiVersion === CURRENT_API_VERSION) { + return apiVersion; + } + + return NON_SPECIFIC_API_VERSION; +} + +// This has been copied from the core package to allow us to use these types in the webapp +export const RunStatusUnspecifiedApiVersion = z.enum([ + /// Task is waiting for a version update because it cannot execute without additional information (task, queue, etc.). Replaces WAITING_FOR_DEPLOY + "PENDING_VERSION", + /// Task hasn't been deployed yet but is waiting to be executed + "WAITING_FOR_DEPLOY", + /// Task is waiting to be executed by a worker + "QUEUED", + /// Task is currently being executed by a worker + "EXECUTING", + /// Task has failed and is waiting to be retried + "REATTEMPTING", + /// Task has been paused by the system, and will be resumed by the system + "FROZEN", + /// Task has been completed successfully + "COMPLETED", + /// Task has been canceled by the user + "CANCELED", + /// Task has been completed with errors + "FAILED", + /// Task has crashed and won't be retried, most likely the worker ran out of resources, e.g. memory or storage + "CRASHED", + /// Task was interrupted during execution, mostly this happens in development environments + "INTERRUPTED", + /// Task has failed to complete, due to an error in the system + "SYSTEM_FAILURE", + /// Task has been scheduled to run at a specific time + "DELAYED", + /// Task has expired and won't be executed + "EXPIRED", + /// Task has reached it's maxDuration and has been stopped + "TIMED_OUT", +]); + +export type RunStatusUnspecifiedApiVersion = z.infer; diff --git a/apps/webapp/app/assets/icons/AIChatIcon.tsx b/apps/webapp/app/assets/icons/AIChatIcon.tsx new file mode 100644 index 00000000000..e17bfce0d95 --- /dev/null +++ b/apps/webapp/app/assets/icons/AIChatIcon.tsx @@ -0,0 +1,27 @@ +export function AIChatIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/AIMetricsIcon.tsx b/apps/webapp/app/assets/icons/AIMetricsIcon.tsx new file mode 100644 index 00000000000..90e61bdbe54 --- /dev/null +++ b/apps/webapp/app/assets/icons/AIMetricsIcon.tsx @@ -0,0 +1,34 @@ +export function AIMetricsIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/AIPenIcon.tsx b/apps/webapp/app/assets/icons/AIPenIcon.tsx new file mode 100644 index 00000000000..146edd0e6d2 --- /dev/null +++ b/apps/webapp/app/assets/icons/AIPenIcon.tsx @@ -0,0 +1,25 @@ +export function AIPenIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/AISparkleIcon.tsx b/apps/webapp/app/assets/icons/AISparkleIcon.tsx new file mode 100644 index 00000000000..46f7429e77a --- /dev/null +++ b/apps/webapp/app/assets/icons/AISparkleIcon.tsx @@ -0,0 +1,31 @@ +export function AISparkleIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/AbacusIcon.tsx b/apps/webapp/app/assets/icons/AbacusIcon.tsx new file mode 100644 index 00000000000..f0b7bfdf7be --- /dev/null +++ b/apps/webapp/app/assets/icons/AbacusIcon.tsx @@ -0,0 +1,71 @@ +export function AbacusIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/AiProviderIcons.tsx b/apps/webapp/app/assets/icons/AiProviderIcons.tsx new file mode 100644 index 00000000000..418cdeff569 --- /dev/null +++ b/apps/webapp/app/assets/icons/AiProviderIcons.tsx @@ -0,0 +1,181 @@ +type IconProps = { className?: string }; + +export function OpenAIIcon({ className }: IconProps) { + return ( + + + + ); +} + +export function AnthropicIcon({ className }: IconProps) { + return ( + + + + ); +} + +export function GeminiIcon({ className }: IconProps) { + return ( + + + + ); +} + +export function LlamaIcon({ className }: IconProps) { + return ( + + + + ); +} + +export function DeepseekIcon({ className }: IconProps) { + return ( + + + + + + + + + + + ); +} + +export function XAIIcon({ className }: IconProps) { + return ( + + + + + + + ); +} + +export function PerplexityIcon({ className }: IconProps) { + return ( + + + + ); +} + +export function CerebrasIcon({ className }: IconProps) { + return ( + + + + + + + + ); +} + +export function MistralIcon({ className }: IconProps) { + return ( + + + + + + + + + + + + + ); +} + +export function AzureIcon({ className }: IconProps) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx b/apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx new file mode 100644 index 00000000000..3c94426fa03 --- /dev/null +++ b/apps/webapp/app/assets/icons/AnimatedHourglassIcon.tsx @@ -0,0 +1,27 @@ +import { useAnimate } from "framer-motion"; +import { HourglassIcon } from "lucide-react"; +import { useEffect } from "react"; + +export function AnimatedHourglassIcon({ + className, + delay, +}: { + className?: string; + delay?: number; +}) { + const [scope, animate] = useAnimate(); + + useEffect(() => { + animate( + [ + [scope.current, { rotate: 0 }, { duration: 0.7 }], + [scope.current, { rotate: 180 }, { duration: 0.3 }], + [scope.current, { rotate: 180 }, { duration: 0.7 }], + [scope.current, { rotate: 360 }, { duration: 0.3 }], + ], + { repeat: Infinity, delay } + ); + }, []); + + return ; +} diff --git a/apps/webapp/app/assets/icons/AnthropicLogoIcon.tsx b/apps/webapp/app/assets/icons/AnthropicLogoIcon.tsx new file mode 100644 index 00000000000..3e647284cce --- /dev/null +++ b/apps/webapp/app/assets/icons/AnthropicLogoIcon.tsx @@ -0,0 +1,12 @@ +export function AnthropicLogoIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ArchiveIcon.tsx b/apps/webapp/app/assets/icons/ArchiveIcon.tsx new file mode 100644 index 00000000000..1d910ba750e --- /dev/null +++ b/apps/webapp/app/assets/icons/ArchiveIcon.tsx @@ -0,0 +1,44 @@ +export function ArchiveIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} + +export function UnarchiveIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ArrowLeftRightIcon.tsx b/apps/webapp/app/assets/icons/ArrowLeftRightIcon.tsx new file mode 100644 index 00000000000..10d7cb0c359 --- /dev/null +++ b/apps/webapp/app/assets/icons/ArrowLeftRightIcon.tsx @@ -0,0 +1,41 @@ +export function ArrowLeftRightIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ArrowRightSquareIcon.tsx b/apps/webapp/app/assets/icons/ArrowRightSquareIcon.tsx new file mode 100644 index 00000000000..f7048209051 --- /dev/null +++ b/apps/webapp/app/assets/icons/ArrowRightSquareIcon.tsx @@ -0,0 +1,33 @@ +export function ArrowRightSquareIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ArrowTopRightBottomLeftIcon.tsx b/apps/webapp/app/assets/icons/ArrowTopRightBottomLeftIcon.tsx new file mode 100644 index 00000000000..c49aa8cb0c2 --- /dev/null +++ b/apps/webapp/app/assets/icons/ArrowTopRightBottomLeftIcon.tsx @@ -0,0 +1,22 @@ +export function ArrowTopRightBottomLeftIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/AttemptIcon.tsx b/apps/webapp/app/assets/icons/AttemptIcon.tsx new file mode 100644 index 00000000000..4b6c7f03698 --- /dev/null +++ b/apps/webapp/app/assets/icons/AttemptIcon.tsx @@ -0,0 +1,30 @@ +export function AttemptIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx b/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx new file mode 100644 index 00000000000..83e67c8468c --- /dev/null +++ b/apps/webapp/app/assets/icons/AvatarCircleIcon.tsx @@ -0,0 +1,20 @@ +export function AvatarCircleIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/BatchesIcon.tsx b/apps/webapp/app/assets/icons/BatchesIcon.tsx new file mode 100644 index 00000000000..b38a0085d5e --- /dev/null +++ b/apps/webapp/app/assets/icons/BatchesIcon.tsx @@ -0,0 +1,17 @@ +export function BatchesIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/BeakerIcon.tsx b/apps/webapp/app/assets/icons/BeakerIcon.tsx new file mode 100644 index 00000000000..cd8f8635e56 --- /dev/null +++ b/apps/webapp/app/assets/icons/BeakerIcon.tsx @@ -0,0 +1,30 @@ +export function BeakerIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/BellIcon.tsx b/apps/webapp/app/assets/icons/BellIcon.tsx new file mode 100644 index 00000000000..0cee1b40036 --- /dev/null +++ b/apps/webapp/app/assets/icons/BellIcon.tsx @@ -0,0 +1,23 @@ +export function BellIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/BookIcon.tsx b/apps/webapp/app/assets/icons/BookIcon.tsx new file mode 100644 index 00000000000..79775fbd0eb --- /dev/null +++ b/apps/webapp/app/assets/icons/BookIcon.tsx @@ -0,0 +1,23 @@ +export function BookIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/Box3DIcon.tsx b/apps/webapp/app/assets/icons/Box3DIcon.tsx new file mode 100644 index 00000000000..ebf53a9b091 --- /dev/null +++ b/apps/webapp/app/assets/icons/Box3DIcon.tsx @@ -0,0 +1,22 @@ +export function Box3DIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/BugIcon.tsx b/apps/webapp/app/assets/icons/BugIcon.tsx new file mode 100644 index 00000000000..64125f3d325 --- /dev/null +++ b/apps/webapp/app/assets/icons/BugIcon.tsx @@ -0,0 +1,73 @@ +export function BugIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/BulbIcon.tsx b/apps/webapp/app/assets/icons/BulbIcon.tsx new file mode 100644 index 00000000000..c0e293fe176 --- /dev/null +++ b/apps/webapp/app/assets/icons/BulbIcon.tsx @@ -0,0 +1,19 @@ +export function BulbIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/BunLogoIcon.tsx b/apps/webapp/app/assets/icons/BunLogoIcon.tsx new file mode 100644 index 00000000000..b7357189f7c --- /dev/null +++ b/apps/webapp/app/assets/icons/BunLogoIcon.tsx @@ -0,0 +1,94 @@ +export function BunLogoIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ChartArrowIcon.tsx b/apps/webapp/app/assets/icons/ChartArrowIcon.tsx new file mode 100644 index 00000000000..f3b6a08f120 --- /dev/null +++ b/apps/webapp/app/assets/icons/ChartArrowIcon.tsx @@ -0,0 +1,42 @@ +export function ChartArrowIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ChartBarIcon.tsx b/apps/webapp/app/assets/icons/ChartBarIcon.tsx new file mode 100644 index 00000000000..966616e45c4 --- /dev/null +++ b/apps/webapp/app/assets/icons/ChartBarIcon.tsx @@ -0,0 +1,28 @@ +export function ChartBarIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ChevronExtraSmallDown.tsx b/apps/webapp/app/assets/icons/ChevronExtraSmallDown.tsx new file mode 100644 index 00000000000..134cbe4dfda --- /dev/null +++ b/apps/webapp/app/assets/icons/ChevronExtraSmallDown.tsx @@ -0,0 +1,13 @@ +export function ChevronExtraSmallDown({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ChevronExtraSmallUp.tsx b/apps/webapp/app/assets/icons/ChevronExtraSmallUp.tsx new file mode 100644 index 00000000000..710eeccdf20 --- /dev/null +++ b/apps/webapp/app/assets/icons/ChevronExtraSmallUp.tsx @@ -0,0 +1,13 @@ +export function ChevronExtraSmallUp({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ClockIcon.tsx b/apps/webapp/app/assets/icons/ClockIcon.tsx new file mode 100644 index 00000000000..808a0271a92 --- /dev/null +++ b/apps/webapp/app/assets/icons/ClockIcon.tsx @@ -0,0 +1,24 @@ +export function ClockIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ClockRotateLeftIcon.tsx b/apps/webapp/app/assets/icons/ClockRotateLeftIcon.tsx new file mode 100644 index 00000000000..edef4f87b75 --- /dev/null +++ b/apps/webapp/app/assets/icons/ClockRotateLeftIcon.tsx @@ -0,0 +1,15 @@ +export function ClockRotateLeftIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/CloudProviderIcon.tsx b/apps/webapp/app/assets/icons/CloudProviderIcon.tsx new file mode 100644 index 00000000000..6c162528247 --- /dev/null +++ b/apps/webapp/app/assets/icons/CloudProviderIcon.tsx @@ -0,0 +1,76 @@ +export function CloudProviderIcon({ + provider, + className, +}: { + provider: "aws" | "digitalocean" | (string & {}); + className?: string; +}) { + switch (provider) { + case "aws": + return ; + case "digitalocean": + return ; + default: + return null; + } +} + +export function AWS({ className }: { className?: string }) { + return ( + + + + + + ); +} + +export function DigitalOcean({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/CodeSquareIcon.tsx b/apps/webapp/app/assets/icons/CodeSquareIcon.tsx new file mode 100644 index 00000000000..6975c96efbe --- /dev/null +++ b/apps/webapp/app/assets/icons/CodeSquareIcon.tsx @@ -0,0 +1,28 @@ +export function CodeSquareIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ConcurrencyIcon.tsx b/apps/webapp/app/assets/icons/ConcurrencyIcon.tsx new file mode 100644 index 00000000000..710ba4e6fa9 --- /dev/null +++ b/apps/webapp/app/assets/icons/ConcurrencyIcon.tsx @@ -0,0 +1,13 @@ +export function ConcurrencyIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ConnectionIcons.tsx b/apps/webapp/app/assets/icons/ConnectionIcons.tsx new file mode 100644 index 00000000000..beb0e9bab63 --- /dev/null +++ b/apps/webapp/app/assets/icons/ConnectionIcons.tsx @@ -0,0 +1,73 @@ +export function ConnectedIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +export function DisconnectedIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +export function CheckingConnectionIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/CreditCardIcon.tsx b/apps/webapp/app/assets/icons/CreditCardIcon.tsx new file mode 100644 index 00000000000..5109ce6d602 --- /dev/null +++ b/apps/webapp/app/assets/icons/CreditCardIcon.tsx @@ -0,0 +1,16 @@ +export function CreditCardIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/CubeSparkleIcon.tsx b/apps/webapp/app/assets/icons/CubeSparkleIcon.tsx new file mode 100644 index 00000000000..896e7217ca2 --- /dev/null +++ b/apps/webapp/app/assets/icons/CubeSparkleIcon.tsx @@ -0,0 +1,24 @@ +export function CubeSparkleIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/DeploymentsIcon.tsx b/apps/webapp/app/assets/icons/DeploymentsIcon.tsx new file mode 100644 index 00000000000..2eb98ba28e7 --- /dev/null +++ b/apps/webapp/app/assets/icons/DeploymentsIcon.tsx @@ -0,0 +1,17 @@ +export function DeploymentsIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/DialIcon.tsx b/apps/webapp/app/assets/icons/DialIcon.tsx new file mode 100644 index 00000000000..3f49ce3de4c --- /dev/null +++ b/apps/webapp/app/assets/icons/DialIcon.tsx @@ -0,0 +1,21 @@ +export function DialIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/DropdownIcon.tsx b/apps/webapp/app/assets/icons/DropdownIcon.tsx new file mode 100644 index 00000000000..4a869ec8f62 --- /dev/null +++ b/apps/webapp/app/assets/icons/DropdownIcon.tsx @@ -0,0 +1,20 @@ +export function DropdownIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/DynamicTriggerIcon.tsx b/apps/webapp/app/assets/icons/DynamicTriggerIcon.tsx new file mode 100644 index 00000000000..961586c45e7 --- /dev/null +++ b/apps/webapp/app/assets/icons/DynamicTriggerIcon.tsx @@ -0,0 +1,12 @@ +export function DynamicTriggerIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EndpointIcon.tsx b/apps/webapp/app/assets/icons/EndpointIcon.tsx new file mode 100644 index 00000000000..d491e25a8a4 --- /dev/null +++ b/apps/webapp/app/assets/icons/EndpointIcon.tsx @@ -0,0 +1,36 @@ +export function EndpointIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EnvelopeIcon.tsx b/apps/webapp/app/assets/icons/EnvelopeIcon.tsx new file mode 100644 index 00000000000..e5888009d4c --- /dev/null +++ b/apps/webapp/app/assets/icons/EnvelopeIcon.tsx @@ -0,0 +1,20 @@ +export function EnvelopeIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/EnvironmentIcons.tsx b/apps/webapp/app/assets/icons/EnvironmentIcons.tsx new file mode 100644 index 00000000000..bc74ab10bcf --- /dev/null +++ b/apps/webapp/app/assets/icons/EnvironmentIcons.tsx @@ -0,0 +1,178 @@ +export function DevEnvironmentIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} + +export function DevEnvironmentIconSmall({ className }: { className?: string }) { + return ( + + + + + + + + ); +} + +export function ProdEnvironmentIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function ProdEnvironmentIconSmall({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function DeployedEnvironmentIcon({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function DeployedEnvironmentIconSmall({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function PreviewEnvironmentIconSmall({ className }: { className?: string }) { + return ; +} + +export function BranchEnvironmentIconSmall({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ErrorIcon.tsx b/apps/webapp/app/assets/icons/ErrorIcon.tsx new file mode 100644 index 00000000000..85a08236f4a --- /dev/null +++ b/apps/webapp/app/assets/icons/ErrorIcon.tsx @@ -0,0 +1,13 @@ +export function ErrorIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ExitIcon.tsx b/apps/webapp/app/assets/icons/ExitIcon.tsx new file mode 100644 index 00000000000..29d52609cdd --- /dev/null +++ b/apps/webapp/app/assets/icons/ExitIcon.tsx @@ -0,0 +1,14 @@ +export function ExitIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/FolderClosedIcon.tsx b/apps/webapp/app/assets/icons/FolderClosedIcon.tsx new file mode 100644 index 00000000000..a75962fef85 --- /dev/null +++ b/apps/webapp/app/assets/icons/FolderClosedIcon.tsx @@ -0,0 +1,19 @@ +export function FolderClosedIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/FolderOpenIcon.tsx b/apps/webapp/app/assets/icons/FolderOpenIcon.tsx new file mode 100644 index 00000000000..9bfccadef82 --- /dev/null +++ b/apps/webapp/app/assets/icons/FolderOpenIcon.tsx @@ -0,0 +1,24 @@ +export function FolderOpenIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/FunctionIcon.tsx b/apps/webapp/app/assets/icons/FunctionIcon.tsx new file mode 100644 index 00000000000..11b630ac2fa --- /dev/null +++ b/apps/webapp/app/assets/icons/FunctionIcon.tsx @@ -0,0 +1,20 @@ +export function FunctionIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/GlobeLinesIcon.tsx b/apps/webapp/app/assets/icons/GlobeLinesIcon.tsx new file mode 100644 index 00000000000..592cbaddd78 --- /dev/null +++ b/apps/webapp/app/assets/icons/GlobeLinesIcon.tsx @@ -0,0 +1,33 @@ +export function GlobeLinesIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/HomeIcon.tsx b/apps/webapp/app/assets/icons/HomeIcon.tsx new file mode 100644 index 00000000000..b62fc527989 --- /dev/null +++ b/apps/webapp/app/assets/icons/HomeIcon.tsx @@ -0,0 +1,23 @@ +export function HomeIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/IDIcon.tsx b/apps/webapp/app/assets/icons/IDIcon.tsx new file mode 100644 index 00000000000..254fb74f55b --- /dev/null +++ b/apps/webapp/app/assets/icons/IDIcon.tsx @@ -0,0 +1,22 @@ +export function IDIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/InfoIcon.tsx b/apps/webapp/app/assets/icons/InfoIcon.tsx new file mode 100644 index 00000000000..e42dc424f63 --- /dev/null +++ b/apps/webapp/app/assets/icons/InfoIcon.tsx @@ -0,0 +1,22 @@ +export function InfoIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/IntegrationsIcon.tsx b/apps/webapp/app/assets/icons/IntegrationsIcon.tsx new file mode 100644 index 00000000000..b2b8b438695 --- /dev/null +++ b/apps/webapp/app/assets/icons/IntegrationsIcon.tsx @@ -0,0 +1,29 @@ +export function IntegrationsIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/KeyIcon.tsx b/apps/webapp/app/assets/icons/KeyIcon.tsx new file mode 100644 index 00000000000..d0c8213d262 --- /dev/null +++ b/apps/webapp/app/assets/icons/KeyIcon.tsx @@ -0,0 +1,19 @@ +export function KeyIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/KeyValueIcon.tsx b/apps/webapp/app/assets/icons/KeyValueIcon.tsx new file mode 100644 index 00000000000..6fc876a35aa --- /dev/null +++ b/apps/webapp/app/assets/icons/KeyValueIcon.tsx @@ -0,0 +1,76 @@ +export function KeyValueIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/KeyboardDownIcon.tsx b/apps/webapp/app/assets/icons/KeyboardDownIcon.tsx new file mode 100644 index 00000000000..1ef015d900a --- /dev/null +++ b/apps/webapp/app/assets/icons/KeyboardDownIcon.tsx @@ -0,0 +1,17 @@ +export function KeyboardDownIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/KeyboardEnterIcon.tsx b/apps/webapp/app/assets/icons/KeyboardEnterIcon.tsx new file mode 100644 index 00000000000..b6341912724 --- /dev/null +++ b/apps/webapp/app/assets/icons/KeyboardEnterIcon.tsx @@ -0,0 +1,12 @@ +export function KeyboardEnterIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/KeyboardIcon.tsx b/apps/webapp/app/assets/icons/KeyboardIcon.tsx new file mode 100644 index 00000000000..59f86d1579b --- /dev/null +++ b/apps/webapp/app/assets/icons/KeyboardIcon.tsx @@ -0,0 +1,28 @@ +export function KeyboardIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/KeyboardLeftIcon.tsx b/apps/webapp/app/assets/icons/KeyboardLeftIcon.tsx new file mode 100644 index 00000000000..6b6999e6830 --- /dev/null +++ b/apps/webapp/app/assets/icons/KeyboardLeftIcon.tsx @@ -0,0 +1,17 @@ +export function KeyboardLeftIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/KeyboardRightIcon.tsx b/apps/webapp/app/assets/icons/KeyboardRightIcon.tsx new file mode 100644 index 00000000000..879e7e183ca --- /dev/null +++ b/apps/webapp/app/assets/icons/KeyboardRightIcon.tsx @@ -0,0 +1,17 @@ +export function KeyboardRightIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/KeyboardUpIcon.tsx b/apps/webapp/app/assets/icons/KeyboardUpIcon.tsx new file mode 100644 index 00000000000..d87f26488d8 --- /dev/null +++ b/apps/webapp/app/assets/icons/KeyboardUpIcon.tsx @@ -0,0 +1,17 @@ +export function KeyboardUpIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/KeyboardWindowsIcon.tsx b/apps/webapp/app/assets/icons/KeyboardWindowsIcon.tsx new file mode 100644 index 00000000000..859a633a305 --- /dev/null +++ b/apps/webapp/app/assets/icons/KeyboardWindowsIcon.tsx @@ -0,0 +1,17 @@ +export function KeyboardWindowsIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ListBulletIcon.tsx b/apps/webapp/app/assets/icons/ListBulletIcon.tsx new file mode 100644 index 00000000000..3ca7636a900 --- /dev/null +++ b/apps/webapp/app/assets/icons/ListBulletIcon.tsx @@ -0,0 +1,30 @@ +export function ListBulletIcon({ className }: { className?: string }) { + return ( + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ListCheckedIcon.tsx b/apps/webapp/app/assets/icons/ListCheckedIcon.tsx new file mode 100644 index 00000000000..29cb828f5dd --- /dev/null +++ b/apps/webapp/app/assets/icons/ListCheckedIcon.tsx @@ -0,0 +1,48 @@ +export function ListCheckedIcon({ className }: { className?: string }) { + return ( + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/LogsIcon.tsx b/apps/webapp/app/assets/icons/LogsIcon.tsx new file mode 100644 index 00000000000..160c5b6e815 --- /dev/null +++ b/apps/webapp/app/assets/icons/LogsIcon.tsx @@ -0,0 +1,58 @@ +export function LogsIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MachineIcon.tsx b/apps/webapp/app/assets/icons/MachineIcon.tsx new file mode 100644 index 00000000000..f07e7467b0d --- /dev/null +++ b/apps/webapp/app/assets/icons/MachineIcon.tsx @@ -0,0 +1,221 @@ +import { cn } from "~/utils/cn"; + +export function MachineIcon({ preset, className }: { preset?: string; className?: string }) { + if (!preset) { + return ; + } + + switch (preset) { + case "no-machine": + return ; + case "micro": + return ; + case "small-1x": + return ; + case "small-2x": + return ; + case "medium-1x": + return ; + case "medium-2x": + return ; + case "large-1x": + return ; + case "large-2x": + return ; + default: + return ; + } +} + +export function MachineDefaultIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function MachineIconNoMachine({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +function MachineIconMicro({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function MachineIconSmall1x({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function MachineIconSmall2x({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function MachineIconMedium1x({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function MachineIconMedium2x({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function MachineIconLarge1x({ className }: { className?: string }) { + return ( + + + + + + ); +} + +function MachineIconLarge2x({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MessageInputIcon.tsx b/apps/webapp/app/assets/icons/MessageInputIcon.tsx new file mode 100644 index 00000000000..06b92b86e44 --- /dev/null +++ b/apps/webapp/app/assets/icons/MessageInputIcon.tsx @@ -0,0 +1,15 @@ +export function MessageInputIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MessageOutputIcon.tsx b/apps/webapp/app/assets/icons/MessageOutputIcon.tsx new file mode 100644 index 00000000000..47a28f2d562 --- /dev/null +++ b/apps/webapp/app/assets/icons/MessageOutputIcon.tsx @@ -0,0 +1,15 @@ +export function MessageOutputIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MiddlewareIcon.tsx b/apps/webapp/app/assets/icons/MiddlewareIcon.tsx new file mode 100644 index 00000000000..c9802f68c05 --- /dev/null +++ b/apps/webapp/app/assets/icons/MiddlewareIcon.tsx @@ -0,0 +1,21 @@ +export function MiddlewareIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MoveToBottomIcon.tsx b/apps/webapp/app/assets/icons/MoveToBottomIcon.tsx new file mode 100644 index 00000000000..997550e9265 --- /dev/null +++ b/apps/webapp/app/assets/icons/MoveToBottomIcon.tsx @@ -0,0 +1,27 @@ +export function MoveToBottomIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MoveToTopIcon.tsx b/apps/webapp/app/assets/icons/MoveToTopIcon.tsx new file mode 100644 index 00000000000..46938fd391a --- /dev/null +++ b/apps/webapp/app/assets/icons/MoveToTopIcon.tsx @@ -0,0 +1,34 @@ +export function MoveToTopIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/MoveUpIcon.tsx b/apps/webapp/app/assets/icons/MoveUpIcon.tsx new file mode 100644 index 00000000000..6e5d8a84ba9 --- /dev/null +++ b/apps/webapp/app/assets/icons/MoveUpIcon.tsx @@ -0,0 +1,41 @@ +export function MoveUpIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/NodejsLogoIcon.tsx b/apps/webapp/app/assets/icons/NodejsLogoIcon.tsx new file mode 100644 index 00000000000..234dd079e1c --- /dev/null +++ b/apps/webapp/app/assets/icons/NodejsLogoIcon.tsx @@ -0,0 +1,15 @@ +export function NodejsLogoIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/NotificationIcon.tsx b/apps/webapp/app/assets/icons/NotificationIcon.tsx new file mode 100644 index 00000000000..eb7ad5e0ace --- /dev/null +++ b/apps/webapp/app/assets/icons/NotificationIcon.tsx @@ -0,0 +1,20 @@ +export function NotificationIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/OneTreeIcon.tsx b/apps/webapp/app/assets/icons/OneTreeIcon.tsx new file mode 100644 index 00000000000..280a7b73c27 --- /dev/null +++ b/apps/webapp/app/assets/icons/OneTreeIcon.tsx @@ -0,0 +1,161 @@ +export function OneTreeIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/PadlockIcon.tsx b/apps/webapp/app/assets/icons/PadlockIcon.tsx new file mode 100644 index 00000000000..0d7047dbbbb --- /dev/null +++ b/apps/webapp/app/assets/icons/PadlockIcon.tsx @@ -0,0 +1,21 @@ +export function PadlockIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/PauseIcon.tsx b/apps/webapp/app/assets/icons/PauseIcon.tsx new file mode 100644 index 00000000000..9da4b7f885b --- /dev/null +++ b/apps/webapp/app/assets/icons/PauseIcon.tsx @@ -0,0 +1,19 @@ +export function PauseIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/PlaygroundIcon.tsx b/apps/webapp/app/assets/icons/PlaygroundIcon.tsx new file mode 100644 index 00000000000..857b9c314cb --- /dev/null +++ b/apps/webapp/app/assets/icons/PlaygroundIcon.tsx @@ -0,0 +1,37 @@ +export function PlaygroundIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/PlusIcon.tsx b/apps/webapp/app/assets/icons/PlusIcon.tsx new file mode 100644 index 00000000000..9b302f46b3a --- /dev/null +++ b/apps/webapp/app/assets/icons/PlusIcon.tsx @@ -0,0 +1,27 @@ +export function PlusIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/PrivateIcon.tsx b/apps/webapp/app/assets/icons/PrivateIcon.tsx new file mode 100644 index 00000000000..b5390f1e141 --- /dev/null +++ b/apps/webapp/app/assets/icons/PrivateIcon.tsx @@ -0,0 +1,26 @@ +export function PrivateIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/PromoteIcon.tsx b/apps/webapp/app/assets/icons/PromoteIcon.tsx new file mode 100644 index 00000000000..be703888772 --- /dev/null +++ b/apps/webapp/app/assets/icons/PromoteIcon.tsx @@ -0,0 +1,24 @@ +export function PromoteIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/PythonLogoIcon.tsx b/apps/webapp/app/assets/icons/PythonLogoIcon.tsx new file mode 100644 index 00000000000..e0fbc6fc0ec --- /dev/null +++ b/apps/webapp/app/assets/icons/PythonLogoIcon.tsx @@ -0,0 +1,21 @@ +export function PythonLogoIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/QuestionMarkIcon.tsx b/apps/webapp/app/assets/icons/QuestionMarkIcon.tsx new file mode 100644 index 00000000000..e4f064e9c90 --- /dev/null +++ b/apps/webapp/app/assets/icons/QuestionMarkIcon.tsx @@ -0,0 +1,22 @@ +export function QuestionMarkIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/QueuesIcon.tsx b/apps/webapp/app/assets/icons/QueuesIcon.tsx new file mode 100644 index 00000000000..51038053e9b --- /dev/null +++ b/apps/webapp/app/assets/icons/QueuesIcon.tsx @@ -0,0 +1,32 @@ +export function QueuesIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/RadarPulseIcon.tsx b/apps/webapp/app/assets/icons/RadarPulseIcon.tsx new file mode 100644 index 00000000000..b7c6fe3ca5e --- /dev/null +++ b/apps/webapp/app/assets/icons/RadarPulseIcon.tsx @@ -0,0 +1,26 @@ +export function RadarPulseIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/RegionIcons.tsx b/apps/webapp/app/assets/icons/RegionIcons.tsx new file mode 100644 index 00000000000..098d5bc98ce --- /dev/null +++ b/apps/webapp/app/assets/icons/RegionIcons.tsx @@ -0,0 +1,106 @@ +export function FlagIcon({ + region, + className, +}: { + region: "usa" | "europe" | (string & {}); + className?: string; +}) { + switch (region) { + case "usa": + return ; + case "europe": + return ; + default: + return null; + } +} + +export function FlagUSA({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + ); +} + +export function FlagEurope({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/RightSideMenuIcon.tsx b/apps/webapp/app/assets/icons/RightSideMenuIcon.tsx new file mode 100644 index 00000000000..d3feae1d7c2 --- /dev/null +++ b/apps/webapp/app/assets/icons/RightSideMenuIcon.tsx @@ -0,0 +1,30 @@ +import { motion } from "framer-motion"; +import { useState } from "react"; + +export function RightSideMenuIcon({ className }: { className?: string }) { + const [hovered, setHovered] = useState(false); + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + + + ); +} diff --git a/apps/webapp/app/assets/icons/RolesIcon.tsx b/apps/webapp/app/assets/icons/RolesIcon.tsx new file mode 100644 index 00000000000..ae73fde80b2 --- /dev/null +++ b/apps/webapp/app/assets/icons/RolesIcon.tsx @@ -0,0 +1,35 @@ +export function RolesIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/RunFunctionIcon.tsx b/apps/webapp/app/assets/icons/RunFunctionIcon.tsx new file mode 100644 index 00000000000..06c6cb55fa9 --- /dev/null +++ b/apps/webapp/app/assets/icons/RunFunctionIcon.tsx @@ -0,0 +1,20 @@ +export function RunFunctionIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/RunsIcon.tsx b/apps/webapp/app/assets/icons/RunsIcon.tsx new file mode 100644 index 00000000000..a053d045287 --- /dev/null +++ b/apps/webapp/app/assets/icons/RunsIcon.tsx @@ -0,0 +1,79 @@ +export function RunsIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} + +export function RunsIconSmall({ className }: { className?: string }) { + return ( + + + + + ); +} + +export function RunsIconExtraSmall({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SaplingIcon.tsx b/apps/webapp/app/assets/icons/SaplingIcon.tsx new file mode 100644 index 00000000000..80a0109de39 --- /dev/null +++ b/apps/webapp/app/assets/icons/SaplingIcon.tsx @@ -0,0 +1,137 @@ +export function SaplingIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ScheduleIcon.tsx b/apps/webapp/app/assets/icons/ScheduleIcon.tsx new file mode 100644 index 00000000000..7cd6341abc7 --- /dev/null +++ b/apps/webapp/app/assets/icons/ScheduleIcon.tsx @@ -0,0 +1,6 @@ +import { CalendarDaysIcon } from "@heroicons/react/24/solid"; +import { cn } from "~/utils/cn"; + +export function ScheduleIcon({ className }: { className?: string }) { + return ; +} diff --git a/apps/webapp/app/assets/icons/ShieldIcon.tsx b/apps/webapp/app/assets/icons/ShieldIcon.tsx new file mode 100644 index 00000000000..5616e0380f9 --- /dev/null +++ b/apps/webapp/app/assets/icons/ShieldIcon.tsx @@ -0,0 +1,25 @@ +export function ShieldIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ShieldLockIcon.tsx b/apps/webapp/app/assets/icons/ShieldLockIcon.tsx new file mode 100644 index 00000000000..27726e61867 --- /dev/null +++ b/apps/webapp/app/assets/icons/ShieldLockIcon.tsx @@ -0,0 +1,20 @@ +export function ShieldLockIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ShowParentIcon.tsx b/apps/webapp/app/assets/icons/ShowParentIcon.tsx new file mode 100644 index 00000000000..fe9a5540568 --- /dev/null +++ b/apps/webapp/app/assets/icons/ShowParentIcon.tsx @@ -0,0 +1,25 @@ +export function ShowParentIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function ShowParentIconSelected({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SideMenuRightClosed.tsx b/apps/webapp/app/assets/icons/SideMenuRightClosed.tsx new file mode 100644 index 00000000000..b120300c0c0 --- /dev/null +++ b/apps/webapp/app/assets/icons/SideMenuRightClosed.tsx @@ -0,0 +1,15 @@ +export function SideMenuRightClosedIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SlackIcon.tsx b/apps/webapp/app/assets/icons/SlackIcon.tsx new file mode 100644 index 00000000000..dd706fb34d3 --- /dev/null +++ b/apps/webapp/app/assets/icons/SlackIcon.tsx @@ -0,0 +1,14 @@ +export function SlackIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SlackMonoIcon.tsx b/apps/webapp/app/assets/icons/SlackMonoIcon.tsx new file mode 100644 index 00000000000..b2322f2b76e --- /dev/null +++ b/apps/webapp/app/assets/icons/SlackMonoIcon.tsx @@ -0,0 +1,15 @@ +export function SlackMonoIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SlidersIcon.tsx b/apps/webapp/app/assets/icons/SlidersIcon.tsx new file mode 100644 index 00000000000..7a859fd1d65 --- /dev/null +++ b/apps/webapp/app/assets/icons/SlidersIcon.tsx @@ -0,0 +1,43 @@ +export function SlidersIcon({ className }: { className?: string }) { + return ( + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SnakedArrowIcon.tsx b/apps/webapp/app/assets/icons/SnakedArrowIcon.tsx new file mode 100644 index 00000000000..0766cce1b46 --- /dev/null +++ b/apps/webapp/app/assets/icons/SnakedArrowIcon.tsx @@ -0,0 +1,20 @@ +export function SnakedArrowIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SparkleListIcon.tsx b/apps/webapp/app/assets/icons/SparkleListIcon.tsx new file mode 100644 index 00000000000..264fc227c84 --- /dev/null +++ b/apps/webapp/app/assets/icons/SparkleListIcon.tsx @@ -0,0 +1,14 @@ +export function SparkleListIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/StarIcon.tsx b/apps/webapp/app/assets/icons/StarIcon.tsx new file mode 100644 index 00000000000..ea21f278477 --- /dev/null +++ b/apps/webapp/app/assets/icons/StarIcon.tsx @@ -0,0 +1,18 @@ +export function StarIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/StatusIcon.tsx b/apps/webapp/app/assets/icons/StatusIcon.tsx new file mode 100644 index 00000000000..9499b50d575 --- /dev/null +++ b/apps/webapp/app/assets/icons/StatusIcon.tsx @@ -0,0 +1,9 @@ +import { cn } from "~/utils/cn"; + +export function StatusIcon({ className }: { className?: string }) { + return ( +
+
+
+ ); +} diff --git a/apps/webapp/app/assets/icons/StreamsIcon.tsx b/apps/webapp/app/assets/icons/StreamsIcon.tsx new file mode 100644 index 00000000000..8deb7a19c9e --- /dev/null +++ b/apps/webapp/app/assets/icons/StreamsIcon.tsx @@ -0,0 +1,24 @@ +export function StreamsIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/TableIcon.tsx b/apps/webapp/app/assets/icons/TableIcon.tsx new file mode 100644 index 00000000000..f8a181d7f66 --- /dev/null +++ b/apps/webapp/app/assets/icons/TableIcon.tsx @@ -0,0 +1,16 @@ +export function TableIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/TaskIcon.tsx b/apps/webapp/app/assets/icons/TaskIcon.tsx new file mode 100644 index 00000000000..c0cdc3fd440 --- /dev/null +++ b/apps/webapp/app/assets/icons/TaskIcon.tsx @@ -0,0 +1,231 @@ +export function TaskIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + ); +} + +export function TaskCachedIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + ); +} + +export function TaskIconSmall({ className }: { className?: string }) { + return ( + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/TasksIcon.tsx b/apps/webapp/app/assets/icons/TasksIcon.tsx new file mode 100644 index 00000000000..1a31ba65804 --- /dev/null +++ b/apps/webapp/app/assets/icons/TasksIcon.tsx @@ -0,0 +1,25 @@ +export function TasksIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/TestTubeIcon.tsx b/apps/webapp/app/assets/icons/TestTubeIcon.tsx new file mode 100644 index 00000000000..3497ac9fc47 --- /dev/null +++ b/apps/webapp/app/assets/icons/TestTubeIcon.tsx @@ -0,0 +1,22 @@ +export function TestTubeIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/TextInlineIcon.tsx b/apps/webapp/app/assets/icons/TextInlineIcon.tsx new file mode 100644 index 00000000000..538d9768d03 --- /dev/null +++ b/apps/webapp/app/assets/icons/TextInlineIcon.tsx @@ -0,0 +1,41 @@ +export function TextInlineIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/TextSquareIcon.tsx b/apps/webapp/app/assets/icons/TextSquareIcon.tsx new file mode 100644 index 00000000000..cffb0aa9d2e --- /dev/null +++ b/apps/webapp/app/assets/icons/TextSquareIcon.tsx @@ -0,0 +1,50 @@ +export function TextSquareIcon({ className }: { className?: string }) { + return ( + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/TextWrapIcon.tsx b/apps/webapp/app/assets/icons/TextWrapIcon.tsx new file mode 100644 index 00000000000..ac37867e829 --- /dev/null +++ b/apps/webapp/app/assets/icons/TextWrapIcon.tsx @@ -0,0 +1,34 @@ +export function TextWrapIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/TimedOutIcon.tsx b/apps/webapp/app/assets/icons/TimedOutIcon.tsx new file mode 100644 index 00000000000..3ad34e698c8 --- /dev/null +++ b/apps/webapp/app/assets/icons/TimedOutIcon.tsx @@ -0,0 +1,19 @@ +export function TimedOutIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/ToggleArrowIcon.tsx b/apps/webapp/app/assets/icons/ToggleArrowIcon.tsx new file mode 100644 index 00000000000..7bcb261c4dd --- /dev/null +++ b/apps/webapp/app/assets/icons/ToggleArrowIcon.tsx @@ -0,0 +1,10 @@ +export function ToggleArrowIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/TraceIcon.tsx b/apps/webapp/app/assets/icons/TraceIcon.tsx new file mode 100644 index 00000000000..20eb1078483 --- /dev/null +++ b/apps/webapp/app/assets/icons/TraceIcon.tsx @@ -0,0 +1,9 @@ +export function TraceIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/TriggerIcon.tsx b/apps/webapp/app/assets/icons/TriggerIcon.tsx new file mode 100644 index 00000000000..da73b842911 --- /dev/null +++ b/apps/webapp/app/assets/icons/TriggerIcon.tsx @@ -0,0 +1,5 @@ +import { BoltIcon } from "@heroicons/react/20/solid"; + +export function TriggerIcon({ className }: { className?: string }) { + return ; +} diff --git a/apps/webapp/app/assets/icons/TwoTreesIcon.tsx b/apps/webapp/app/assets/icons/TwoTreesIcon.tsx new file mode 100644 index 00000000000..b00cd7aeda9 --- /dev/null +++ b/apps/webapp/app/assets/icons/TwoTreesIcon.tsx @@ -0,0 +1,44 @@ +export function TwoTreesIcon({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/UsageIcon.tsx b/apps/webapp/app/assets/icons/UsageIcon.tsx new file mode 100644 index 00000000000..56215bb3490 --- /dev/null +++ b/apps/webapp/app/assets/icons/UsageIcon.tsx @@ -0,0 +1,28 @@ +export function UsageIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/UserCrossIcon.tsx b/apps/webapp/app/assets/icons/UserCrossIcon.tsx new file mode 100644 index 00000000000..84a999cd691 --- /dev/null +++ b/apps/webapp/app/assets/icons/UserCrossIcon.tsx @@ -0,0 +1,37 @@ +export function UserCrossIcon({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/UserGroupIcon.tsx b/apps/webapp/app/assets/icons/UserGroupIcon.tsx new file mode 100644 index 00000000000..d7b49db993c --- /dev/null +++ b/apps/webapp/app/assets/icons/UserGroupIcon.tsx @@ -0,0 +1,33 @@ +export function UserGroupIcon({ className }: { className?: string }) { + return ( + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/WaitpointTokenIcon.tsx b/apps/webapp/app/assets/icons/WaitpointTokenIcon.tsx new file mode 100644 index 00000000000..4438fd0bfde --- /dev/null +++ b/apps/webapp/app/assets/icons/WaitpointTokenIcon.tsx @@ -0,0 +1,26 @@ +export function WaitpointTokenIcon({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/WarmStartIcon.tsx b/apps/webapp/app/assets/icons/WarmStartIcon.tsx new file mode 100644 index 00000000000..211b27a98f2 --- /dev/null +++ b/apps/webapp/app/assets/icons/WarmStartIcon.tsx @@ -0,0 +1,26 @@ +import { FireIcon } from "@heroicons/react/20/solid"; +import { cn } from "~/utils/cn"; + +function ColdStartIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export function WarmStartIcon({ + isWarmStart, + className, +}: { + isWarmStart: boolean; + className?: string; +}) { + if (isWarmStart) { + return ; + } + return ; +} diff --git a/apps/webapp/app/assets/icons/WebhookIcon.tsx b/apps/webapp/app/assets/icons/WebhookIcon.tsx new file mode 100644 index 00000000000..cbd6e85dddf --- /dev/null +++ b/apps/webapp/app/assets/icons/WebhookIcon.tsx @@ -0,0 +1,12 @@ +export function WebhookIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/v3.svg b/apps/webapp/app/assets/icons/v3.svg new file mode 100644 index 00000000000..d9aa6523ce8 --- /dev/null +++ b/apps/webapp/app/assets/icons/v3.svg @@ -0,0 +1,4 @@ + + + + diff --git a/apps/webapp/app/assets/images/blurred-dashboard-background-menu-bottom.jpg b/apps/webapp/app/assets/images/blurred-dashboard-background-menu-bottom.jpg new file mode 100644 index 00000000000..2a993f82127 Binary files /dev/null and b/apps/webapp/app/assets/images/blurred-dashboard-background-menu-bottom.jpg differ diff --git a/apps/webapp/app/assets/images/blurred-dashboard-background-menu-top.jpg b/apps/webapp/app/assets/images/blurred-dashboard-background-menu-top.jpg new file mode 100644 index 00000000000..8aca8563cdc Binary files /dev/null and b/apps/webapp/app/assets/images/blurred-dashboard-background-menu-top.jpg differ diff --git a/apps/webapp/app/assets/images/blurred-dashboard-background-table.jpg b/apps/webapp/app/assets/images/blurred-dashboard-background-table.jpg new file mode 100644 index 00000000000..a2ae4029fe2 Binary files /dev/null and b/apps/webapp/app/assets/images/blurred-dashboard-background-table.jpg differ diff --git a/apps/webapp/app/assets/images/cli-connected.png b/apps/webapp/app/assets/images/cli-connected.png new file mode 100644 index 00000000000..cd6b4e37fe1 Binary files /dev/null and b/apps/webapp/app/assets/images/cli-connected.png differ diff --git a/apps/webapp/app/assets/images/cli-disconnected.png b/apps/webapp/app/assets/images/cli-disconnected.png new file mode 100644 index 00000000000..dff3ecc106a Binary files /dev/null and b/apps/webapp/app/assets/images/cli-disconnected.png differ diff --git a/apps/webapp/app/assets/images/color-wheel.png b/apps/webapp/app/assets/images/color-wheel.png new file mode 100644 index 00000000000..af76136e82d Binary files /dev/null and b/apps/webapp/app/assets/images/color-wheel.png differ diff --git a/apps/webapp/app/assets/images/error-banner-tile@2x.png b/apps/webapp/app/assets/images/error-banner-tile@2x.png new file mode 100644 index 00000000000..2d601bfced7 Binary files /dev/null and b/apps/webapp/app/assets/images/error-banner-tile@2x.png differ diff --git a/apps/webapp/app/assets/images/logo.svg b/apps/webapp/app/assets/images/logo.svg index 2a438e5c4f3..503d34b3e3f 100644 --- a/apps/webapp/app/assets/images/logo.svg +++ b/apps/webapp/app/assets/images/logo.svg @@ -1,64 +1,192 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/webapp/app/assets/images/onboarding-image.png b/apps/webapp/app/assets/images/onboarding-image.png deleted file mode 100644 index 04337710b0d..00000000000 Binary files a/apps/webapp/app/assets/images/onboarding-image.png and /dev/null differ diff --git a/apps/webapp/app/assets/images/open-bulk-actions-panel.png b/apps/webapp/app/assets/images/open-bulk-actions-panel.png new file mode 100644 index 00000000000..a1b48f38646 Binary files /dev/null and b/apps/webapp/app/assets/images/open-bulk-actions-panel.png differ diff --git a/apps/webapp/app/assets/images/producthunt.png b/apps/webapp/app/assets/images/producthunt.png new file mode 100644 index 00000000000..e27a96f6976 Binary files /dev/null and b/apps/webapp/app/assets/images/producthunt.png differ diff --git a/apps/webapp/app/assets/images/queues-dashboard.png b/apps/webapp/app/assets/images/queues-dashboard.png new file mode 100644 index 00000000000..321c79e6290 Binary files /dev/null and b/apps/webapp/app/assets/images/queues-dashboard.png differ diff --git a/apps/webapp/app/assets/images/select-runs-individually.png b/apps/webapp/app/assets/images/select-runs-individually.png new file mode 100644 index 00000000000..31a5d048a8a Binary files /dev/null and b/apps/webapp/app/assets/images/select-runs-individually.png differ diff --git a/apps/webapp/app/assets/images/select-runs-using-filters.png b/apps/webapp/app/assets/images/select-runs-using-filters.png new file mode 100644 index 00000000000..78ce487d0fc Binary files /dev/null and b/apps/webapp/app/assets/images/select-runs-using-filters.png differ diff --git a/apps/webapp/app/assets/images/tile-in-progress@2x.png b/apps/webapp/app/assets/images/tile-in-progress@2x.png new file mode 100644 index 00000000000..5724c3a0a2d Binary files /dev/null and b/apps/webapp/app/assets/images/tile-in-progress@2x.png differ diff --git a/apps/webapp/app/assets/images/triggers/custom-event.png b/apps/webapp/app/assets/images/triggers/custom-event.png deleted file mode 100644 index 762cabd4b79..00000000000 Binary files a/apps/webapp/app/assets/images/triggers/custom-event.png and /dev/null differ diff --git a/apps/webapp/app/assets/images/triggers/event-bridge.png b/apps/webapp/app/assets/images/triggers/event-bridge.png deleted file mode 100644 index 1c0879f5faa..00000000000 Binary files a/apps/webapp/app/assets/images/triggers/event-bridge.png and /dev/null differ diff --git a/apps/webapp/app/assets/images/triggers/http-endpoint.png b/apps/webapp/app/assets/images/triggers/http-endpoint.png deleted file mode 100644 index 2865b748acf..00000000000 Binary files a/apps/webapp/app/assets/images/triggers/http-endpoint.png and /dev/null differ diff --git a/apps/webapp/app/assets/images/triggers/http-polling.png b/apps/webapp/app/assets/images/triggers/http-polling.png deleted file mode 100644 index 329344ab30b..00000000000 Binary files a/apps/webapp/app/assets/images/triggers/http-polling.png and /dev/null differ diff --git a/apps/webapp/app/assets/images/triggers/schedule.png b/apps/webapp/app/assets/images/triggers/schedule.png deleted file mode 100644 index c39d49c0543..00000000000 Binary files a/apps/webapp/app/assets/images/triggers/schedule.png and /dev/null differ diff --git a/apps/webapp/app/assets/images/triggers/slack-interaction.png b/apps/webapp/app/assets/images/triggers/slack-interaction.png deleted file mode 100644 index 9ea87cfe05e..00000000000 Binary files a/apps/webapp/app/assets/images/triggers/slack-interaction.png and /dev/null differ diff --git a/apps/webapp/app/assets/images/triggers/webhook.png b/apps/webapp/app/assets/images/triggers/webhook.png deleted file mode 100644 index 02edf60e4e2..00000000000 Binary files a/apps/webapp/app/assets/images/triggers/webhook.png and /dev/null differ diff --git a/apps/webapp/app/assets/logos/ATAndTLogo.tsx b/apps/webapp/app/assets/logos/ATAndTLogo.tsx new file mode 100644 index 00000000000..505294d3440 --- /dev/null +++ b/apps/webapp/app/assets/logos/ATAndTLogo.tsx @@ -0,0 +1,21 @@ +export function ATAndTLogo({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/AppsmithLogo.tsx b/apps/webapp/app/assets/logos/AppsmithLogo.tsx new file mode 100644 index 00000000000..4d4af738fe4 --- /dev/null +++ b/apps/webapp/app/assets/logos/AppsmithLogo.tsx @@ -0,0 +1,34 @@ +export function AppsmithLogo({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/AstroLogo.tsx b/apps/webapp/app/assets/logos/AstroLogo.tsx new file mode 100644 index 00000000000..fb51a8f422b --- /dev/null +++ b/apps/webapp/app/assets/logos/AstroLogo.tsx @@ -0,0 +1,57 @@ +export function AstroLogo({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/CalComLogo.tsx b/apps/webapp/app/assets/logos/CalComLogo.tsx new file mode 100644 index 00000000000..097ddbe8149 --- /dev/null +++ b/apps/webapp/app/assets/logos/CalComLogo.tsx @@ -0,0 +1,50 @@ +export function CalComLogo({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/ExpressLogo.tsx b/apps/webapp/app/assets/logos/ExpressLogo.tsx new file mode 100644 index 00000000000..974e93a718a --- /dev/null +++ b/apps/webapp/app/assets/logos/ExpressLogo.tsx @@ -0,0 +1,15 @@ +export function ExpressLogo({ className }: { className?: string }) { + return ( + + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/FastifyLogo.tsx b/apps/webapp/app/assets/logos/FastifyLogo.tsx new file mode 100644 index 00000000000..928df8d6a64 --- /dev/null +++ b/apps/webapp/app/assets/logos/FastifyLogo.tsx @@ -0,0 +1,45 @@ +export function FastifyLogo({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/GoogleLogo.tsx b/apps/webapp/app/assets/logos/GoogleLogo.tsx new file mode 100644 index 00000000000..e0ff9597f07 --- /dev/null +++ b/apps/webapp/app/assets/logos/GoogleLogo.tsx @@ -0,0 +1,22 @@ +export function GoogleLogo({ className }: { className?: string }) { + return ( + + + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/LyftLogo.tsx b/apps/webapp/app/assets/logos/LyftLogo.tsx new file mode 100644 index 00000000000..270781927af --- /dev/null +++ b/apps/webapp/app/assets/logos/LyftLogo.tsx @@ -0,0 +1,19 @@ +export function LyftLogo({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/logos/MiddayLogo.tsx b/apps/webapp/app/assets/logos/MiddayLogo.tsx new file mode 100644 index 00000000000..1f4740ecb14 --- /dev/null +++ b/apps/webapp/app/assets/logos/MiddayLogo.tsx @@ -0,0 +1,23 @@ +export function MiddayLogo({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/NestjsLogo.tsx b/apps/webapp/app/assets/logos/NestjsLogo.tsx new file mode 100644 index 00000000000..d908241d092 --- /dev/null +++ b/apps/webapp/app/assets/logos/NestjsLogo.tsx @@ -0,0 +1,16 @@ +export function NestjsLogo({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/NextjsLogo.tsx b/apps/webapp/app/assets/logos/NextjsLogo.tsx new file mode 100644 index 00000000000..9e5fa09cc0d --- /dev/null +++ b/apps/webapp/app/assets/logos/NextjsLogo.tsx @@ -0,0 +1,47 @@ +export function NextjsLogo({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/NuxtLogo.tsx b/apps/webapp/app/assets/logos/NuxtLogo.tsx new file mode 100644 index 00000000000..e4fe0295bc0 --- /dev/null +++ b/apps/webapp/app/assets/logos/NuxtLogo.tsx @@ -0,0 +1,21 @@ +export function NuxtLogo({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/RedwoodLogo.tsx b/apps/webapp/app/assets/logos/RedwoodLogo.tsx new file mode 100644 index 00000000000..6dd0e386ee5 --- /dev/null +++ b/apps/webapp/app/assets/logos/RedwoodLogo.tsx @@ -0,0 +1,42 @@ +export function RedwoodLogo({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/RemixLogo.tsx b/apps/webapp/app/assets/logos/RemixLogo.tsx new file mode 100644 index 00000000000..be9a10fdaec --- /dev/null +++ b/apps/webapp/app/assets/logos/RemixLogo.tsx @@ -0,0 +1,199 @@ +export function RemixLogo({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/ShopifyLogo.tsx b/apps/webapp/app/assets/logos/ShopifyLogo.tsx new file mode 100644 index 00000000000..86c71de7cfa --- /dev/null +++ b/apps/webapp/app/assets/logos/ShopifyLogo.tsx @@ -0,0 +1,39 @@ +export function ShopifyLogo({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/SveltekitLogo.tsx b/apps/webapp/app/assets/logos/SveltekitLogo.tsx new file mode 100644 index 00000000000..70875e2c58c --- /dev/null +++ b/apps/webapp/app/assets/logos/SveltekitLogo.tsx @@ -0,0 +1,29 @@ +export function SvelteKitLogo({ className }: { className?: string }) { + return ( + + + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/TldrawLogo.tsx b/apps/webapp/app/assets/logos/TldrawLogo.tsx new file mode 100644 index 00000000000..a15ca8c64d0 --- /dev/null +++ b/apps/webapp/app/assets/logos/TldrawLogo.tsx @@ -0,0 +1,41 @@ +export function TldrawLogo({ className }: { className?: string }) { + return ( + + + + + + + + + + ); +} diff --git a/apps/webapp/app/assets/logos/UnkeyLogo.tsx b/apps/webapp/app/assets/logos/UnkeyLogo.tsx new file mode 100644 index 00000000000..9ef4f416675 --- /dev/null +++ b/apps/webapp/app/assets/logos/UnkeyLogo.tsx @@ -0,0 +1,17 @@ +export function UnkeyLogo({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/logos/VerizonLogo.tsx b/apps/webapp/app/assets/logos/VerizonLogo.tsx new file mode 100644 index 00000000000..908dcb4968c --- /dev/null +++ b/apps/webapp/app/assets/logos/VerizonLogo.tsx @@ -0,0 +1,33 @@ +export function VerizonLogo({ className }: { className?: string }) { + return ( + + + + + + + + + + + + ); +} diff --git a/apps/webapp/app/bootstrap.ts b/apps/webapp/app/bootstrap.ts new file mode 100644 index 00000000000..84c13c061f8 --- /dev/null +++ b/apps/webapp/app/bootstrap.ts @@ -0,0 +1,75 @@ +import { mkdir, writeFile } from "fs/promises"; +import { prisma } from "./db.server"; +import { env } from "./env.server"; +import { WorkerGroupService } from "./v3/services/worker/workerGroupService.server"; +import { dirname } from "path"; +import { tryCatch } from "@trigger.dev/core"; + +export async function bootstrap() { + if (env.TRIGGER_BOOTSTRAP_ENABLED !== "1") { + return; + } + + if (env.TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME) { + const [error] = await tryCatch(createWorkerGroup()); + if (error) { + console.error("Failed to create worker group", { error }); + } + } +} + +async function createWorkerGroup() { + const workerGroupName = env.TRIGGER_BOOTSTRAP_WORKER_GROUP_NAME; + const tokenPath = env.TRIGGER_BOOTSTRAP_WORKER_TOKEN_PATH; + + const existingWorkerGroup = await prisma.workerInstanceGroup.findFirst({ + where: { + name: workerGroupName, + }, + }); + + if (existingWorkerGroup) { + console.warn(`[bootstrap] Worker group ${workerGroupName} already exists`); + return; + } + + const service = new WorkerGroupService(); + const { token, workerGroup } = await service.createWorkerGroup({ + name: workerGroupName, + }); + + console.log(` +========================== +Trigger.dev Bootstrap - Worker Token + +WARNING: This will only be shown once. Save it now! + +Worker group: +${workerGroup.name} + +Token: +${token.plaintext} + +If using docker compose, set: +TRIGGER_WORKER_TOKEN=${token.plaintext} + +${ + tokenPath + ? `Or, if using a file: +TRIGGER_WORKER_TOKEN=file://${tokenPath}` + : "" +} + +========================== + `); + + if (tokenPath) { + const dir = dirname(tokenPath); + await mkdir(dir, { recursive: true }); + await writeFile(tokenPath, token.plaintext, { + mode: 0o600, + }); + + console.log(`[bootstrap] Worker token saved to ${tokenPath}`); + } +} diff --git a/apps/webapp/app/clientBeforeFirstRender.ts b/apps/webapp/app/clientBeforeFirstRender.ts new file mode 100644 index 00000000000..5b5b6404221 --- /dev/null +++ b/apps/webapp/app/clientBeforeFirstRender.ts @@ -0,0 +1,35 @@ +/** + * 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/components/AskAI.tsx b/apps/webapp/app/components/AskAI.tsx new file mode 100644 index 00000000000..d62ffa5b33d --- /dev/null +++ b/apps/webapp/app/components/AskAI.tsx @@ -0,0 +1,545 @@ +import { + ArrowPathIcon, + ArrowUpIcon, + HandThumbDownIcon, + HandThumbUpIcon, + StopIcon, +} from "@heroicons/react/20/solid"; +import { cn } from "~/utils/cn"; +import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk"; +import { useSearchParams } from "@remix-run/react"; +import DOMPurify from "dompurify"; +import { motion } from "framer-motion"; +import { marked } from "marked"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useTypedRouteLoaderData } from "remix-typedjson"; +import { AISparkleIcon } from "~/assets/icons/AISparkleIcon"; +import { SparkleListIcon } from "~/assets/icons/SparkleListIcon"; +import { useFeatures } from "~/hooks/useFeatures"; +import { type loader } from "~/root"; +import { Button } from "./primitives/Buttons"; +import { Callout } from "./primitives/Callout"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./primitives/Dialog"; +import { Header2 } from "./primitives/Headers"; +import { Paragraph } from "./primitives/Paragraph"; +import { ShortcutKey } from "./primitives/ShortcutKey"; +import { Spinner } from "./primitives/Spinner"; +import { + SimpleTooltip, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./primitives/Tooltip"; +import { ClientOnly } from "remix-utils/client-only"; + +function useKapaWebsiteId() { + const routeMatch = useTypedRouteLoaderData("root"); + return routeMatch?.kapa.websiteId; +} + +export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) { + const { isManagedCloud } = useFeatures(); + const websiteId = useKapaWebsiteId(); + + if (!isManagedCloud || !websiteId) { + return null; + } + + return ( + + + + } + > + {() => } + + ); +} + +type AskAIProviderProps = { + websiteId: string; + isCollapsed?: boolean; +}; + +function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) { + const [isOpen, setIsOpen] = useState(false); + const [initialQuery, setInitialQuery] = useState(); + const [searchParams, setSearchParams] = useSearchParams(); + + const openAskAI = useCallback((question?: string) => { + if (question) { + setInitialQuery(question); + } else { + setInitialQuery(undefined); + } + setIsOpen(true); + }, []); + + const closeAskAI = useCallback(() => { + setIsOpen(false); + setInitialQuery(undefined); + }, []); + + // Handle URL param functionality + useEffect(() => { + const aiHelp = searchParams.get("aiHelp"); + if (aiHelp) { + // Delay to avoid hCaptcha bot detection + window.setTimeout(() => openAskAI(aiHelp), 1000); + + // Clone instead of mutating in place + const next = new URLSearchParams(searchParams); + next.delete("aiHelp"); + setSearchParams(next); + } + }, [searchParams, openAskAI]); + + return ( + openAskAI(), + onAnswerGenerationCompleted: () => openAskAI(), + }, + }} + botProtectionMechanism="hcaptcha" + > + + + + + + + + + + Ask AI + + + + + + + + + + + ); +} + +type AskAIDialogProps = { + initialQuery?: string; + isOpen: boolean; + onOpenChange: (open: boolean) => void; + closeAskAI: () => void; +}; + +function AskAIDialog({ initialQuery, isOpen, onOpenChange, closeAskAI }: AskAIDialogProps) { + const handleOpenChange = (open: boolean) => { + if (!open) { + closeAskAI(); + } else { + onOpenChange(open); + } + }; + + return ( + + + +
+ + Ask AI +
+
+ +
+
+ ); +} + +function ChatMessages({ + conversation, + isPreparingAnswer, + isGeneratingAnswer, + onReset, + onExampleClick, + error, + addFeedback, +}: { + conversation: QA[]; + isPreparingAnswer: boolean; + isGeneratingAnswer: boolean; + onReset: () => void; + onExampleClick: (question: string) => void; + error: string | null; + addFeedback: ( + questionAnswerId: string, + reaction: "upvote" | "downvote", + comment?: FeedbackComment + ) => void; +}) { + const [feedbackGivenForQAs, setFeedbackGivenForQAs] = useState>(new Set()); + + // Reset feedback state when conversation is reset + useEffect(() => { + if (conversation.length === 0) { + setFeedbackGivenForQAs(new Set()); + } + }, [conversation.length]); + + // Check if feedback has been given for the latest QA + const latestQA = conversation[conversation.length - 1]; + const hasFeedbackForLatestQA = latestQA?.id ? feedbackGivenForQAs.has(latestQA.id) : false; + + const exampleQuestions = [ + "How do I increase my concurrency limit?", + "How do I debug errors in my task?", + "How do I deploy my task?", + ]; + + return ( +
+ {conversation.length === 0 ? ( + + + I'm trained on docs, examples, and other content. Ask me anything about Trigger.dev. + + {exampleQuestions.map((question, index) => ( + onExampleClick(question)} + variants={{ + hidden: { + opacity: 0, + x: 20, + }, + visible: { + opacity: 1, + x: 0, + transition: { + opacity: { + duration: 0.5, + ease: "linear", + }, + x: { + type: "spring", + stiffness: 300, + damping: 25, + }, + }, + }, + }} + > + + + {question} + + + ))} + + ) : ( + conversation.map((qa) => ( +
+ {qa.question} +
+
+ )) + )} + {conversation.length > 0 && + !isPreparingAnswer && + !isGeneratingAnswer && + !error && + !latestQA?.id && ( +
+ + Answer generation was stopped + + +
+ )} + {conversation.length > 0 && + !isPreparingAnswer && + !isGeneratingAnswer && + !error && + latestQA?.id && ( +
+ {hasFeedbackForLatestQA ? ( + + + Thanks for your feedback! + + + ) : ( +
+ + Was this helpful? + +
+ + +
+
+ )} + +
+ )} + {isPreparingAnswer && ( +
+ + Preparing answerโ€ฆ +
+ )} + {error && ( +
+ + Error generating answer: + + {error} If the problem persists after retrying, please contact support. + + +
+ +
+
+ )} +
+ ); +} + +function ChatInterface({ initialQuery }: { initialQuery?: string }) { + const [message, setMessage] = useState(""); + const [isExpanded, setIsExpanded] = useState(false); + const hasSubmittedInitialQuery = useRef(false); + const { + conversation, + submitQuery, + isGeneratingAnswer, + isPreparingAnswer, + resetConversation, + stopGeneration, + error, + addFeedback, + } = useChat(); + + useEffect(() => { + if (initialQuery && !hasSubmittedInitialQuery.current) { + hasSubmittedInitialQuery.current = true; + setIsExpanded(true); + submitQuery(initialQuery); + } + }, [initialQuery, submitQuery]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (message.trim()) { + setIsExpanded(true); + submitQuery(message); + setMessage(""); + } + }; + + const handleExampleClick = (question: string) => { + setIsExpanded(true); + submitQuery(question); + }; + + const handleReset = () => { + resetConversation(); + setIsExpanded(false); + }; + + return ( + + +
+
+ setMessage(e.target.value)} + placeholder="Ask a question..." + disabled={isGeneratingAnswer} + autoFocus + className="flex-1 rounded-md border border-grid-bright bg-background-dimmed px-3 py-2 text-text-bright placeholder:text-text-dimmed focus-visible:focus-custom" + /> + {isGeneratingAnswer ? ( + stopGeneration()} + className="group relative z-10 flex size-10 min-w-10 cursor-pointer items-center justify-center" + > + + + + } + content="Stop generating" + /> + ) : isPreparingAnswer ? ( + + + + ) : ( +
+
+
+ ); +} + +function GradientSpinnerBackground({ + children, + className, + hoverEffect = false, +}: { + children?: React.ReactNode; + className?: string; + hoverEffect?: boolean; +}) { + return ( +
+
+ {children} +
+
+ ); +} diff --git a/apps/webapp/app/components/BackgroundWrapper.tsx b/apps/webapp/app/components/BackgroundWrapper.tsx new file mode 100644 index 00000000000..c13983a12af --- /dev/null +++ b/apps/webapp/app/components/BackgroundWrapper.tsx @@ -0,0 +1,44 @@ +import { type ReactNode } from "react"; +import blurredDashboardBackgroundMenuTop from "~/assets/images/blurred-dashboard-background-menu-top.jpg"; +import blurredDashboardBackgroundMenuBottom from "~/assets/images/blurred-dashboard-background-menu-bottom.jpg"; +import blurredDashboardBackgroundTable from "~/assets/images/blurred-dashboard-background-table.jpg"; + +export function BackgroundWrapper({ children }: { children: ReactNode }) { + return ( +
+
+ +
+ +
+ +
{children}
+
+ ); +} diff --git a/apps/webapp/app/components/BlankStatePanels.tsx b/apps/webapp/app/components/BlankStatePanels.tsx new file mode 100644 index 00000000000..56a7c0da45d --- /dev/null +++ b/apps/webapp/app/components/BlankStatePanels.tsx @@ -0,0 +1,788 @@ +import { + BeakerIcon, + BellAlertIcon, + BookOpenIcon, + ChatBubbleLeftRightIcon, + ClockIcon, + PlusIcon, + QuestionMarkCircleIcon, + RectangleGroupIcon, + RectangleStackIcon, + Squares2X2Icon, +} from "@heroicons/react/20/solid"; +import { useLocation } from "react-use"; +import { AIChatIcon } from "~/assets/icons/AIChatIcon"; +import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons"; +import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon"; +import openBulkActionsPanel from "~/assets/images/open-bulk-actions-panel.png"; +import selectRunsIndividually from "~/assets/images/select-runs-individually.png"; +import selectRunsUsingFilters from "~/assets/images/select-runs-using-filters.png"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useFeatures } from "~/hooks/useFeatures"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server"; +import { type BranchableEnvironmentToken } from "~/utils/branchableEnvironment"; +import { NewBranchPanel } from "~/routes/resources.branches.create"; +import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github"; +import { + docsPath, + v3BillingPath, + v3CreateBulkActionPath, + v3EnvironmentPath, + v3NewProjectAlertPath, + v3NewSchedulePath, +} from "~/utils/pathBuilder"; +import { AskAI } from "./AskAI"; +import { CodeBlock } from "./code/CodeBlock"; +import { InlineCode } from "./code/InlineCode"; +import { environmentFullTitle, EnvironmentIcon } from "./environments/EnvironmentLabel"; +import { Feedback } from "./Feedback"; +import { EnvironmentSelector } from "./navigation/EnvironmentSelector"; +import { Button, LinkButton } from "./primitives/Buttons"; +import { + ClientTabs, + ClientTabsContent, + ClientTabsList, + ClientTabsTrigger, +} from "./primitives/ClientTabs"; +import { Header1 } from "./primitives/Headers"; +import { InfoPanel } from "./primitives/InfoPanel"; +import { Paragraph } from "./primitives/Paragraph"; +import { StepNumber } from "./primitives/StepNumber"; +import { TextLink } from "./primitives/TextLink"; +import { SimpleTooltip } from "./primitives/Tooltip"; +import { + InitCommandV3, + PackageManagerProvider, + TriggerDeployStep, + TriggerDevStepV3, +} from "./SetupCommands"; +import { StepContentContainer } from "./StepContentContainer"; +import { V4Badge } from "./V4Badge"; + +export function HasNoTasksDev() { + return ( + +
+
+ Get setup in 3 minutes +
+ + I'm stuck! + + } + defaultValue="help" + /> +
+
+ + + + + You'll notice a new folder in your project called{" "} + trigger. We've added a few simple example tasks + in there to help you get started. + + + + + + + + + This page will automatically refresh. + +
+
+ ); +} + +export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) { + return ; +} + +export function SchedulesNoPossibleTaskPanel() { + return ( + + How to schedule tasks + + } + > + + You have no scheduled tasks in your project. Before you can schedule a task you need to + create a schedules.task. + + + ); +} + +export function SchedulesNoneAttached() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + const location = useLocation(); + + return ( + + + Scheduled tasks will only run automatically if you connect a schedule to them, you can do + this in the dashboard or using the SDK. + +
+ + Use the dashboard + + + Use the SDK + +
+
+ ); +} + +export function BatchesNone() { + return ( + + How to trigger batches + + } + > + + You have no batches in this environment. You can trigger batches from your backend or from + inside other tasks. + + + ); +} + +export function SessionsNone() { + return ( + + Sessions docs + + } + > + + A session is a stateful execution of an agent, with two-way streaming and durable compute. A + single session can have multiple runs associated with it, so one conversation can span many + task triggers. The input stream carries incoming user messages, and the output stream + carries everything the agent produces, including AI generation parts (text, reasoning, tool + calls, etc.) and any custom data parts your task emits. + + + The easiest way to create one is to trigger a chat.agent task, + which is built on sessions and handles the chat turn loop for you. You can also call{" "} + sessions.start() directly for non-chat patterns like agent inboxes, + approval flows, or server-to-server streaming. + + + ); +} + +export function TestHasNoTasks() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + return ( + + Create a task + + } + > + + Before testing a task, you must first create one. Follow the instructions on the{" "} + Tasks page{" "} + to create a task, then return here to test it. + + + ); +} + +export function DeploymentsNone() { + return ; +} + +export function DeploymentsNoneDev() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( + <> +
+
+ + Deploy your tasks +
+
+ + } + content="Deploy docs" + /> + + } + content="Troubleshooting docs" + /> + +
+
+ + + + This is the Development environment. When you're ready to deploy your tasks, switch to a + different environment. + + + + + ); +} + +export function AlertsNoneDev() { + return ( +
+ + + You can get alerted when deployed runs fail. + + + We don't support alerts in the Development environment. Switch to a deployed environment + to setup alerts. + +
+ + How to setup alerts + +
+
+ +
+ ); +} + +export function AlertsNoneDeployed() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( +
+ + + You can get alerted when deployed runs fail. We currently support sending Slack, Email, + and webhooks. + + +
+ + Alerts docs + + + New alert + +
+
+
+ ); +} + +export function QueuesHasNoTasks() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( + + Create a task + + } + > + + Queues will appear here when you have created a task in this environment. Follow the + instructions on the{" "} + Tasks page{" "} + to create a task, then return here to see its queue. + + + ); +} + +export function NoWaitpointTokens() { + return ( + + Waitpoint docs + + } + > + + Waitpoint tokens pause task runs until you complete the token. They're commonly used for + approval workflows and other scenarios where you need to wait for external confirmation, + such as human-in-the-loop processes. + + + ); +} + +export function BranchesNoBranchableEnvironment({ showSelfServe }: { showSelfServe: boolean }) { + const { isManagedCloud } = useFeatures(); + const organization = useOrganization(); + + if (!isManagedCloud) { + return ( + + + To add branches you need to have a RuntimeEnvironment where{" "} + isBranchableEnvironment is true. We recommend creating a + dedicated one using the "PREVIEW" type. + + + ); + } + + return ( + + Upgrade + + ) : ( + Request more} + defaultValue="enterprise" + /> + ) + } + > + + Preview branches in Trigger.dev create isolated environments for testing new features before + production. + + + You must be on to access preview branches. Read our{" "} + upgrade to v4 guide to learn more. + + + ); +} + +export function BranchesNoBranches({ + env, + limits, + canUpgrade, + showSelfServe, +}: { + env: BranchableEnvironmentToken; + limits: { used: number; limit: number }; + canUpgrade: boolean; + showSelfServe: boolean; +}) { + const organization = useOrganization(); + + const envTextClassName = env === "preview" ? "text-preview" : "text-dev"; + const branchesLabel = env === "preview" ? "preview branches" : "dev branches"; + + if (limits.used >= limits.limit) { + return ( + + Upgrade + + ) : ( + + Request more + + } + defaultValue={showSelfServe ? "help" : "enterprise"} + /> + ) + } + > + + You've reached the limit ({limits.used}/{limits.limit}) of branches for your plan. Upgrade + to get branches. + + + ); + } + + return ( + + New branch + + } + env={env} + /> + } + > + + Branches are a way to test new features in isolation before merging them into the main + environment. + + + Branches are only available when using or above. Read our{" "} + v4 upgrade guide to learn more. + + + ); +} + +export function SwitcherPanel({ title = "Switch to a deployed environment" }: { title?: string }) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( +
+ + {title} + + +
+ ); +} + +export function BulkActionsNone() { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + return ( +
+
+ Create a bulk action +
+ + New bulk action + +
+
+ + + Select runs from the runs page individually. +
+ Select runs individually +
+
+
+
+ + OR + +
+
+ + + + Use the filter menu on the runs page to select just the runs you want to bulk action. + +
+ Select runs using filters +
+
+ + + Click the โ€œBulk actionsโ€ button in the top right of the runs page. +
+ Open the bulk action panel +
+
+
+ ); +} + +function DeploymentOnboardingSteps() { + const environment = useEnvironment(); + const organization = useOrganization(); + const project = useProject(); + + return ( + +
+
+ + + Deploy your tasks to {environmentFullTitle(environment)} + +
+
+ + } + content="Deploy docs" + /> + + } + content="Troubleshooting docs" + /> + +
+
+ + + + GitHub + + + Manual + + + GitHub Actions + + + + + + + Deploy automatically with every push. Read the{" "} + full guide. + +
+ +
+
+
+ + + + + This will deploy your tasks to the {environmentFullTitle(environment)} environment. + Read the full guide. + + + + + + + + + Read the GitHub Actions guide to + get started. + + + +
+ + + + This page will automatically refresh when your tasks are deployed. + +
+ ); +} + +export function PromptsNone() { + return ( + + Prompts docs + + } + > + + Managed prompts let you define AI prompts in code with typesafe variables, then edit and + version them from the dashboard without redeploying. + + + Add a prompt to your project using prompts.define() + : + + + + Deploy your project and your prompts will appear here with version history and a live + editor. + + + ); +} diff --git a/apps/webapp/app/components/BulkActionFilterSummary.tsx b/apps/webapp/app/components/BulkActionFilterSummary.tsx new file mode 100644 index 00000000000..3c6871a23cd --- /dev/null +++ b/apps/webapp/app/components/BulkActionFilterSummary.tsx @@ -0,0 +1,310 @@ +import { z } from "zod"; +import { + filterIcon, + filterTitle, + type TaskRunListSearchFilterKey, + type TaskRunListSearchFilters, +} from "./runs/v3/RunFilters"; +import { Paragraph } from "./primitives/Paragraph"; +import simplur from "simplur"; +import { appliedSummary, dateFromString, timeFilterRenderValues } from "./runs/v3/SharedFilters"; +import { formatNumber } from "~/utils/numberFormatter"; +import { SpinnerWhite } from "./primitives/Spinner"; +import { ArrowPathIcon, CheckIcon, XCircleIcon } from "@heroicons/react/20/solid"; +import { XCircleIcon as XCircleIconOutline } from "@heroicons/react/24/outline"; +import assertNever from "assert-never"; +import { AppliedFilter } from "./primitives/AppliedFilter"; +import { runStatusTitle } from "./runs/v3/TaskRunStatus"; +import type { TaskRunStatus } from "@trigger.dev/database"; + +export const BulkActionMode = z.union([z.literal("selected"), z.literal("filter")]); +export type BulkActionMode = z.infer; +export const BulkActionAction = z.union([z.literal("cancel"), z.literal("replay")]); +export type BulkActionAction = z.infer; + +export function BulkActionFilterSummary({ + selected, + final = false, + mode, + action, + filters, +}: { + selected?: number; + final?: boolean; + mode: BulkActionMode; + action: BulkActionAction; + filters: TaskRunListSearchFilters; +}) { + switch (mode) { + case "selected": + return ( + + You {!final ? "have " : " "}individually selected {simplur`${selected} run[|s]`} to be{" "} + . + + ); + case "filter": { + const { + label, + valueLabel, + rangeType: _rangeType, + } = timeFilterRenderValues({ + from: filters.from ? dateFromString(`${filters.from}`) : undefined, + to: filters.to ? dateFromString(`${filters.to}`) : undefined, + period: filters.period, + }); + + return ( +
+ + You {!final ? "have " : " "}selected{" "} + + {final ? selected : } + {" "} + runs to be using these filters: + +
+ + {Object.entries(filters).map(([key, value]) => { + if (!value && key !== "period") { + return null; + } + + const typedKey = key as TaskRunListSearchFilterKey; + + switch (typedKey) { + case "cursor": + case "direction": + case "environments": + //We need to handle time differently because we have a default + case "period": + case "from": + case "to": { + return null; + } + case "tasks": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "versions": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "statuses": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + runStatusTitle(v as TaskRunStatus)))} + removable={false} + /> + ); + } + case "tags": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "bulkId": { + return ( + + ); + } + case "rootOnly": { + return ( + + ) : ( + + ) + } + removable={false} + /> + ); + } + case "runId": { + return ( + + ); + } + case "batchId": { + return ( + + ); + } + case "scheduleId": { + return ( + + ); + } + case "queues": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + v.replace("task/", "")))} + removable={false} + /> + ); + } + case "regions": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "machines": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + case "errorId": { + return ( + + ); + } + case "sources": { + const values = Array.isArray(value) ? value : [`${value}`]; + return ( + + ); + } + default: { + assertNever(typedKey); + } + } + })} +
+
+ ); + } + } +} + +function Action({ action }: { action: BulkActionAction }) { + switch (action) { + case "cancel": + return ( + + + Canceled + + ); + case "replay": + return ( + + + Replayed + + ); + } +} + +export function EstimatedCount({ count }: { count?: number }) { + if (typeof count === "number") { + return <>~{formatNumber(count)}; + } + + return ; +} diff --git a/apps/webapp/app/components/CloudProvider.tsx b/apps/webapp/app/components/CloudProvider.tsx new file mode 100644 index 00000000000..acf8cff5506 --- /dev/null +++ b/apps/webapp/app/components/CloudProvider.tsx @@ -0,0 +1,10 @@ +export function cloudProviderTitle(provider: "aws" | "digitalocean" | (string & {})) { + switch (provider) { + case "aws": + return "Amazon Web Services"; + case "digitalocean": + return "Digital Ocean"; + default: + return provider; + } +} diff --git a/apps/webapp/app/components/CopyText.tsx b/apps/webapp/app/components/CopyText.tsx deleted file mode 100644 index 5f3ac68aafc..00000000000 --- a/apps/webapp/app/components/CopyText.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { useCallback } from "react"; - -export type CopyTextProps = { - children?: React.ReactNode; - value: string; - className?: string; - onCopied?: () => void; -}; - -export function CopyText({ - children, - value, - className, - onCopied, -}: CopyTextProps) { - const onClick = useCallback(() => { - navigator.clipboard.writeText(value); - if (onCopied) { - onCopied(); - } - }, [value, onCopied]); - - return ( -
- {children} -
- ); -} diff --git a/apps/webapp/app/components/CopyTextButton.tsx b/apps/webapp/app/components/CopyTextButton.tsx deleted file mode 100644 index 95873bbd65f..00000000000 --- a/apps/webapp/app/components/CopyTextButton.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { ClipboardIcon } from "@heroicons/react/24/outline"; -import classNames from "classnames"; -import { useCallback, useState } from "react"; -import { CopyText } from "./CopyText"; - -const variantStyle = { - slate: - "bg-slate-800 text-white rounded px-2 py-1 transition hover:text-slate-700 hover:bg-slate-700 hover:bg-slate-700 hover:text-slate-100 active:bg-slate-800 active:text-slate-300 focus-visible:outline-slate-900", - blue: "text-sm bg-indigo-700 rounded px-3 py-2 transition text-white hover:bg-indigo-600 active:bg-indigo-800 active:text-indigo-100 focus-visible:outline-indigo-600", - darkTransparent: - "bg-black/10 text-slate-900 rounded px-2 py-1 transition hover:bg-blue-50 active:bg-blue-200 active:text-slate-600 focus-visible:outline-white", - lightTransparent: - "bg-white/10 text-white-900 rounded px-2 py-1 transition hover:bg-blue-50 active:bg-blue-200 active:text-slate-600 focus-visible:outline-white", - text: "text-sm text-slate-300 transition hover:text-slate-400", -}; - -export type CopyTextButtonProps = { - value: string; - className?: string; - variant?: "slate" | "blue" | "darkTransparent" | "lightTransparent" | "text"; -}; - -export function CopyTextButton({ - value, - className, - variant = "blue", -}: CopyTextButtonProps) { - const [copied, setCopied] = useState(false); - const onCopied = useCallback(() => { - setCopied(true); - setTimeout(() => { - setCopied(false); - }, 1500); - }, [setCopied]); - return ( - - {copied ? ( -
-

Copied!

-
- ) : ( -
- -

Copy

-
- )} -
- ); -} diff --git a/apps/webapp/app/components/CreateNewWorkflow.tsx b/apps/webapp/app/components/CreateNewWorkflow.tsx deleted file mode 100644 index f168bf75160..00000000000 --- a/apps/webapp/app/components/CreateNewWorkflow.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { Tab } from "@headlessui/react"; -import { ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline"; -import { Underlined, UnderlinedList } from "~/components/primitives/Tabs"; -import { useCurrentOrganization } from "~/hooks/useOrganizations"; -import CodeBlock from "./code/CodeBlock"; -import { PrimaryLink, SecondaryA } from "./primitives/Buttons"; -import { SubTitle } from "./primitives/text/SubTitle"; - -export function CreateNewWorkflow() { - const currentOrganization = useCurrentOrganization(); - if (currentOrganization === undefined) { - return <>; - } - return ( - <> - Create a new workflow -
- - Create a workflow - - - - Documentation - -
- - ); -} - -export function InstallPackages({ packages }: { packages: string }) { - return ( - - - npm - pnpm - yarn - - - - - - - - - - - - - - ); -} diff --git a/apps/webapp/app/components/DefinitionTooltip.tsx b/apps/webapp/app/components/DefinitionTooltip.tsx new file mode 100644 index 00000000000..13505e2d57f --- /dev/null +++ b/apps/webapp/app/components/DefinitionTooltip.tsx @@ -0,0 +1,35 @@ +import { Header3 } from "./primitives/Headers"; +import { Paragraph } from "./primitives/Paragraph"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./primitives/Tooltip"; + +export function DefinitionTip({ + content, + children, + title, + disableHoverableContent = true, +}: { + content: React.ReactNode; + children: React.ReactNode; + title: React.ReactNode; + disableHoverableContent?: boolean; +}) { + return ( + + + + + {children} + + + + {title} + {typeof content === "string" ? ( + {content} + ) : ( +
{content}
+ )} +
+
+
+ ); +} diff --git a/apps/webapp/app/components/DevPresence.tsx b/apps/webapp/app/components/DevPresence.tsx new file mode 100644 index 00000000000..27d96954758 --- /dev/null +++ b/apps/webapp/app/components/DevPresence.tsx @@ -0,0 +1,222 @@ +import { AnimatePresence, motion } from "framer-motion"; +import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react"; +import { + CheckingConnectionIcon, + ConnectedIcon, + DisconnectedIcon, +} from "~/assets/icons/ConnectionIcons"; +import { useEnvironment } from "~/hooks/useEnvironment"; +import { useEventSource } from "~/hooks/useEventSource"; +import { useOrganization } from "~/hooks/useOrganizations"; +import { useProject } from "~/hooks/useProject"; +import { docsPath } from "~/utils/pathBuilder"; +import connectedImage from "../assets/images/cli-connected.png"; +import disconnectedImage from "../assets/images/cli-disconnected.png"; +import { InlineCode } from "./code/InlineCode"; +import { Button } from "./primitives/Buttons"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "./primitives/Dialog"; +import { Paragraph } from "./primitives/Paragraph"; +import { TextLink } from "./primitives/TextLink"; +import { PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands"; + +// Define Context types +type DevPresenceContextType = { + isConnected: boolean | undefined; +}; + +// Create Context with default values +const DevPresenceContext = createContext({ + isConnected: undefined, +}); + +// Provider component with enabled prop +interface DevPresenceProviderProps { + children: ReactNode; + enabled?: boolean; +} + +export function DevPresenceProvider({ children, enabled = true }: DevPresenceProviderProps) { + const organization = useOrganization(); + const project = useProject(); + const environment = useEnvironment(); + + // Only subscribe to event source if enabled is true + const streamedEvents = useEventSource( + `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/presence`, + { + event: "presence", + disabled: !enabled, + } + ); + + const [isConnected, setIsConnected] = useState(undefined); + + useEffect(() => { + // If disabled or no events + if (!enabled || streamedEvents === null) { + setIsConnected(undefined); + return; + } + + try { + const data = JSON.parse(streamedEvents) as any; + if ("isConnected" in data && data.isConnected) { + try { + setIsConnected(true); + } catch (error) { + console.log("DevPresence: Failed to parse lastSeen timestamp", { error }); + setIsConnected(false); + } + } else { + setIsConnected(false); + } + } catch (error) { + console.log("DevPresence: Failed to parse presence message", { error }); + setIsConnected(false); + } + }, [streamedEvents, enabled]); + + // Calculate isConnected and memoize the context value + const contextValue = useMemo(() => { + return { isConnected }; + }, [isConnected, enabled]); + + return {children}; +} + +// Custom hook to use the context +export function useDevPresence() { + const context = useContext(DevPresenceContext); + if (context === undefined) { + throw new Error("useDevPresence must be used within a DevPresenceProvider"); + } + return context; +} + +/** + * We need this for the legacy v1 engine, where we show the banner after a delay if there are no events. + */ +export function useCrossEngineIsConnected({ + isCompleted, + logCount, +}: { + isCompleted: boolean; + logCount: number; +}) { + const project = useProject(); + const environment = useEnvironment(); + const { isConnected } = useDevPresence(); + const [crossEngineIsConnected, setCrossEngineIsConnected] = useState( + undefined + ); + + useEffect(() => { + if (project.engine === "V2") { + setCrossEngineIsConnected(isConnected); + return; + } + + if (project.engine === "V1") { + if (isCompleted) { + setCrossEngineIsConnected(true); + return; + } + + if (logCount <= 1) { + const timer = setTimeout(() => { + setCrossEngineIsConnected(false); + }, 5000); + return () => clearTimeout(timer); + } else { + setCrossEngineIsConnected(true); + } + } + }, [environment.type, project.engine, logCount, isConnected, isCompleted]); + + return crossEngineIsConnected; +} + +export function ConnectionIcon({ isConnected }: { isConnected: boolean | undefined }) { + if (isConnected === undefined) { + return ; + } + return isConnected ? ( + + ) : ( + + ); +} + +export function DevPresencePanel({ isConnected }: { isConnected: boolean | undefined }) { + return ( + + + {isConnected === undefined + ? "Checking connection..." + : isConnected + ? "Your dev server is connected" + : "Your dev server is not connected"} + +
+
+ {isConnected + + {isConnected === undefined + ? "Checking connection..." + : isConnected + ? "Your local dev server is connected to Trigger.dev" + : "Your local dev server is not connected to Trigger.dev"} + +
+ {isConnected ? null : ( +
+ + + + + Run this CLI dev command to connect to + the Trigger.dev servers to start developing locally. Keep it running while you develop + to stay connected. Learn more in the{" "} + CLI docs. + +
+ )} +
+
+ ); +} + +export function DevDisconnectedBanner({ isConnected }: { isConnected: boolean | undefined }) { + return ( + + + {isConnected === false && ( + + + + + + )} + + + + ); +} diff --git a/apps/webapp/app/components/ErrorDisplay.tsx b/apps/webapp/app/components/ErrorDisplay.tsx new file mode 100644 index 00000000000..374f427c504 --- /dev/null +++ b/apps/webapp/app/components/ErrorDisplay.tsx @@ -0,0 +1,79 @@ +import { HomeIcon } from "@heroicons/react/20/solid"; +import { isRouteErrorResponse, useRouteError } from "@remix-run/react"; +import { friendlyErrorDisplay } from "~/utils/httpErrors"; +import { permissionDeniedMessage } from "~/utils/permissionDenied"; +import { LinkButton } from "./primitives/Buttons"; +import { Header1 } from "./primitives/Headers"; +import { Paragraph } from "./primitives/Paragraph"; +import { PermissionDenied } from "./PermissionDenied"; +import { TriggerRotatingLogo } from "./TriggerRotatingLogo"; +import { type ReactNode } from "react"; + +type ErrorDisplayOptions = { + button?: { + title: string; + to: string; + }; +}; + +export function RouteErrorDisplay(options?: ErrorDisplayOptions) { + const error = useRouteError(); + + // A failed `authorization` check (or `throwPermissionDenied`) throws a 403 + // that bubbles to the nearest route ErrorBoundary. Every layout boundary + // renders through here, so handling it once means a gated route only has to + // declare `authorization` to get the permission panel: no per-route boundary. + const permission = isRouteErrorResponse(error) ? permissionDeniedMessage(error.data) : null; + if (permission) { + return ( +
+
+ +
+
+ ); + } + + return ( + <> + {isRouteErrorResponse(error) ? ( + + ) : error instanceof Error ? ( + + ) : ( + + )} + + ); +} + +type DisplayOptionsProps = { + title: string; + message?: ReactNode; +} & ErrorDisplayOptions; + +export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) { + return ( +
+
+ {title} + {message && {message}} + + {button ? button.title : "Go to homepage"} + +
+ +
+ ); +} diff --git a/apps/webapp/app/components/FeatureBadges.tsx b/apps/webapp/app/components/FeatureBadges.tsx new file mode 100644 index 00000000000..1364d0f76c7 --- /dev/null +++ b/apps/webapp/app/components/FeatureBadges.tsx @@ -0,0 +1,66 @@ +import { cn } from "~/utils/cn"; +import { Badge } from "./primitives/Badge"; +import { SimpleTooltip } from "./primitives/Tooltip"; + +export function AlphaBadge({ + inline = false, + className, +}: { + inline?: boolean; + className?: string; +}) { + return ( + + Alpha + + } + content="This feature is in Alpha" + disableHoverableContent + /> + ); +} + +export function AlphaTitle({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + ); +} + +export function BetaBadge({ inline = false, className }: { inline?: boolean; className?: string }) { + return ( + + Beta + + } + content="This feature is in Beta" + disableHoverableContent + /> + ); +} + +export function BetaTitle({ children }: { children: React.ReactNode }) { + return ( + <> + {children} + + + ); +} + +export function NewBadge({ inline = false, className }: { inline?: boolean; className?: string }) { + return ( + + New + + ); +} diff --git a/apps/webapp/app/components/Feedback.tsx b/apps/webapp/app/components/Feedback.tsx new file mode 100644 index 00000000000..ebd61180de5 --- /dev/null +++ b/apps/webapp/app/components/Feedback.tsx @@ -0,0 +1,206 @@ +import { + getFormProps, + getSelectProps, + getInputProps, + getTextareaProps, + useForm, +} from "@conform-to/react"; +import { parseWithZod } from "@conform-to/zod"; +import { InformationCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid"; +import { EnvelopeIcon, ShieldCheckIcon } from "@heroicons/react/24/solid"; +import { Form, useActionData, useLocation, useNavigation, useSearchParams } from "@remix-run/react"; +import { type ReactNode, useEffect, useState } from "react"; +import { type FeedbackType, feedbackTypes, schema } from "~/routes/resources.feedback"; +import { Button } from "./primitives/Buttons"; +import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "./primitives/Dialog"; +import { Fieldset } from "./primitives/Fieldset"; +import { FormButtons } from "./primitives/FormButtons"; +import { FormError } from "./primitives/FormError"; +import { Icon } from "./primitives/Icon"; +import { InfoPanel } from "./primitives/InfoPanel"; +import { InputGroup } from "./primitives/InputGroup"; +import { Label } from "./primitives/Label"; +import { Paragraph } from "./primitives/Paragraph"; +import { Select, SelectItem } from "./primitives/Select"; +import { TextArea } from "./primitives/TextArea"; +import { TextLink } from "./primitives/TextLink"; +import { DialogClose } from "@radix-ui/react-dialog"; + +type FeedbackProps = { + button: ReactNode; + defaultValue?: FeedbackType; + onOpenChange?: (open: boolean) => void; +}; + +export function Feedback({ button, defaultValue = "bug", onOpenChange }: FeedbackProps) { + const [open, setOpen] = useState(false); + const [searchParams, setSearchParams] = useSearchParams(); + const location = useLocation(); + const lastSubmission = useActionData(); + const navigation = useNavigation(); + const [type, setType] = useState(defaultValue); + + const [form, fields] = useForm({ + id: "accept-invite", + lastResult: lastSubmission as any, + onValidate({ formData }) { + return parseWithZod(formData, { schema }); + }, + shouldRevalidate: "onInput", + }); + + useEffect(() => { + if ( + navigation.formAction === "/resources/feedback" && + navigation.state === "loading" && + Object.keys(form.allErrors).length === 0 + ) { + setOpen(false); + } + }, [navigation.formAction, navigation.state, form.allErrors]); + + // Handle URL param functionality + useEffect(() => { + const open = searchParams.get("feedbackPanel"); + if (open) { + setType(open as FeedbackType); + setOpen(true); + // Clone instead of mutating in place + const next = new URLSearchParams(searchParams); + next.delete("feedbackPanel"); + setSearchParams(next); + } + }, [searchParams]); + + const handleOpenChange = (value: boolean) => { + setOpen(value); + onOpenChange?.(value); + }; + + return ( + + {button} + + Contact us +
+
+ + + How can we help? We read every message and will respond as quickly as we can. + +
+ {!( + type === "feature" || + type === "help" || + type === "concurrency" || + type === "hipaa" + ) &&
} +
+
+ + + {type === "feature" && ( + + + All our feature requests are public and voted on by the community. The best + way to submit your feature request is to{" "} + + post it to our feedback forum + + . + + + )} + {type === "help" && ( + + + The quickest way to get answers from the Trigger.dev team and community is to{" "} + ask in our Discord. + + + )} + {type === "concurrency" && ( + + + How much extra concurrency do you need? You can add bundles of 50 for + $50/month each. To help us advise you, please let us know what your tasks do, + your typical run volume, and if your workload is spiky (many runs at once). + + + )} + {type === "hipaa" && ( + + + We offer a signed Business Associate Agreement (BAA) as a paid add-on on any + paid plan. To help us get back to you quickly, please include your company + name, and a brief description of the PHI workload you plan to run. + + + )} + + {fields.feedbackType.errors} + + + +