Skip to content
Back to skills

Server Architecture

BSecurity

ShipIt server/orchestrator architecture: buildApp(), HTTP API routes, services layer, WebSocket handler dispatch, SSE broadcasts, dependency injection, state scopes, manager initialization, type system. Load when working on orchestrator code, routes, services, or WebSocket handlers.

  • 9 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added September 3, 2026
developmentrustgosqlreactdockergitapidatabase

Works with

  • terminal
  • cli
  • api

Security analysis

B75/100
  • criticalAccesses system keychains or credential stores

Pro shows the line behind each finding and how to fix it

Scanned October 7, 2026

npx -y skills add nikzlabs/shipit --skill server-architecture --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Server Architecture?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Server Architecture
[![Security: B — Skills Directory](https://www.skillsdirectory.com/api/skills/nikzlabs-server-architecture/badge)](https://www.skillsdirectory.com/skills/nikzlabs-server-architecture)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: server-architecture
description: "ShipIt server/orchestrator architecture: buildApp(), HTTP API routes, services layer, WebSocket handler dispatch, SSE broadcasts, dependency injection, state scopes, manager initialization, type system. Load when working on orchestrator code, routes, services, or WebSocket handlers."
user-invocable: true
---

# Server Architecture

The server is a single Fastify process (the orchestrator) that handles HTTP, WebSocket, and SSE connections from the browser. It delegates session-scoped work to Docker containers running session workers.

## Entry Point: `buildApp()`

`src/server/orchestrator/index.ts` exports `buildApp(deps: AppDeps)`, which:

1. Instantiates all managers (or accepts injected stubs from `deps`)
2. Initializes global git config and agent detection
3. Sets up Docker container manager (production) or skips it (tests)
4. Creates the session runner registry with a factory
5. Registers HTTP routes via `registerApiRoutes()`
6. Registers WebSocket handler at `/ws/sessions/:id`
7. Registers SSE endpoint at `/api/events`
8. Registers preview proxy routes
9. Sets up startup tasks (warm pool validation, orphan cleanup)
10. Returns the Fastify instance without starting it

The function returns the app without calling `listen()`, so integration tests can use `app.inject()` without binding a port.

## Dependency Injection

`buildApp()` accepts an `AppDeps` object where every field is optional. Production uses real implementations; tests supply mocks/stubs. This is the foundation of testability — integration tests never spawn real Docker containers, Claude CLI processes, or Vite servers.

Key injectable dependencies:

| Dependency | Type | Purpose |
|------------|------|---------|
| `createGitManager` | `(dir) => GitManager` | Per-session git operations |
| `createRepoGit` | `(dir) => RepoGit` | Bare cache + per-session local-clone ops |
| `sessionManager` | `SessionManager` | Session metadata persistence |
| `authManager` | `AuthManager` | Claude CLI OAuth |
| `githubAuthManager` | `GitHubAuthManager` | GitHub token + API |
| `credentialStore` | `CredentialStore` | Unified credentials |
| `agentFactory` | `(agentId) => AgentProcess` | Agent process creation (test-only) |
| `runnerFactory` | `SessionRunnerFactory` | Session runner creation |
| `sessionContainerManager` | `SessionContainerManager` | Docker orchestration |

In production, agents live inside session containers — the orchestrator never spawns them directly. The `agentFactory` dependency exists only for integration tests.

## Manager Initialization Order

```
createGitManager / createRepoGit  (factories)
SessionManager                    (session metadata)
RepoStore                         (imported repos)
ChatHistoryManager                (per-session messages)
UsageManager                      (cost tracking)
AuthManager                       (Claude CLI OAuth)
CredentialStore                   (unified credentials)
initGlobalGitConfig()             (GIT_CONFIG_GLOBAL)
AgentRegistry                     (detect installed CLIs)
GitHubAuthManager                 (GitHub token + API)
markdown.ts                       (scan workspace for docs/ markdown)
SessionContainerManager           (Docker, production only)
SessionRunnerRegistry             (active runners)
```

## State Scopes

State is managed at three scopes:

### App-Level (server lifetime)

Singleton managers created in `buildApp()`. Shared across all connections.

- `SessionManager` — session metadata (title, workspace dir, remote URL, warm flag)
- `RepoStore` — imported repos, clone status, warm session IDs
- `SessionRunnerRegistry` — active runners (no count cap; reclaim is memory-budget driven, docs/284)
- `SessionContainerManager` — Docker containers
- `CredentialStore` — git identity, GitHub token, agent auth (one encrypted file)
- `AuthManager` / `GitHubAuthManager` — authentication state
- `AgentRegistry` — detected agent CLIs

### Per-Connection (WebSocket lifetime)

State bound to a single browser tab's WebSocket connection, tracked in closure variables inside the WS handler.

- Active session ID and directory
- Active agent ID
- Attached runner reference
- Log buffer

### Per-Session (runner lifetime)

State on the `SessionRunnerInterface`, shared across all connections viewing the same session.

- Agent process and running state
- Message queue (queued prompts waiting for current turn to finish)
- Turn event buffer (replayed to new viewers)
- Terminal process
- Preview status
- Viewer count

## HTTP API (`api-routes*.ts`)

Routes are registered via `registerApiRoutes()` (`api-routes.ts`, called from `route-registry.ts`) and split by
domain across `api-routes-*.ts`. All are prefixed `/api`. For the live set, grep the route files
(`grep -n "app\.\(get\|post\|patch\|put\|delete\)" src/server/orchestrator/api-routes*.ts`) rather than trusting a copied table.

### Error Handling

Service functions throw `ServiceError(statusCode, message)`. A Fastify `onError` hook catches these and returns the appropriate HTTP status with a JSON body:

```json
{ "error": "Session not found" }
```

## Services Layer (`services/`)

Business logic lives in `src/server/orchestrator/services/` as pure exported functions. Each function accepts explicit parameters (manager references, IDs) and returns data or throws `ServiceError`.

`ls src/server/orchestrator/services/` for the current set; `types.ts` holds `ServiceError` and the
bootstrap/settings shapes.

Services are consumed by both HTTP routes and WebSocket handlers. This keeps business logic testable independently of transport.

## WebSocket Handler Architecture

### Connection Lifecycle

The client connects to `ws[s]://host/ws/sessions/{sessionId}?agent=claude`. On connection:

1. Validate session exists (close with 4004 if not)
2. Initialize per-connection state (closures in the WS handler)
3. Call `activateSession(sessionId)` — gets or creates a runner, attaches the connection
4. Send log buffer and current status

### Message Dispatch

The `switch (msg.type)` dispatcher lives in `route-registry.ts` (not `index.ts`) and narrows
`WsClientMessage` (`shared/types/ws-client-messages.ts`) to per-file handlers in `ws-handlers/`.

### Handler Context

Handlers receive a `ctx` object combining three interfaces (see `ws-handlers/types.ts`):

**`ConnectionCtx`** — per-connection communication and session management:
- `send()`, `broadcastLog()`, `sseBroadcast()`
- `getActiveDir()`, `getActiveSessionDir()`, `getActiveAppSessionId()`
- `activateSession()`, `checkGitIdentity()`, `scheduleAutoPush()`

**`RunnerCtx`** — per-session runner delegation:
- `agentFactory(agentId)`
- `getActiveAgentId()` / `setActiveAgentId()`, `getSelectedModel()` / `setSelectedModel()`, `getSelectedReasoning()` / `setSelectedReasoning()` — per-connection identifiers that don't depend on runner state
- `getRunner()`, `getRunnerRegistry()`, `attachToRunner()`, `detachFromRunner()`

**Turn state is NOT on the context.** There are no `getIsClaudeRunning()`, `getAccumulatedText()`, `getTurnSummary()`, `getChatMessageGroups()`, `getMessageQueue()`, or `clearMessageQueue()` accessors — they were removed because they silently no-op'd after a WS disconnect. Resolve a runner with `resolveRunner(ctx)` (which prefers the registry) and read/write it directly: `runner.running`, `runner.turnSummary = "…"`. See `CLAUDE.md` → *WebSocket lifecycle MUST NOT affect server behavior*.

**`AppCtx`** — app-level managers and factories:
- `sessionManager`, `chatHistoryManager`, etc.
- `createGitManager()`, `createRepoGit()`, `createSessionDir()`
- `workspaceDir`, `sessionsRoot`, `defaultAgentId`

### Handler Files

One file per domain in `ws-handlers/` (`send-message.ts`, `terminal-handlers.ts`, `misc-handlers.ts`, …); `ls` it and read the `route-registry.ts` switch for the message→handler map.

### Critical rule: WebSocket lifecycle MUST NOT affect server behavior

WS disconnects and reconnects are routine. They MUST NOT stop agents, dispose runners, destroy containers, or corrupt persisted state. Concretely:

- **`socket.on("close")` only calls `detachFromRunner()`.** It must NOT call `enforceIdleContainerLimit()`, `runner.dispose()`, `agent.kill()`, `containerManager.destroy()`, or anything that affects state. Idle cleanup runs on a periodic timer and acts only when ShipIt is over its memory budget (see `idle-enforcer.ts: createIdleEnforcer`, docs/284).
- **`runner.dispose()` refuses to kill running agents** unless `{ force: true }` is passed. The only callers passing `force` are explicit user actions (archive, repo delete, full reset) and shutdown.
- **Inside async closures (`agent.on("event"|"done"|"error")`, `setTimeout`, `Promise.then`, recursive turns), capture `runner` / `capturedSessionId` / `capturedSessionDir` ONCE at function entry.** Never call `ctx.getRunner()` inside those closures — it returns the per-connection `attachedRunner`, which becomes null after disconnect. Mutate `runner.X` directly and emit via `runner.emitMessage()` (which broadcasts to all viewers AND buffers for reconnects), not `ctx.send()`.
- **Resolve runners via the registry**: use `resolveRunner(ctx)` from `ws-handlers/resolve-runner.ts` (or `ctx.getRunnerRegistry().get(capturedSessionId)`). The registry survives WS disconnects; `attachedRunner` doesn't.
- **`RunnerCtx` no longer exposes `setIsClaudeRunning`, `setTurnSummary`, etc.** They were a hazard — silent no-ops after disconnect. The only way to mutate runner state is `runner.X = …` on a resolved runner. See `docs/095-runner-ctx-simplification/plan.md`.

## SSE Broadcast (`/api/events`)

The orchestrator maintains a Server-Sent Events endpoint for global push to all connected clients. Events include:

- `session_list` — session created/renamed/archived/graduated
- `session_started` — new session with initial message
- `repo_list` — repo added/removed/status changed
- `repo_status` — clone progress, warm ready
- `repo_warm_ready` — warm session available for a repo
- `auth_required` / `auth_complete` — Claude auth flow
- `agent_list` — agent availability changed
- `active_runners` — which sessions have running agents
- `full_reset_complete` — workspace was reset

SSE is separate from per-session WebSocket. It broadcasts to all tabs, not just the active session.

So a field only the open session's viewer reads does not belong on a session list row: `list()` / `listAll()` return `SessionListRow` (no status card, goal, conversation replay or agent notice), and single-session events go through `toListRow`. The card and the goal reach the open session on its socket as `session_details`, sent at activation and on each `SessionManager.onDetailsChanged` (docs/303-session-status-card → Delivery).

## Type System

All types live in `src/server/shared/types/`:

| File | Contents |
|------|----------|
| `index.ts` | Barrel re-export |
| `ws-client-messages.ts` | `WsClientMessage` union |
| `ws-server-messages.ts` | `WsServerMessage` union (50+ types) |
| `domain-types.ts` | `SessionInfo`, `RepoInfo`, `ProjectTemplate` |
| `claude-types.ts` | `ClaudeEvent`, NDJSON message types |
| `agent-types.ts` | `AgentProcess`, `AgentEvent`, `AgentCapabilities` |
| `deployment-types.ts` | `GitHubDeploymentStatus` |
| `github-types.ts` | `WsGitHubStatus`, `WsGitHubPushResult`, … (GitHub WS server messages) |
| `terminal-types.ts` | `TerminalProcess`, `WsTerminalStart`, … |
| `usage-types.ts` | `UsageTurn`, `TurnUsage`, `SessionUsage`, `WeeklyUsage` |
| `attachment-types.ts` | `ImageAttachment`, `FileContextRef` |

Types are shared between server and client (client imports from `../../server/shared/types.js`).

## Persistence

**Persistence is SQLite** (`src/server/shared/database.ts`), not JSON files. The managers below are thin typed accessors over their tables; adding a persisted field means a table column plus a `database.ts` migration.

| Data | Storage | Owner |
|------|---------|-------|
| Session metadata | `sessions` table | `SessionManager` (`sessions.ts`) |
| Chat history | `messages` table | `ChatHistoryManager` (`chat-history.ts`) |
| Usage stats | `usage_turns` table | `UsageManager` (`usage.ts`) |
| Repos | `repos` table | `RepoStore` (`repo-store.ts`) |
| Secrets | `secrets` table | `SecretStore` (`secret-store.ts`) |
| Reviews, rewind snapshots, egress rules, presentations | their own tables | see `database.ts` |
| GitHub token, agent env, provider-account metadata | `{credentialsDir}/shipit-credentials.json` (encrypted **when a cipher is supplied**, not unconditionally) | `CredentialStore` (`credential-store.ts`) |
| Git identity | `.gitconfig` in the credentials dir, via `GIT_CONFIG_GLOBAL` — not a `CredentialStore` field | `git-config.ts` |
| Provider subscription auth | Per-account filesystem roots (`.claude/…`, `.codex/auth.json`) | `provider-account-manager.ts` |
| Session code | Git repo | `/workspace/sessions/{uuid}/` |

Those are four distinct stores — SQLite domain records, the CredentialStore JSON, global git config, and the provider CLI auth roots. Don't collapse them.

Deploy status is **not** persisted locally — it is read from the GitHub Deployments API (see the `deployment-architecture` skill). The `.vibe-sessions.json` / `.shipit-usage.json` names survive only as file-watcher ignore entries (`fs-constants.ts`), not as stores. There is no `ThreadManager`.

## Key Files

| File | Role |
|------|------|
| `src/server/orchestrator/index.ts` | `buildApp()` — app factory, DI setup |
| `src/server/orchestrator/route-registry.ts` | Route registration, WS connection + dispatch switch |
| `src/server/orchestrator/api-routes.ts` | HTTP REST API routes |
| `src/server/orchestrator/services/*.ts` | Business logic (pure functions) |
| `src/server/orchestrator/ws-handlers/*.ts` | WebSocket message handlers |
| `src/server/orchestrator/ws-handlers/types.ts` | `ConnectionCtx`, `RunnerCtx`, `AppCtx` interfaces |
| `src/server/orchestrator/session-runner.ts` | `SessionRunnerInterface`, `SessionRunnerRegistry` |
| `src/server/orchestrator/container-session-runner.ts` | `ContainerSessionRunner` (production runner) |
| `src/server/orchestrator/session-container.ts` | Docker container management |
| `src/server/session/session-worker.ts` | In-container Fastify server |
| `src/server/session/agents/claude/process.ts` | `ClaudeProcess` — spawns CLI, parses NDJSON (per-agent dirs since docs/155) |
| `src/server/orchestrator/service-manager.ts` | `ServiceManager` — Docker Compose lifecycle |
| `src/server/orchestrator/compose-generator.ts` | Compose override generation, volume rewriting |
| `src/server/shared/git.ts` | `GitManager` — per-session git |
| `src/server/orchestrator/repo-git.ts` | `RepoGit` — bare cache + per-session local-clone ops |
| `src/client/App.tsx` | Main React component |
| `src/client/stores/*.ts` | Zustand stores |
| `src/client/hooks/*.ts` | Custom hooks (WebSocket, API, message handling) |

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…