Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion brain/knowledge/engineering/ci-pr-review-hygiene.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions brain/knowledge/engineering/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ The **Activepieces engineering brain**: how the system works, and *why* it was b
- **API & Endpoints** — route conventions and the security contract
- **Server Module Anatomy** — the six files of a server module (entity → migration → repo → service → controller → module), and the manual registration steps nothing auto-discovers
- **Web Feature Anatomy** — the frontend feature folder, its barrel, route guards, and how a failed primary query reports itself in place
- **Web Navigation Shell** — the rail, the three sidebars and the layouts that choose between them, which primitives are shared, and why platform admin renders without the rail
- **Cloud Deployment Paths** — canary → prod, the `cloud-hotfix` override, and the breaking-migration gate that blocks both
- **Helm Chart** — the Kubernetes install we ship to self-hosters, its two competing paths for an `AP_*` variable, and the secrets it never creates
- **CI PR Review Hygiene** — draft-first Greptile review, the per-area PR size gate, and the workflow conventions reviewers keep re-litigating
Expand Down
63 changes: 59 additions & 4 deletions brain/knowledge/engineering/web-feature-anatomy.md

Large diffs are not rendered by default.

77 changes: 77 additions & 0 deletions brain/knowledge/engineering/web-navigation-shell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
icon: 🧱
---

# Web Navigation Shell

The frame the web app renders every route inside. Four components, and knowing which is shared matters
more than knowing what each looks like.

**Primary rail** (`components/primary-rail`) is the chat-first left rail that replaced the project
sidebar in PR #15005. Chat, Agents, Explore, Impact, plus the projects list and the credits meter.

**Dashboard sidebar** (`components/sidebar/dashboard`) is the older per-project nav the rail replaced
on most surfaces.

**Platform sidebar** (`components/sidebar/platform`) is the platform-admin nav, and it is the only one
of the three that understands paid gating. Its groups and items are a literal built inline on every
render, not a config.

**Layouts** (`components/platform-layout.tsx`, `components/project-layout`) pick which of the above a
route gets. `PlatformLayout` is 44 lines and mounts the platform sidebar with no rail beside it.

`ap-sidebar-item` and `ap-sidebar-group` are the shared primitives, used by the dashboard and platform
sidebars only. The rail imports neither.

## Gotchas

