Skip to content

Latest commit

 

History

History
245 lines (187 loc) · 16.8 KB

AGENTS.md

File metadata and controls

245 lines (187 loc) · 16.8 KB

Sim App Scope

These rules apply to files under apps/sim/ in addition to the repository root AGENTS.md.

Architecture

Core Principles

  1. Single Responsibility: Each component, hook, store has one clear purpose
  2. Composition Over Complexity: Break down complex logic into smaller pieces
  3. Type Safety First: TypeScript interfaces for all props, state, return types
  4. Predictable State: Zustand for global state, useState for UI-only concerns

Application Operation Boundary

  • Every protected read, write, canonical resource lookup, or authorization-sensitive reference resolution enters through an authorized application use case.
  • Define one stable semantic operation with its role, workspace-key policy, principal kinds, and delegated services. Internal, v2, Copilot, and trusted-tool adapters call the same use case when domain behavior is the same.
  • Surface adapters authenticate and build a Principal, rate-limit, parse, map, and present. They do not query protected data, authorize resources, implement business transactions, or record semantic audit.
  • Application use cases canonicalize scope, authorize current access, execute managers/repositories, project semantic audit, and trigger shared domain effects. Application code stays surface-neutral and never imports app/api/**, next/server, route contracts/presenters, or Copilot handlers.
  • Copilot uses createCopilotApplicationAdapter; it does not own separate protected business logic. Compound protected mutations require a top-level semantic application operation.
  • Never substitute billing attribution, an uploader, creator, or API-key owner for the acting principal. Fail fast when the identity or policy cannot be represented.
  • Use the migrate-application-operation skill for new and migrated protected endpoints, tool commands, and resource methods.

Root-Level Structure

apps/
├── sim/                 # this app (Next.js: UI + API routes + workflow editor)
│   ├── app/             # Next.js app router (pages, API routes)
│   ├── blocks/          # Block definitions and registry
│   ├── components/      # Shared UI (emcn/, ui/)
│   ├── executor/        # Workflow execution engine
│   ├── hooks/           # Shared hooks (queries/, selectors/)
│   ├── lib/             # App-wide utilities
│   ├── providers/       # LLM provider integrations
│   ├── stores/          # Zustand stores
│   ├── tools/           # Tool definitions
│   └── triggers/        # Trigger definitions
└── realtime/            # Bun Socket.IO server (collaborative canvas)

packages/                # @sim/* — audit, auth, db, logger, realtime-protocol,
                         # security, tsconfig, utils, platform-authz,
                         # workflow-persistence, workflow-types

The Socket.IO collaborative-canvas server lives in a separate workspace at apps/realtime/. It shares DB + auth with apps/sim via the @sim/* packages. apps/* → packages/* only — packages never import from apps/*. Do not add imports from @/lib/webhooks/providers/*, @/executor/*, @/blocks/*, or @/tools/* to any package consumed by apps/realtime — those heavyweight registries stay in this app. apps/realtime calls back into this app only over internal HTTP with INTERNAL_API_SECRET. CI enforces these boundaries via scripts/check-monorepo-boundaries.ts and scripts/check-realtime-prune-graph.ts.

Feature Organization

Features live under app/workspace/[workspaceId]/:

feature/
├── components/          # Feature components
├── hooks/               # Feature-scoped hooks
├── utils/               # Feature-scoped utilities (2+ consumers)
├── feature.tsx          # Main component
└── page.tsx             # Next.js page entry

Naming Conventions

  • Components: PascalCase (WorkflowList)
  • Hooks: use prefix (useWorkflowOperations)
  • Files: kebab-case (workflow-list.tsx)
  • Stores: stores/feature/store.ts
  • Constants: SCREAMING_SNAKE_CASE
  • Interfaces: PascalCase with suffix (WorkflowListProps)

Imports And Types

  • Always use absolute imports from @/...; do not add relative imports.
  • Use barrel exports only when a folder has 3+ exports; do not re-export through non-barrel files.
  • Use import type for type-only imports.
  • Do not use any; prefer precise types or unknown with guards.

Components And Styling

  • Use 'use client' only when hooks or browser-only APIs are required.
  • Define a props interface for every component.
  • Extract constants with as const where appropriate.
  • Use Tailwind classes and cn() for conditional classes; avoid inline styles unless CSS variables are the intended mechanism.
  • Keep styling local to the component; do not modify global styles for feature work.

API Contracts

Boundary HTTP request and response shapes for all routes under apps/sim/app/api/** live in apps/sim/lib/api/contracts/** (one file per resource family). Routes never define route-local boundary Zod schemas, and clients never define ad-hoc wire types — both sides consume the same contract.

  • Each contract is built with defineRouteContract({ method, path, params?, query?, body?, headers?, response: { mode: 'json', schema } }) from @/lib/api/contracts.
  • Contracts export named schemas AND named TypeScript type aliases (e.g., export type CreateFolderBody = z.input<typeof createFolderBodySchema>). Clients import the named aliases — never z.input<...> / z.output<...> in hooks.
  • Shared identifier schemas live in apps/sim/lib/api/contracts/primitives.ts (e.g., workspaceIdSchema, workflowIdSchema).
  • Audit script: bun run check:api-validation enforces boundary policy and prints ratchet metrics for route Zod imports, route-local schema constructors, route ZodError references, client hook Zod imports, and related counters. It must pass on PRs. bun run check:api-validation:strict is the strict CI gate and additionally fails on annotations with empty reasons.
  • Domain validators that are not HTTP boundaries — tools, blocks, triggers, connectors, realtime handlers, and internal helpers — may still use Zod directly. The contract rule is boundary-only.

Boundary annotations

A small number of legitimate exceptions to the boundary rules are tolerated when annotated. The audit script recognizes four annotation forms:

  • // boundary-raw-fetch: <reason> — placed on the line directly above a raw fetch( call inside apps/sim/hooks/queries/**, apps/sim/hooks/selectors/**, or any other client/UI source under apps/sim/** that targets a same-origin /api/... URL. Use only for documented exceptions: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests.
  • // double-cast-allowed: <reason> — placed on the line directly above an as unknown as X cast outside test files.
  • // boundary-raw-json: <reason> — placed on the line directly above a raw await request.json() / await req.json() read (or the multi-line await request.clone().json() shim variant) in a route handler. Use only when the body is a JSON-RPC envelope, a tolerant .catch(() => ({})) parse, or otherwise cannot go through parseRequest.
  • // untyped-response: <reason> — placed on the line directly above a schema: z.unknown() / schema: z.object({}).passthrough() / schema: z.record(z.string(), z.unknown()) response declaration (or a simple alias to one of those) in a contract file. Use only when the response body is genuinely opaque (user-supplied data, third-party passthrough).

Placement rule: the annotation must immediately precede the call or cast. Up to three non-empty preceding comment lines are tolerated, so additional context comments above the annotation are fine. The reason must be non-empty after trimming — annotations with empty reasons fail strict mode (annotationsMissingReason).

Whole-file allowlists for routes (legitimate non-boundary or auth-handled routes that legitimately import Zod for non-boundary reasons) go through INDIRECT_ZOD_ROUTES in scripts/check-api-validation-contracts.ts, not per-line annotations.

Examples:

// boundary-raw-fetch: streaming SSE chunks must be processed as they arrive
const response = await fetch(`/api/copilot/chat/stream?chatId=${chatId}`, { signal })
// double-cast-allowed: legacy provider type lacks the discriminator field we need
const provider = config as unknown as LegacyProvider
// boundary-raw-json: shim pre-validates the mothership envelope before delegating to the copilot handler that consumes the body
const body = await request
  .clone()
  .json()
  .catch(() => undefined)
// untyped-response: forwards firecrawl /v2/parse response unchanged for downstream tool consumers
output: z.unknown(),

API Route Pattern

Every route method must run inside withRouteHandler. Ordinary internal and v2 JSON/binary routes use defineInternalJsonRoute, defineV2JsonRoute, or the matching binary/stream builder. These builders already apply withRouteHandler; never wrap them again. Use raw withRouteHandler only for explicit protocol or lifecycle exceptions such as streaming, multipart control, large-body admission, OAuth, or public execution.

Routes never import { z } from 'zod' and never define route-local boundary schemas. Declarative builders consume contracts and own authentication, admission, parsing, use-case execution, response validation, and error projection. A raw special route consumes the same contracts and validates with canonical helpers from @/lib/api/server, after authentication and cheap admission:

  • parseRequest(contract, request, context, options?) — fully contract-bound routes; parses params, query, body, and headers in one call. Pass {} for context on routes without route params, or the route's context argument when route params exist. Returns a discriminated union; check parsed.success and return parsed.response on failure.
  • validationErrorResponse(error) and getValidationErrorMessage(error, fallback) — produce 400 responses from a ZodError.
  • validationErrorResponseFromError(error) — when handling unknown caught errors that may or may not be a ZodError.
  • isZodError(error) — type guard. Routes never use instanceof z.ZodError.

Ordinary authorized JSON route

export const PATCH = defineInternalJsonRoute({
  contract: renameWidgetContract,
  auth: internalSessionAuth,
  operation: widgetOperations.rename,
  rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal behavior' }),
  errorPolicy: internalWidgetErrorPolicy,
  mapInput: ({ params, body }) => ({
    widgetId: params.widgetId,
    assertedWorkspaceId: params.workspaceId,
    name: body.name,
  }),
  useCase: renameWidget,
  present: ({ widget }) => ({ success: true, widget }),
})

The contract, operation, and use case must agree at definition time. Authentication and request-rate admission happen before parsing; canonical loading and authorization happen in the application use case. The presenter returns only the surface success body.

Routes under apps/sim/app/api/v1/** use the shared middleware in apps/sim/app/api/v1/middleware.ts for auth, rate-limit, and workspace access. Compose contract validation inside that middleware — never reimplement auth/rate-limit per-route.

Never export a bare async function GET/POST/.... Export the result of a shared builder or, for a documented special route, withRouteHandler(...).

Adding a new boundary feature end-to-end

When adding a new route + client surface, follow this order. Each step has one place it lives.

  1. Author the contract first in apps/sim/lib/api/contracts/<domain>.ts (or a subdirectory for large domains: knowledge/, selectors/, tools/). Define one schema per request slice (params, query, body, headers) and one for the response, then wrap with defineRouteContract. Export named type aliases (z.input for inputs, z.output for outputs).
  2. Define the semantic operation and application use case under apps/sim/lib/<domain>/application/. The use case owns canonical loading, asserted-scope checks, current authorization, business behavior, semantic audit, and shared domain effects.
  3. Implement the route adapter in apps/sim/app/api/<path>/route.ts with the appropriate shared builder. Declare auth, operation, rate policy, error policy, input mapping, use case, and presenter. Auth always runs before parsing. Use raw withRouteHandler only for an explicit special route, and keep protected work in application use cases.
  4. Add the React Query hook in apps/sim/hooks/queries/<domain>.ts. Use requestJson(contract, input) for the call. Build a hierarchical query-key factory (all → lists() → list(workspaceId) → details() → detail(id)) so invalidations can target prefixes.
  5. Use the hook in the component. The mutation's data and error are fully typed from the contract; surface error.message (already extracted from the response body's error or message field by requestJson).

Schema review checklist (read the contract diff like a DB migration)

LLMs will write contracts that compile but are sloppy. The human reviewer should optimize attention on:

  • required vs optional vs nullable is correct. optional() allows omission; nullable() allows null; chaining both creates a tri-state that's almost never what you want.
  • Response schema matches the route's actual JSON output. The most common drift bug — route emits a field the schema doesn't declare, or omits a required field. Walk every NextResponse.json(...) callsite against the schema.
  • Error messages are descriptive. 'fileName cannot be empty' beats 'Required'. Use the second arg of min(1, '...'), nonempty('...'), etc. For cross-field refines, use superRefine with a path and a message that names the failing field.
  • Bounds are set on arrays (.min(1), .max(N)), strings (.min(1).max(N) for IDs/names), and numbers (.min().max() for limits/sizes).
  • z.unknown() is a smell unless the data is genuinely arbitrary (provider passthrough, user-defined tool result, JSON-RPC envelope). When kept, must be annotated // untyped-response: <specific reason> in a schema: slot.
  • Discriminated unions over plain unions when the wire has a discriminant field — gives clients exhaustive narrowing.

CI (bun run check:api-validation:strict) catches structural violations (Zod imports in routes, raw request.json(), double casts, missing annotations). It does not catch these schema-quality judgments — that's the human's job in PR review.

React Query Client Boundary

Hooks in apps/sim/hooks/queries/** consume contracts the same way routes do. Every same-origin JSON call must go through requestJson(contract, ...) from @/lib/api/client/request instead of raw fetch:

  • Hooks import named type aliases from @/lib/api/contracts/**. Never write z.input<...> / z.output<...> in hooks, and never import { z } from 'zod' in client code.
  • requestJson parses params, query, body, and headers against the contract on the way out and validates the JSON response on the way back. Hooks always forward signal for cancellation.
  • Documented exceptions for raw fetch: streaming responses, binary downloads, multipart uploads, signed-URL flows, OAuth redirects, and external-origin requests. Mark each raw fetch with a TSDoc comment explaining which exception applies.
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { requestJson } from '@/lib/api/client/request'
import { listEntitiesContract, type EntityList } from '@/lib/api/contracts/entities'

async function fetchEntities(workspaceId: string, signal?: AbortSignal): Promise<EntityList> {
  const data = await requestJson(listEntitiesContract, {
    query: { workspaceId },
    signal,
  })
  return data.entities
}

export function useEntityList(workspaceId?: string) {
  return useQuery({
    queryKey: entityKeys.list(workspaceId),
    queryFn: ({ signal }) => fetchEntities(workspaceId as string, signal),
    enabled: Boolean(workspaceId),
    staleTime: 60 * 1000,
    placeholderData: keepPreviousData,
  })
}

Testing

  • Use Vitest.
  • Prefer @vitest-environment node unless DOM APIs are required.
  • Use vi.hoisted() + vi.mock() + static imports; do not use vi.resetModules() + vi.doMock() + dynamic imports except for true module-scope singletons.
  • Do not use vi.importActual().
  • Prefer mocks and factories from @sim/testing.

Utils Rules

  • Never create utils.ts for single consumer - inline it
  • Create utils.ts when 2+ files need the same helper
  • Check existing sources before duplicating (lib/ has many utilities)
  • Location: lib/ (app-wide) → feature/utils/ (feature-scoped) → inline (single-use)