nexus/.planning/codebase/ARCHITECTURE.md
Mikkel Georgsen 6c4272ce85 [nexus] chore: migrate .planning/ from agent repo to nexus repo
Planning artifacts (milestones v1.0-v1.2.1, v1.3 queue, PROJECT.md,
STATE.md, config) now live alongside the code they describe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 03:55:42 +00:00

512 lines
23 KiB
Markdown

# Architecture
**Analysis Date:** 2026-03-30
**Codebase:** Paperclip (nexus repo at `/Volumes/UsbNvme/repos/nexus/`)
---
## Pattern Overview
**Overall:** Multi-package TypeScript monorepo. Server-rendered React SPA (Vite) + Express API server + CLI tool + adapter plugin system.
**Key Characteristics:**
- pnpm workspaces monorepo with five top-level workspace members: `server`, `ui`, `cli`, `packages/*`, `plugins/*`
- Express REST API with actor-based auth middleware (board users, agents, instance admin)
- React SPA using React Router v6 with company-prefixed URL scheme (e.g., `/PAP/issues`)
- Agent execution driven by a heartbeat lifecycle: wakeup requests → heartbeat runs → adapter subprocess calls
- Plugin system with isolated worker processes and structured host/worker RPC
---
## Monorepo Structure
```
nexus/
├── server/ # Express API + business logic
│ ├── src/
│ │ ├── routes/ # Express route handlers
│ │ ├── services/ # Business logic layer
│ │ ├── adapters/ # Agent execution adapter registry
│ │ ├── auth/ # BetterAuth integration
│ │ ├── middleware/ # auth, logging, validation, error handling
│ │ ├── realtime/ # WebSocket live-events bridge
│ │ ├── secrets/ # Secret provider abstraction
│ │ ├── storage/ # S3/local file storage abstraction
│ │ └── app.ts # Express app factory
├── ui/ # React SPA (Vite)
│ └── src/
│ ├── pages/ # Top-level page components
│ ├── components/ # Shared UI components
│ ├── context/ # React contexts (company, dialog, live updates)
│ ├── api/ # API client modules (one per domain)
│ ├── hooks/ # Custom React hooks
│ └── App.tsx # Router + route tree
├── cli/ # paperclipai CLI (Commander.js)
│ └── src/
│ ├── commands/ # CLI command implementations
│ ├── config/ # Config file read/write
│ └── prompts/ # @clack/prompts interactive wizards
├── packages/
│ ├── db/ # Drizzle ORM schema + migrations + DB client
│ ├── shared/ # Shared types, validators (Zod), constants
│ ├── adapter-utils/ # Shared adapter type contracts + session utils
│ └── adapters/ # Adapter packages (one per AI tool)
│ ├── claude-local/
│ ├── codex-local/
│ ├── cursor-local/
│ ├── gemini-local/
│ ├── opencode-local/
│ ├── pi-local/
│ └── openclaw-gateway/
└── plugins/
├── sdk/ # @paperclipai/plugin-sdk (worker-side SDK)
├── create-paperclip-plugin/ # Plugin scaffolding tool
└── examples/ # Example plugins
```
---
## Layers
**Database Layer:**
- Purpose: Schema definitions, migrations, Drizzle ORM client
- Location: `packages/db/src/`
- Contains: Drizzle table definitions in `schema/`, migration SQL in `migrations/`, `client.ts` (embedded Postgres or external URL)
- Depends on: PostgreSQL (embedded via `embedded-postgres` or external)
- Used by: Server services exclusively
**Services Layer:**
- Purpose: All business logic; one service function per domain
- Location: `server/src/services/`
- Contains: `agentService`, `companyService`, `heartbeatService`, `issueService`, `pluginLifecycleManager`, etc.
- Pattern: Each service is a factory function taking `db: Db` and returning an object of async methods. No class instances.
- Depends on: `@paperclipai/db` schema tables, other services
- Used by: Route handlers
**Route Layer:**
- Purpose: HTTP request parsing, auth assertions, response shaping
- Location: `server/src/routes/`
- Contains: One file per resource domain (companies, agents, issues, projects, routines, plugins, etc.)
- Pattern: Each route file exports a factory `function fooRoutes(db: Db): Router`. Routes call service methods, assert auth via `assertBoard` / `assertCompanyAccess` helpers.
- Depends on: Services layer, middleware
**Middleware Layer:**
- Purpose: Cross-cutting request processing
- Location: `server/src/middleware/`
- Files:
- `auth.ts``actorMiddleware`: resolves `req.actor` as board user, agent, or none
- `board-mutation-guard.ts` — blocks unsafe mutations on read-only mounts
- `private-hostname-guard.ts` — hostname allowlist enforcement
- `validate.ts` — Zod schema validation helper
- `logger.ts` — pino logger
- `error-handler.ts` — global Express error handler
**Adapter Layer:**
- Purpose: Execute agents via different AI tools (Claude, Codex, Cursor, etc.)
- Location: `server/src/adapters/` (registry + process/http sub-adapters), `packages/adapters/` (per-tool implementations)
- Pattern: Each adapter implements `ServerAdapterModule` from `@paperclipai/adapter-utils`. The registry (`registry.ts`) maps adapter type strings to module instances. `heartbeatService` calls `getServerAdapter(type).execute(...)` to run agents.
**UI Layer:**
- Purpose: React SPA for the board (human operators)
- Location: `ui/src/`
- API communication: `ui/src/api/client.ts` — simple `fetch` wrapper. All domain API modules (e.g., `ui/src/api/agents.ts`) use this `api` object. No external HTTP library.
- State: TanStack Query (`@tanstack/react-query`) for server state. React Context for cross-component state (company selection, dialogs, live updates, theme).
- Real-time: SSE from `/api/realtime` consumed by `LiveUpdatesProvider` in `ui/src/context/LiveUpdatesProvider.tsx`.
**CLI Layer:**
- Purpose: Operator tooling — first-run setup, diagnostics, server run, one-off commands
- Location: `cli/src/`
- Framework: Commander.js with `@clack/prompts` for interactive steps
---
## Authentication Model
Two principal types flow through `actorMiddleware` (`server/src/middleware/auth.ts`):
**Board (human):**
- `local_trusted` deployment: automatically granted `isInstanceAdmin: true`, no auth needed
- `authenticated` deployment: BetterAuth session cookie OR bearer API key (from `board_api_keys` table)
- `req.actor.type === "board"`, carries `userId`, `companyIds[]`, `isInstanceAdmin`
**Agent:**
- Bearer token: either a hashed API key from `agent_api_keys` table, or a short-lived JWT (`createLocalAgentJwt`)
- `req.actor.type === "agent"`, carries `agentId`, `companyId`
- Agents cannot access other companies; CEO agents get additional mutation rights (branding, portability)
Route authorization helpers live in `server/src/routes/authz.ts`:
- `assertBoard(req)` — throws 403 if not board
- `assertCompanyAccess(req, companyId)` — throws 403 if actor cannot access this company
- `assertInstanceAdmin(req)` — throws 403 if not instance admin
---
## Company Model
Companies are the top-level tenant container. They map to what will become "projects" in Nexus.
**Schema:** `packages/db/src/schema/companies.ts`
```
companies
id uuid (PK)
name text
description text
status text (active | paused | archived)
issuePrefix text UNIQUE // e.g. "PAP" — used in URLs and identifiers
issueCounter integer // auto-increment for issue numbers
budgetMonthlyCents integer
requireBoardApprovalForNewAgents boolean
brandColor text
```
**Company creation flow (`POST /api/companies`):**
1. Board asserts instance admin
2. `companyService.create(data)` inserts company
3. `accessService.ensureMembership(...)` adds creator as owner in `company_memberships`
4. If budget set, `budgetService.upsertPolicy(...)` creates budget policy
5. Activity logged
**Company service:** `server/src/services/companies.ts``companyService(db)` factory returning `list`, `getById`, `create`, `update`, `archive`, `remove`, `stats`.
**Company portability:** `server/src/services/company-portability.ts` — import/export bundles; CEO agents can run imports/exports on their own company.
**Company members:** `packages/db/src/schema/company_memberships.ts``principalType` (user|agent), `principalId`, `companyId`, `role` (owner|member), `status` (active|invited|etc.)
---
## Agent Model
Agents are AI workers belonging to a company. CEO is a special role with elevated permissions.
**Schema:** `packages/db/src/schema/agents.ts`
```
agents
id uuid (PK)
companyId uuid (FK companies)
name text
role text // "ceo" | "general" | custom
title text
reportsTo uuid // self-referential FK for org chart
status text // idle | running | paused | terminated | pending_approval
adapterType text // "claude_local" | "codex_local" | "cursor" | etc.
adapterConfig jsonb // adapter-specific config (model, instructionsFilePath, etc.)
runtimeConfig jsonb // session compaction policy, max concurrent runs
budgetMonthlyCents integer
permissions jsonb // canCreateAgents, etc.
lastHeartbeatAt timestamp
```
**Agent service:** `server/src/services/agents.ts``agentService(db)` factory. Key methods:
- `create`, `update`, `pause`, `resume`, `terminate`, `remove`
- `orgForCompany(companyId)` — builds hierarchical org tree from `reportsTo` links
- `getChainOfCommand(agentId)` — walks up `reportsTo` chain
- `createApiKey`, `listKeys`, `revokeKey`
- `rollbackConfigRevision` — restores an agent config from `agent_config_revisions`
**CEO agents:** Identified by `role === "ceo"`. Only CEO agents can:
- Update company branding (`PATCH /api/companies/:id/branding`)
- Manage company portability (import/export)
- The default first task in onboarding is assigned to the CEO agent
---
## Agent Heartbeat / Task Lifecycle
This is the core execution engine. An "agent run" is called a **heartbeat run**.
**Flow:**
```
1. Wake request queued
→ agent_wakeup_requests (status: queued)
→ agentWakeupRequest.source = "timer" | "assignment" | "on_demand" | "automation"
2. heartbeatService.runHeartbeat(agentId, options)
→ acquires per-agent start lock (Map<agentId, Promise>)
→ checks concurrent run limits (runtimeConfig.maxConcurrentRuns, default 1)
→ resolves execution workspace (project repo, task session, or agent home dir)
→ resolves session state (previous session params from agent_task_sessions)
→ checks session compaction (rotate if maxSessionRuns / maxRawInputTokens exceeded)
→ inserts heartbeat_runs row (status: queued → running)
3. getServerAdapter(adapterType).execute(context, meta)
→ adapter launches subprocess or HTTP call
→ streams stdout/stderr chunks back
→ heartbeatService writes live log chunks to run log store
4. On completion:
→ updates heartbeat_runs (status: done | error | cancelled)
→ persists session state to agent_task_sessions
→ writes cost event to cost_events
→ publishes live event to EventEmitter for SSE clients
→ updates issue status if issue was being worked
5. Wakeup request marked finished
```
**Key tables:**
- `heartbeat_runs` — one row per execution (`packages/db/src/schema/heartbeat_runs.ts`)
- `agent_wakeup_requests` — queued wakeup requests, coalesced by idempotency key
- `agent_task_sessions` — persisted session params per (agent, adapterType, taskKey)
- `agent_runtime_state` — current session ID for an agent
**heartbeatService:** `server/src/services/heartbeat.ts` (~1400 lines). The central orchestrator. Dependencies: adapter registry, workspace services, session codec, budget service, issue service, cost service.
---
## Adapter System
Adapters wrap specific AI tools (Claude Code, OpenAI Codex, Cursor, Gemini, etc.).
**Contract:** `ServerAdapterModule` from `packages/adapter-utils/`. Key methods:
- `execute(context, meta): Promise<AdapterExecutionResult>` — run the agent
- `testEnvironment(config): Promise<AdapterEnvironmentTestResult>` — check prerequisites
- `listSkills/syncSkills` — skill management
- `sessionCodec` — serialize/deserialize session state
- `onHireApproved` (optional) — notify adapter when a hired agent is approved
**Registry:** `server/src/adapters/registry.ts` — maps type string to `ServerAdapterModule`. Falls back to `processAdapter` for unknown types.
**Registered adapter types:**
- `claude_local` — Claude Code CLI subprocess (`packages/adapters/claude-local/`)
- `codex_local` — OpenAI Codex (`packages/adapters/codex-local/`)
- `opencode_local` — OpenCode (`packages/adapters/opencode-local/`)
- `pi_local` — Pi adapter (`packages/adapters/pi-local/`)
- `cursor` — Cursor IDE (`packages/adapters/cursor-local/`)
- `gemini_local` — Gemini CLI (`packages/adapters/gemini-local/`)
- `openclaw_gateway` — HTTP gateway adapter (`packages/adapters/openclaw-gateway/`)
- `process` — generic subprocess adapter (`server/src/adapters/process/`)
- `http` — generic HTTP adapter (`server/src/adapters/http/`)
- `hermes_local` — third-party Hermes adapter
**Process adapter:** `server/src/adapters/process/` — launches a configured command as a child process, streams stdout/stderr, parses structured JSON event chunks from the process.
---
## Plugin System
Plugins extend Paperclip with custom tools, UI slots, scheduled jobs, and webhooks.
**Architecture:**
- Each plugin runs as a separate worker process (isolated via `pluginWorkerManager`)
- Host (server) communicates with worker via JSON-RPC over stdin/stdout
- Host provides `PluginContext` services to the worker via the SDK
**Key services:**
- `plugin-lifecycle.ts` — state machine: `installed → ready → disabled/error/upgrade_pending → uninstalled`. Starting/stopping worker processes on transitions.
- `plugin-loader.ts` — loads plugin packages from disk, validates manifests
- `plugin-worker-manager.ts` — spawns/manages worker processes
- `plugin-job-coordinator.ts` + `plugin-job-scheduler.ts` — scheduled job execution
- `plugin-tool-dispatcher.ts` — routes tool invocations from agents to plugin workers
- `plugin-event-bus.ts` — dispatches Paperclip events (issue.created, agent.run.completed, etc.) to plugin workers
- `plugin-host-services.ts` — host-side service implementations exposed to workers via SDK
- `plugin-registry.ts` — DB CRUD for plugin records
**Worker SDK:** `packages/plugins/sdk/src/``@paperclipai/plugin-sdk`. Plugin authors import this to define tools, jobs, webhooks, UI slots, and interact with Paperclip state.
**Plugin manifest:** `PaperclipPluginManifestV1` from `@paperclipai/shared` — declares capabilities, tools, jobs, webhooks, UI slots.
**DB tables:** `plugins`, `plugin_config`, `plugin_state`, `plugin_jobs`, `plugin_logs`, `plugin_entities`, `plugin_webhooks`, `plugin_company_settings`
---
## Issue / Task Model
Issues are work items assigned to agents or users.
**Schema:** `packages/db/src/schema/issues.ts` — key fields:
```
issues
id, companyId, projectId, goalId, parentId (self-ref)
title, description, status (backlog|todo|in_progress|in_review|blocked|done|cancelled)
priority (low|medium|high|urgent)
assigneeAgentId, assigneeUserId
checkoutRunId, executionRunId // FK to heartbeat_runs
executionWorkspaceId
issueNumber, identifier (e.g. "PAP-42")
originKind, originId // "manual" | "routine_execution" | "heartbeat"
requestDepth // for sub-issues created by agents
```
**Identifier format:** `{issuePrefix}-{issueNumber}` — e.g., `PAP-42`. The prefix is unique per company.
**Sub-issues:** `parentId` self-reference allows agent-created sub-issues. `requestDepth` tracks nesting.
**Execution workspace:** Each issue can have an `executionWorkspaceId` pointing to a specific git worktree or container workspace. Managed by `execution-workspace-policy.ts`.
---
## Project Model
Projects group issues and have an optional lead agent and git repo.
**Schema:** `packages/db/src/schema/projects.ts`
```
projects
id, companyId, goalId
name, description, status (backlog|active|paused|done|archived)
leadAgentId
targetDate, color
executionWorkspacePolicy jsonb // repo URL, worktree settings
```
Projects will become the renamed entity in Nexus (replacing "companies" as the primary project container).
---
## Goal Model
Goals are objectives that group projects and issues.
**Schema:** `packages/db/src/schema/goals.ts``id, companyId, title, description, status, parentId` (hierarchical)
---
## Onboarding Flow
### CLI Onboarding (`paperclipai onboard`)
Interactive wizard in `cli/src/commands/onboard.ts` driven by `@clack/prompts`:
1. **Mode selection** — "quickstart" (embedded Postgres, sane defaults) or "advanced" (custom DB, S3, auth)
2. **Database** — embedded Postgres (auto-configured) or external `DATABASE_URL`
3. **LLM** — configure default LLM provider/API key
4. **Server** — port, host, deployment mode (`local_trusted` vs `authenticated`), public URL
5. **Storage** — local filesystem or S3
6. **Secrets** — local encrypted file or external secret manager
7. Writes config to `~/.paperclip/config.yaml` (or `--config` path)
8. Optionally runs `auth bootstrap-ceo` to generate first admin invite
### UI Onboarding Wizard (`OnboardingWizard.tsx`)
4-step dialog at `ui/src/components/OnboardingWizard.tsx`:
- **Step 1 — Company**: enter company name + high-level goal. Creates company via `POST /api/companies`, creates a Goal record.
- **Step 2 — Agent**: name the CEO agent, select adapter type (claude_local, codex_local, cursor, etc.), configure model. Tests adapter environment. Creates agent via `POST /api/companies/:id/agents`.
- **Step 3 — First Task**: pre-filled task description (`"Hire a founding engineer, write a hiring plan, break the roadmap into concrete tasks"`). Creates a project + issue assigned to the CEO agent.
- **Step 4 — Launch**: confirms entities created, navigates to issue detail. Shows run transcript.
**Trigger:** `openOnboarding()` from `DialogContext`. Also triggered by route `/onboarding` or by navigating to company-prefixed routes when no companies exist.
**Default task:** The CEO agent's first issue is "Hire your first engineer and create a hiring plan" with the task description guiding it to hire a founding engineer and begin delegating work.
---
## Real-time Updates
**Server:** `server/src/services/live-events.ts` — in-process `EventEmitter`. Services call `publishLiveEvent({ companyId, type, payload })` after mutations. SSE endpoint in `server/src/realtime/live-events-ws.ts` streams events per company.
**Client:** `ui/src/context/LiveUpdatesProvider.tsx` — subscribes to SSE, calls `queryClient.invalidateQueries(...)` to refresh TanStack Query caches on relevant events. Also shows toast notifications for agent run completions and issue status changes.
---
## CLI Commands
Entry point: `cli/src/index.ts` — Commander.js program named `paperclipai`.
**Setup commands:**
- `onboard` — interactive first-run setup wizard
- `run` — onboard + doctor + start server
- `configure` — update config sections (llm, database, logging, server, storage, secrets)
- `doctor` — diagnostic checks + optional auto-repair
- `env` — print deployment environment variables
- `allowed-hostname` — add hostname to allowlist
- `db:backup` — one-off database backup
**Runtime commands:**
- `heartbeat run --agent-id <id>` — trigger one agent heartbeat and stream logs
**Client commands (interact with a running Paperclip instance):**
- `company list|create|get` — CRUD on companies
- `issue list|create|get|update` — CRUD on issues
- `agent list|create|get|update` — CRUD on agents
- `approval list|get|approve|reject` — manage approvals
- `activity list` — view activity log
- `dashboard` — display dashboard stats
- `plugin list|install|enable|disable|uninstall` — plugin management
- `auth login|logout|whoami|bootstrap-ceo` — auth operations
- `worktree ...` — git worktree management for execution workspaces
Config/context for client commands loaded from `~/.paperclip/config.yaml` or `--config` flag.
---
## Routine Model
Routines are scheduled triggers that create issues on a cron-like cadence.
**Schema:** `packages/db/src/schema/routines.ts`
Routines fire at configured intervals, create issues with the configured title/description, and assign them to the specified agent. `server/src/services/cron.ts` drives the scheduling.
---
## Execution Workspace Model
Execution workspaces provide isolated git checkouts per project or issue.
**Schema:** `packages/db/src/schema/execution_workspaces.ts`, `project_workspaces.ts`
Managed by `server/src/services/execution-workspace-policy.ts` and `workspace-runtime.ts`. On heartbeat start, `heartbeatService` calls `realizeExecutionWorkspace(...)` to ensure the workspace directory + git clone exist before passing `cwd` to the adapter.
---
## Key Data Model Relationships
```
Instance
└── companies (1:N)
├── agents (1:N) — role: ceo | general | custom
│ └── reportsTo (self-ref tree)
├── projects (1:N)
│ └── issues (1:N)
├── goals (1:N, hierarchical)
├── routines (1:N)
├── heartbeat_runs (1:N, via agents)
│ └── heartbeat_run_events (1:N)
├── agent_wakeup_requests (1:N)
├── agent_task_sessions (1:N)
├── approvals (1:N)
├── cost_events (1:N)
├── company_secrets (1:N)
└── plugins (M:N via plugin_company_settings)
```
---
## Entry Points
**Server:**
- `server/src/index.ts` — boots Postgres, runs migrations, calls `createApp(db, opts)`, starts HTTP listener
- `server/src/app.ts``createApp(db, opts)` — wires Express middleware, mounts all routes, initializes plugin system, returns `app`
**UI:**
- `ui/src/main.tsx` — React root, wraps `<App />` in TanStack Query provider, `CompanyProvider`, etc.
- `ui/src/App.tsx` — top-level router with `CloudAccessGate`, company-prefixed routes, `<OnboardingWizard />`
**CLI:**
- `cli/src/index.ts` — Commander program, registers all commands, calls `program.parseAsync()`
---
## Error Handling
**Server:** `server/src/errors.ts` exports `notFound`, `conflict`, `forbidden`, `unprocessable` helpers that throw typed errors. `server/src/middleware/error-handler.ts` catches them and returns structured JSON: `{ error: string }`.
**UI:** `ui/src/api/client.ts``ApiError` class with `status` and `body`. Components check `error instanceof ApiError` and display `error.message`.
---
## Deployment Modes
Controlled by `PAPERCLIP_DEPLOYMENT_MODE`:
- `local_trusted` — single-user local, no auth required. All requests automatically get `isInstanceAdmin: true`.
- `authenticated` — multi-user, requires BetterAuth session or API key. Board API keys stored in `board_api_keys`.
`PAPERCLIP_DEPLOYMENT_EXPOSURE`:
- `public` — open to external traffic
- `private` — private-hostname-guard enforces allowlist
---
*Architecture analysis: 2026-03-30*