- **`workspace-shell/` does not exist, and anything still referring to it is stale.** PR #14330 reverted
the four merges that introduced it (#14298, #14303, #14304, #14306) five days after they landed,
deleting the folder along with `stage-context.tsx`, `stage-breadcrumb.tsx` and `chat-dock-context.tsx`,
and restoring `sidebar/dashboard`. That revert was 106 files and touched **zero** platform-admin files,
so it is not a precedent for admin-nav work; what it does show is why it was unrevertable in pieces,
since it bundled a navigation model change together with global search and the builder canvas. Keep a
navigation change scoped to one surface and it stays revertable on its own.
- **The rail and the platform admin nav are aligned by hand, not by shared code.** `primary-rail` imports
none of the sidebar primitives, so the two columns only look like one system because PR #15028 tuned
their widths and spacing to match. Any change to admin nav metrics needs re-checking against the rail,
and no type error or test will tell you it drifted.
- **Platform admin deliberately renders without the rail.** `PlatformLayout` mounts the platform sidebar
straight under `SidebarProvider`, because the rail beside it meant two left columns and a narrower admin
nav, and the admin nav already carries its own way back out. This is a documented departure from the
`design/workspace-redesign` direction, which did show the rail there. Entry in is the rail's platform
admin button; the way out is the "Back to app" link in the platform sidebar header, which resolves
through `determineDefaultRoute`.
- **A role check inside a platform admin page is dead code.** `PlatformLayout` calls `useIsPlatformAdmin()`
and redirects away when it fails, so every component below it already knows the viewer is a platform
admin. AI Center nonetheless wrapped itself in a guard keyed on `platformRole !== PlatformRole.ADMIN`,
which can never be true where it sits, and that guard was the only thing standing in for a plan gate on
that page: reading it, the page looks gated when nothing gates it. Same shape as the `isCommunity` checks
under `BillingPageShell`. Before adding a guard to an admin page, check whether the layout above already
decided it.

- **`locked` is a platform-only concept living in a shared primitive.** `ap-sidebar-item` renders the lock
icon, but the dashboard sidebar passes `locked` zero times and the platform sidebar passes it fifteen.
So a change to lock or badge rendering edits a file two callers share to alter a path one caller uses.
Worth rendering platform-specific item chrome in the platform sidebar instead, rather than growing the
shared primitive for one consumer.
- **`/platform` redirects without `replace`, so Back lands you right back on it.** Every other admin
redirect (`/platform/setup`, `/platform/security`, `/platform/infrastructure`, `/platform/setup/branding`,
`/platform/setup/ai-capabilities`) passes `replace` to `<Navigate>`, but the one on `/platform` does not,
so it pushes a history entry and the browser Back button re-enters the redirect instead of leaving the
admin. Anyone who opens platform admin from a link to `/platform` rather than a specific page is stuck
going forward. Preserved deliberately while the routes were being derived from a registry, so fixing it
is a one-word change wherever that redirect is declared.

- **Four platform admin pages already carry their own tabs, so they cannot become tabs of something else without nesting two deep.** `setup/pieces` (Pieces, Piece sets, plus a `piece-sets/:id` child route), `infra/workers` (Health, Worker groups), `infra/health` (System, Runs, Queue) and `setup/mcp` (Connection, Tools). Any plan that groups admin pages under a parent has to answer this for each of them, and the answer decides the item count more than the grouping does: leaving all four as their own nav items lands around seventeen, folding them in lands around thirteen but puts a second tab row inside a tab. Count `TabsTrigger` occurrences per page before promising a number, because the mockups that drive this work are drawn from the nav and cannot see the second level.

- **The active item is a bare prefix match with no query-string awareness.** `pathname.startsWith(item.to)`,
plus an optional `isActive` override that the platform sidebar never passes. Any nav that needs to
distinguish tabs of one page has to pass that override, because `?tab=` is invisible to the default.

## Key files

- `packages/web/src/app/components/primary-rail`: the chat-first rail
- `packages/web/src/app/components/sidebar`: `ap-sidebar-item` / `ap-sidebar-group` primitives, plus the `dashboard`, `platform` and `project` navs
- `packages/web/src/app/components/platform-layout.tsx`: `PlatformLayout`, the admin frame and its admin-role check
- `packages/web/src/lib/route-utils.ts`: `determineDefaultRoute`, where "Back to app" goes
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,14 @@ Billing and entitlements are powered by [Autumn](https://useautumn.com). Each pl
- The daily report's directory, service, and telemetry symbols are named `license-key-usage-report`, but its **persisted system-job name is still `billing-usage-report`** (`BILLING_USAGE_REPORT` in `system-jobs/common.ts`), and its emitted event names are unchanged too. Both strings are keys that outlive a deploy — the job name identifies the already-scheduled row, the event names identify history already captured — so renaming either orphans what is out there instead of renaming it. They read as leftovers of the rename; they are not.
- Never read entitlements inline from Autumn on a request path — always the `platform_plan` projection + Redis caches (`isBillingEnforced` is a plain Redis read defaulting to `false`, i.e. fail open).
- Active flows are unlimited in the new plans (projected `null`); `checkActiveFlowsExceededLimit` still runs on flow enable/publish but only binds when a limit is set.
- **`billedTeamProjectsLimit: 0` hides the create-project control rather than crowning it.** The primary rail's `RailPinnedProjects` renders its create button only when the limit is non-zero, so a plan with no team projects at all shows no button in the rail — only the platform admin table keeps a crowned one. Testing a crowned rail control therefore needs a limit above zero that is fully consumed, not a limit of zero. `null` is unlimited, `0` is none, and the two read alike in a plan row.
- **`openDialog()` from `useManagePlanDialogStore` is a silent no-op on Community, so any upgrade button wired to it is dead there.** `<ManagePlanDialog />` is mounted behind `edition !== ApEdition.COMMUNITY` in all three layouts (`platform-layout`, `project-layout`, `builder-layout`), while the store is a plain Zustand flag that any caller can flip with nothing listening, so the click closes what it was in and shows nothing. Every upgrade surface therefore has to branch on `isCommunity` itself and offer the sales route instead. `FeatureSample`, `FeatureTeaser` and `useTeamProjectLimitGuard` all do; the last of those did not until the team-project limit dialog was fixed, having shipped a dead `Explore plans` on self-hosted installs since well before the platform admin redesign. Check the branch exists whenever you add one.
- **`RequestTrial` lives in `features/billing/components/`, not `app/components/`.** Anything under `features/*` that needs the sales call to action - the plan guards and the upgrade dialog - cannot reach into `app/`, because the `import/no-restricted-paths` zone is one-directional. Keeping it in billing lets both layers use it, since every `app/` caller is importing downward. It exports `FeatureKey`, the union the `activepieces.com/sales` page reads from the query string, and every `PLATFORM_FEATURES` entry carries one so a gated surface can hand over the right key.
- Initial plan by edition: CE/EE → `OPEN_SOURCE_PLAN`, Cloud → `AUTUMN_FREE_PLAN`. CE and `TESTING` environments skip enrollment/sync entirely.
- **Running billing locally means pointing a local backend at a *console*, not standing up Autumn.** Three gates must all pass or the provider is the empty no-op: `AP_EDITION` must be `cloud` or `ee` (`app.ts` sets `autumnBillingProvider` only there; Community returns `{ total: 0, byProject: [] }`), `AP_ENVIRONMENT` must **not** be `testing` (`triggerLazyBillingProviderSync`/`enrollBillingProviderOnCreate` early-return on it), and Redis must be up (billing reads/writes go through Redis caches). Enrollment is lazy — the first read of a billing/usage route fires `ensureEnrolled` → `enrollFree({ ownerEmail })` against `AUTUMN_CONSOLE_URL`. That prop **defaults to the production console** (`https://console.activepieces.com`), so an unconfigured local box enrolls a *real* customer keyed by the owner email; point it at the testing console instead. A freshly enrolled free customer has no events, so all usage aggregations read `0` until real `flow_run`/`ai`/`chat` credit events are metered for it. To merely eyeball the usage page with real data, `serve --filter=web -- --mode=cloud` against the cloud backend is far less setup than local billing.
- **Hand-editing `platform_plan` flags to unlock a feature locally does not stick, and the revert looks like the feature breaking itself.** The lazy entitlement projection above rewrites the plan columns from Autumn on any plan read, so a flag you set by hand survives only until the next sync. Because the throttle is 15 minutes and the web app leaves TanStack Query's `refetchOnWindowFocus` at its default `true`, the trigger in practice is *returning to the tab after a break*: alt-tab quickly and the flag survives, come back later and it is gone — which reads as an intermittent bug in whatever feature the flag gated, not as billing. `triggerLazyBillingProviderSync` is fire-and-forget, so nothing in the request that caused it says so. The clean fix for local work is to null **both** `autumnCustomerId` and `autumnApiKey` on the row: `loadAutumnCreds` returns null only when both are nil, `refreshEntitlements` then returns before its `update()`, and hand-set flags stay put. Null exactly one and you hit the worst case — an `error`-level `Autumn credentials incomplete for an enrolled platform` and every billing call silently no-oping.
- **Hand-editing `platform_plan` flags to unlock a feature locally does not stick, and the revert looks like the feature breaking itself.** The lazy entitlement projection above rewrites the plan columns from Autumn on any plan read, so a flag you set by hand survives only until the next sync. Because the throttle is 15 minutes and the web app leaves TanStack Query's `refetchOnWindowFocus` at its default `true`, the trigger in practice is *returning to the tab after a break*: alt-tab quickly and the flag survives, come back later and it is gone, which reads as an intermittent bug in whatever feature the flag gated, not as billing. `triggerLazyBillingProviderSync` is fire-and-forget, so nothing in the request that caused it says so.
- **To pin hand-set flags, set `autumnCustomerId` to any sentinel and leave `autumnApiKey` NULL.** `loadAutumnCreds` returns null whenever exactly one is missing, so `refreshEntitlements` returns before its `update()` and nothing overwrites the row. The cost is one `error`-level `Autumn credentials incomplete for an enrolled platform` per plan read, which is the no-op announcing itself and is harmless locally.
- **Do NOT null both credentials to achieve that**, even though `loadAutumnCreds` also returns null in that state. `ensureEnrolled` keys off `autumnCustomerId` alone: seeing it nil it treats the platform as never enrolled, calls the console's `enrollFree`, writes fresh credentials, and calls `refreshEntitlements` inline. Nulling both therefore *causes* the sync it was meant to prevent, and the free-plan flags come back within a minute or two along with a real Autumn customer. Verified 2026-09-14 on a local EE stack: flags reverted and both credential columns were repopulated.
- **On `AP_EDITION=ce` the `platform_plan` row is ignored entirely, so there is nothing to hand-edit.** `platform.service.ts`'s `getPlan` returns the `OPEN_SOURCE_PLAN` literal for Community before it ever reads the DB — the row still exists (`createInitialBilling` writes it) and still shows the value you set, which is what makes SQL look like the fix. Unlocking a flag locally on CE means editing that literal in `packages/core/shared/src/lib/ee/billing/index.ts`; for a limit, `null` is unlimited on both sides (`isNil` short-circuits the frontend guard and `assertMaximumNumberOfProjectsReachedByEdition`), while `0` means *not available on this plan* and additionally hides the feature's UI. Two traps: the edit needs `packages/core/shared/dist/` patched too or rebuilt, because **the API resolves `@activepieces/shared` through node_modules to `main: ./dist/src/index.js` while the web app resolves it through the tsconfig path to `src/`** — edit only `src` and the browser flips while the API keeps enforcing the old value, which reads as a frontend/backend disagreement; and `src` is tracked, so revert it before committing.
- The console base URL defaults to the production console and is overridable by an internal system prop (trailing slashes stripped) so our testing instance can point at the testing console. Deliberately absent from the self-hosting env-var reference: a self-hoster has no reason to change it, and the default must always be the one that works with zero setup. The Autumn SDK's own base URL is **not** configurable — nothing passes `serverURL` — so the console override cannot redirect entitlement reads.
- Credit metering for managed AI happens post-run in centralized worker execution (decision 000016), so in-flight spend is invisible to the gate.
Expand Down Expand Up @@ -61,6 +66,8 @@ Billing and entitlements are powered by [Autumn](https://useautumn.com). Each pl
- **The `Required<>` in both guards is load-bearing.** `Nullable()` is `z.optional(z.nullable(...))`, so a column declared that way arrives as an *optional* key in the `Pick` and would slip past an unwrapped `Pick` unnoticed.
- **Those build errors only appear once `@activepieces/shared` has been rebuilt.** `tsc -p packages/server/api/tsconfig.app.json` has `paths: {}` and resolves the package to `packages/core/shared/dist/src/index.d.ts`, not its source, so editing the schema and typechecking the API without a shared build proves nothing. vitest is the opposite — it aliases `@activepieces/shared` to `src`, so tests see schema edits immediately. Build shared first, and remember `tsc` does not clean `dist`, so a deleted module lingers there until you remove it.

- **Inside `BillingPageShell` every `isCommunity` check is dead code, and the billing page is full of them.** The shell wraps its children in `LockedFeatureGuard` keyed on `edition === COMMUNITY`, so nothing below it ever renders on Community. The billing page nonetheless computes `isCommunity` and guards its Current-subscription, Credits and Seats sections plus its Refresh button with `!isCommunity &&`, all of which is unreachable. Harmless, but it reads as if those sections were conditional when the only thing deciding them is a gate one level up, and it makes the page look edition-aware where it is not. Worth knowing before adding another guard in the same style, or before concluding a section is Community-visible because a check exists for it.

### Key files
Entry point: `platformPlanService` (`platform-plan.service.ts`) for projection, usage, and seat checks; `billingProvider.get(log)` for everything billing.

Expand Down
Loading
Loading