agentleFS
nikzlabsnikzlabs/shipit/.agents/skills/server-architecture/SKILL.md

server-architecture

Skill9 starsChanged 17 days agoApache-2.0View on GitHub

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.

  • Reads credentials

The file

We split this skill into the rules it gives an agent

3 rules. About 0 are common, 0 are distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈0

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Call activateSession(sessionId) — gets or creates a runner, attaches the connection

    line 133
  2. Very specific:

    Resolve runners via the registry: use resolveRunner(ctx) from ws-handlers/resolve-runner.ts (or …

    line 173
  3. Very specific:

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

    line 196

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

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:

DependencyTypePurpose
createGitManager(dir) => GitManagerPer-session git operations
createRepoGit(dir) => RepoGitBare cache + per-session local-clone ops
sessionManagerSessionManagerSession metadata persistence
authManagerAuthManagerClaude CLI OAuth
githubAuthManagerGitHubAuthManagerGitHub token + API
credentialStoreCredentialStoreUnified credentials
agentFactory(agentId) => AgentProcessAgent process creation (test-only)
runnerFactorySessionRunnerFactorySession runner creation
sessionContainerManagerSessionContainerManagerDocker 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:

{ "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/:

FileContents
index.tsBarrel re-export
ws-client-messages.tsWsClientMessage union
ws-server-messages.tsWsServerMessage union (50+ types)
domain-types.tsSessionInfo, RepoInfo, ProjectTemplate
claude-types.tsClaudeEvent, NDJSON message types
agent-types.tsAgentProcess, AgentEvent, AgentCapabilities
deployment-types.tsGitHubDeploymentStatus
github-types.tsWsGitHubStatus, WsGitHubPushResult, … (GitHub WS server messages)
terminal-types.tsTerminalProcess, WsTerminalStart, …
usage-types.tsUsageTurn, TurnUsage, SessionUsage, WeeklyUsage
attachment-types.tsImageAttachment, 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.

DataStorageOwner
Session metadatasessions tableSessionManager (sessions.ts)
Chat historymessages tableChatHistoryManager (chat-history.ts)
Usage statsusage_turns tableUsageManager (usage.ts)
Reposrepos tableRepoStore (repo-store.ts)
Secretssecrets tableSecretStore (secret-store.ts)
Reviews, rewind snapshots, egress rules, presentationstheir own tablessee 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 fieldgit-config.ts
Provider subscription authPer-account filesystem roots (.claude/…, .codex/auth.json)provider-account-manager.ts
Session codeGit 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

FileRole
src/server/orchestrator/index.tsbuildApp() — app factory, DI setup
src/server/orchestrator/route-registry.tsRoute registration, WS connection + dispatch switch
src/server/orchestrator/api-routes.tsHTTP REST API routes
src/server/orchestrator/services/*.tsBusiness logic (pure functions)
src/server/orchestrator/ws-handlers/*.tsWebSocket message handlers
src/server/orchestrator/ws-handlers/types.tsConnectionCtx, RunnerCtx, AppCtx interfaces
src/server/orchestrator/session-runner.tsSessionRunnerInterface, SessionRunnerRegistry
src/server/orchestrator/container-session-runner.tsContainerSessionRunner (production runner)
src/server/orchestrator/session-container.tsDocker container management
src/server/session/session-worker.tsIn-container Fastify server
src/server/session/agents/claude/process.tsClaudeProcess — spawns CLI, parses NDJSON (per-agent dirs since docs/155)
src/server/orchestrator/service-manager.tsServiceManager — Docker Compose lifecycle
src/server/orchestrator/compose-generator.tsCompose override generation, volume rewriting
src/server/shared/git.tsGitManager — per-session git
src/server/orchestrator/repo-git.tsRepoGit — bare cache + per-session local-clone ops
src/client/App.tsxMain React component
src/client/stores/*.tsZustand stores
src/client/hooks/*.tsCustom hooks (WebSocket, API, message handling)

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.