Pārlūkot izejas kodu

feat: server foundation specs

Thomas Zhang 3 mēneši atpakaļ
vecāks
revīzija
75e07f9ee0

+ 12 - 0
specs/DESIGN.md

@@ -1,5 +1,17 @@
 # Design
 
+## Detailed Design Docs
+
+Component-level design docs live under `specs/designs/`, organized by category:
+
+```
+specs/designs/
+├── 00-foundation/     — core server infrastructure (server lifecycle, auth, config)
+└── 01-data/           — external data sources (Git, Kubernetes)
+```
+
+---
+
 ## Domain Models
 
 The UI maintains a stable set of domain models — its canonical, backend-agnostic representation of each resource, used by all forms and stores. They are an intermediate representation: they translate to and from the Custom Resources of whichever backend the deployment targets — the platform's own operator, or a community standard such as kagent or KServe. Field-level detail is defined in the individual entity design specs.

+ 95 - 0
specs/designs/00-foundation/auth.md

@@ -0,0 +1,95 @@
+# Auth
+
+## Overview
+
+Authentication is stateless: the server issues a signed JWT on login and never stores session state. Every request carries the token in an HTTP-only cookie; the server verifies the signature and expiry on each request without any database or cache lookup. This makes horizontal scaling trivial — any replica can handle any request.
+
+Credential storage is pluggable. The initial implementation reads from a local file mounted by Kubernetes from a ConfigMap, which is sufficient for dev and early deployments. The interface is designed so the file-based backend can be swapped for an OIDC/SSO provider without touching the JWT layer or any route handler.
+
+## Flow
+
+```
+POST /auth/login
+  │
+  ├─ read userId + password from form body
+  ├─ load credentials from backend (file or SSO)
+  ├─ verify password (bcrypt compare)
+  ├─ invalid → 401, re-render login form with error message
+  └─ valid   → sign JWT → set HttpOnly cookie → redirect to /
+
+Subsequent requests
+  │
+  ├─ hooks.server.ts reads `session` cookie
+  ├─ verifyToken() checks signature + expiry (no I/O)
+  ├─ valid   → populate event.locals.user → proceed
+  └─ invalid → redirect to /auth/login
+
+GET /auth/logout
+  │
+  └─ clear `session` cookie → redirect to /auth/login
+```
+
+## JWT
+
+- **Algorithm**: HS256 with a secret from `AUTH_JWT_SECRET` env var
+- **TTL**: 8 hours; encoded in the `exp` claim
+- **Payload**: `{ username: string }` where `username` is the user's `id` field
+- **Transport**: `HttpOnly; SameSite=Lax; Secure; Path=/` cookie named `session`
+- **Verification**: pure in-memory — `jose.jwtVerify()` with no external calls
+
+## Credential Backend
+
+### Local File
+
+A JSON file mounted into the pod from a Kubernetes ConfigMap:
+
+```json
+[
+	{
+		"id": "admin",
+		"passwordHash": "$2b$10$...",
+		"name": "Admin User",
+		"email": "admin@example.com",
+		"workspaces": ["default"]
+	}
+]
+```
+
+Fields: `id` (required, used as login identifier and stored in JWT), `passwordHash` (required, bcrypt), `name` (optional, display name), `email` (optional), `workspaces` (optional, list of workspace IDs the user may access; defaults to `[]`).
+
+- Path: `AUTH_USERS_FILE` env var
+- Read once at server startup in `Auth.init()` and cached in memory; pod restart required to pick up changes
+- Passwords are bcrypt hashes (cost factor 10). Generate with: `htpasswd -nbB admin mypassword | cut -d: -f2`
+
+**ConfigMap example:**
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: agent-paas-users
+  namespace: agent-paas
+data:
+  users.json: |
+    [
+      { "id": "admin", "passwordHash": "$2b$10$...", "workspaces": ["default"] }
+    ]
+```
+
+Mount it as a volume at `/etc/agent-paas/users.json` in the pod spec.
+
+### SSO / OIDC
+
+When SSO is required, replace the `Auth` class with an OIDC-backed implementation that:
+
+1. Redirects unauthenticated users to the IdP authorization endpoint
+2. Handles the callback, exchanges the code for tokens, validates the ID token
+3. Issues the same internal JWT (same `{ username: id }` payload) so the rest of the system is unaffected
+
+The `Server` class wires in the concrete `Auth` implementation; no route handler or hook needs to change.
+
+## Scaling Properties
+
+- **No shared state** — JWT verification is pure CPU; no Redis, no DB, no sticky sessions needed
+- **Credential cache** — the users list is loaded once at startup per replica; all replicas read the same ConfigMap at pod start, so they stay consistent without coordination
+- **Secret rotation** — rotating `AUTH_JWT_SECRET` invalidates all existing sessions (users must re-login); this is acceptable for a developer tool with an 8-hour TTL

+ 46 - 0
specs/designs/00-foundation/errors.md

@@ -0,0 +1,46 @@
+# Error Handling
+
+## Overview
+
+All server-side errors extend a common `AppError` base class that carries a machine-readable `code`, an HTTP `statusCode`, and optional structured `details`. Infrastructure errors (K8s, Git provider) are always wrapped before surfacing so internal details stay in logs, not in HTTP responses. Unexpected errors are caught by a global `handleError` hook.
+
+The `App.Error` interface in `app.d.ts` defines the shape of errors available to `+error.svelte` pages — just `code` and `message`, both safe to display.
+
+## Error Hierarchy
+
+```
+Error (built-in)
+├── AppError                  — base for all application errors; has code + statusCode
+│   ├── NotFoundError         404  NOT_FOUND
+│   ├── UnauthorizedError     401  UNAUTHORIZED
+│   ├── ForbiddenError        403  FORBIDDEN
+│   ├── ValidationError       400  VALIDATION_ERROR  (+ per-field details)
+│   ├── ConflictError         409  CONFLICT
+│   └── InfrastructureError   502  INFRASTRUCTURE_ERROR  (wraps K8s / Git provider failures)
+└── ConfigError               —    not an HTTP error; fatal startup error
+```
+
+`ValidationError` carries a `details.fields` map of field name → error message for form validation. `InfrastructureError` wraps the original error as `cause` for logging but exposes only a sanitized message to clients.
+
+`ConfigError` is thrown during server init when a required config key is absent. It is not an HTTP error — it causes the process to exit (unhandled rejection) rather than serving a degraded state.
+
+## Error Flow
+
+### In route load functions and actions
+
+Throw typed `AppError` subclasses for domain errors, or use SvelteKit's `error()` helper for simple cases. `handleError` in `hooks.server.ts` maps both to the client.
+
+### In infrastructure modules
+
+Wrap all external call failures with `wrapInfraError(system, operation, cause)` before re-throwing. Never let raw fetch or Kubernetes client errors propagate to route handlers.
+
+### In `hooks.server.ts` (`handleError`)
+
+Catches every error that escapes a load/action/endpoint:
+
+- **`AppError`** → logged at `warn`, `{ code, message }` returned to `+error.svelte`
+- **anything else** → logged at `error` with full stack, generic `INTERNAL_ERROR` returned to client
+
+`requestId` from `event.locals` is included in every error log entry for correlation.
+
+No internal details (stack traces, system messages, env var values) ever reach the client.

+ 70 - 0
specs/designs/00-foundation/logging.md

@@ -0,0 +1,70 @@
+# Logging
+
+## Overview
+
+All server-side logging goes through [Winston](https://github.com/winstonjs/winston). A single root logger is created at module load and shared across the process. Each domain gets a **child logger** that automatically attaches a `domain` field to every log entry, making it easy to filter, tail, and route logs by domain in any log aggregation system.
+
+Logging is server-only. Client-side code does not import from `$lib/server/`.
+
+## Request Tracing
+
+Every request is assigned a UUID (`requestId`) generated in `handle()` and stored in both `event.locals.requestId` and the request-scoped `AsyncLocalStorage` context (`src/lib/server/context.ts`).
+
+A custom Winston format reads from `AsyncLocalStorage` on every log call and injects `requestId` automatically. This means every log line emitted anywhere during a request — across domain loggers, infrastructure clients, auth — carries the same `requestId` with no manual threading required.
+
+## Log Format
+
+Format is selected by `NODE_ENV`:
+
+| `NODE_ENV`    | Format                                                                                            | Use case             |
+| ------------- | ------------------------------------------------------------------------------------------------- | -------------------- |
+| `development` | Colorized, human-readable `HH:mm:ss LEVEL [domain] (requestId) message {...extra}`                | Local dev            |
+| anything else | Structured JSON with `timestamp`, `level`, `domain`, `requestId`, `message`, and any extra fields | Production / staging |
+
+JSON output is structured so every field is at the top level — no nested `meta` objects — which makes querying in tools like Loki, Elasticsearch, or CloudWatch straightforward.
+
+## Log Levels
+
+Standard Winston levels in descending severity: `error`, `warn`, `info`, `http`, `verbose`, `debug`, `silly`.
+
+Set `LOG_LEVEL` env var to control the minimum level emitted (default: `info`).
+
+## Domain Loggers
+
+Each server module imports its own named logger from `$lib/server/logger.ts`. The `domain` field is injected automatically by the child logger — callers never set it manually.
+
+| Logger export  | Domain tag       | Used in                         |
+| -------------- | ---------------- | ------------------------------- |
+| `logger`       | _(root, no tag)_ | General / unclassified          |
+| `serverLogger` | `server`         | `server.ts`, `hooks.server.ts`  |
+| `authLogger`   | `auth`           | `auth.ts`                       |
+| `gitLogger`    | `git`            | `git/github.ts`, `git/cache.ts` |
+| `k8sLogger`    | `k8s`            | `k8s.ts`                        |
+
+Domain-specific loggers for agents, models, tools, knowledge-bases, and gateways are added as those modules are implemented.
+
+## Usage
+
+```ts
+import { serverLogger as log } from '$lib/server/logger';
+
+log.info('Server ready');
+log.warn('K8s CR not found', { name, namespace });
+log.error('Failed to open PR', { err, agentName }); // always pass error as `err` for stack capture
+```
+
+Pass structured fields as the second argument rather than interpolating into the message string. This keeps the message string stable (searchable as a constant) and fields queryable individually.
+
+## What to Log
+
+| Event                  | Level   | Fields                                  |
+| ---------------------- | ------- | --------------------------------------- |
+| Server init / shutdown | `info`  | —                                       |
+| Component initialized  | `info`  | component name, key config (no secrets) |
+| Login success          | `info`  | `username`                              |
+| Login failure          | `warn`  | `username` (never log passwords)        |
+| Outbound API call      | `debug` | `url`, method                           |
+| Outbound API error     | `error` | `url`, `status`, `err`                  |
+| Unhandled exception    | `error` | `err` (stack)                           |
+
+Never log secrets, passwords, tokens, or full request/response bodies.

+ 72 - 0
specs/designs/00-foundation/multi-tenancy.md

@@ -0,0 +1,72 @@
+# Multi-Tenancy
+
+## Overview
+
+Agent PaaS is scoped by **workspaces**. A workspace is an isolated configuration and deployment environment: it has its own persistence backend (where CR YAML files are stored) and its own deployment target (the Kubernetes cluster or namespace where agents run). All catalog reads/writes and all live resource queries are routed to the active workspace.
+
+## Workspace
+
+A workspace has two independent dimensions:
+
+### Persistence
+
+Determines where CR YAML files are stored and how mutations are committed.
+
+| Type    | Fields                                   | Description                              |
+| ------- | ---------------------------------------- | ---------------------------------------- |
+| `local` | `directory`, `authorName`, `authorEmail` | Local filesystem repo via isomorphic-git |
+| `git`   | `repoUrl`, `branch`                      | Remote Git repository (GitHub)           |
+
+Both types accept optional directory overrides in `WorkspacePersistence` (for example `modelsDirectory`, `toolsDirectory`, `kbsDirectory`, and `agentsDirectory`). In the current implementation, catalog constructors still use their built-in default directories.
+
+The `local` type writes commits directly to the filesystem; mutations are auto-committed and the PR lifecycle is skipped. The `git` type writes via pull request; the PR may be auto-merged based on policy.
+
+### Deployment
+
+Determines where live runtime status is read from.
+
+| Type    | Fields                                             | Description                                  |
+| ------- | -------------------------------------------------- | -------------------------------------------- |
+| `k8s`   | `namespace`, `apiUrl?`, `token?`, `skipTlsVerify?` | Kubernetes API server (in-cluster or remote) |
+| `local` | —                                                  | No-op; no live status available              |
+
+## Server Initialization
+
+At startup, `Server.init()` reads the workspace list and constructs one `Catalog` and one `Resources` instance, each internally keyed by workspace ID:
+
+```
+Server.init()
+  → Catalog(workspaces)   — one GitClient + WorkspaceCatalog per workspace
+  → Resources(workspaces) — one K8sClient + WorkspaceResources per workspace (k8s only)
+```
+
+Both are fully initialized before the first request is served. Workspace configuration is managed by `WorkspaceManager` (accessed via `server.ws`); current implementation returns a single local workspace (`default`).
+
+## Workspace Selection
+
+### Cookie
+
+The active workspace is stored in the `workspaceId` cookie (`path: '/'`). On each request, `hooks.server.ts` reads this cookie into `event.locals.workspaceId`.
+
+### Default Selection
+
+`+layout.server.ts` calls `server.ws.list()` on every page navigation and returns the full list to the frontend. It validates the current `workspaceId` cookie against both the workspace list and the user's permitted workspaces (`user.workspaces`). If invalid or absent, it resets the cookie to the first workspace in the list.
+
+### Workspace Selector UI
+
+The workspace selector displays all workspaces the current user has access to and indicates the active one. Selecting a workspace writes the `workspaceId` cookie and reloads the current page.
+
+## Request Flow
+
+```
+Browser request
+  → hooks.server.ts
+      reads session cookie      → locals.user
+      reads workspaceId cookie  → locals.workspaceId
+
+  → route handler / load function
+    server.catalog.getWorkspaceCatalog(locals.workspace.id)     → catalog ops
+    server.resources.getWorkspaceResources(locals.workspace.id) → resource queries
+```
+
+If the workspace is absent or does not match any configured workspace, `getWorkspaceCatalog` / `getWorkspaceResources` throw `NotFoundError`.

+ 67 - 0
specs/designs/00-foundation/server.md

@@ -0,0 +1,67 @@
+# Server
+
+## Overview
+
+`src/lib/server/server.ts` is the single entry point for all server-side initialization. It owns every stateful server component and is responsible for wiring them together in dependency order before any request is served.
+
+The `Server` class is instantiated once as a module-level singleton. Its `init()` method is called immediately in `hooks.server.ts` and the resulting promise is awaited before any request is processed.
+
+## Architecture
+
+```
+Server (singleton)
+├── WorkspaceManager   — workspace list
+├── Auth               — credential validation + JWT
+├── Catalog            — per-workspace GitClient + domain catalogs
+│   └── Map<workspaceId, { workspace, gitClient, catalog: WorkspaceCatalog }>
+│       └── WorkspaceCatalog: { models, tools, kbs, documents, agents, stacks, secrets }
+└── Resources          — per-workspace K8sClient + domain resource watchers
+    └── Map<workspaceId, { workspace, k8sClient?, resources: WorkspaceResources }>
+          └── WorkspaceResources: { models, tools, kbs, documents, agents, stacks, secrets, subscribe }
+```
+
+`Catalog` and `Resources` are initialized after `WorkspaceManager` and receive its workspace list. Each workspace gets its own `GitClient` (for Catalog) and optionally a `K8sClient` (for Resources, only when `workspace.deployment.type === 'k8s'`).
+
+## Lifecycle
+
+```
+process start
+     │
+     ▼
+hooks.server.ts loads
+     │  new Server()        (construction — no I/O)
+     │  server.init()       (returns a Promise, stored as `ready`)
+     ▼
+handle() called per request
+     │  await ready         (blocks until init resolves)
+     │
+     ├─ init still running → request waits
+     └─ init complete      → request proceeds
+```
+
+Init order:
+
+1. `WorkspaceManager.init()` and `Auth.init()` — run in parallel
+2. `Catalog(workspaces).init()` and `Resources(workspaces).init()` — run in parallel after step 1
+
+If `init()` rejects, every subsequent `await ready` re-throws, causing all requests to fail with 500.
+
+## Config
+
+All config is read from environment variables. Components receive config from `Server.init()`; they do not read env vars themselves.
+
+```
+AUTH_JWT_SECRET   # optional — auth disabled when missing or when AUTH_USERS_FILE is missing
+AUTH_USERS_FILE   # optional — auth disabled when missing or when AUTH_JWT_SECRET is missing
+LOG_LEVEL         # optional — default: info
+NODE_ENV          # optional — "development" enables pretty log format; otherwise JSON
+```
+
+Workspace-level configuration (git endpoints, K8s API URL, namespace, credentials) is embedded in the workspace definition managed by `WorkspaceManager`. See `specs/designs/00-foundation/multi-tenancy.md`.
+
+## Design Rationale
+
+- **Workspace-centric**: each workspace owns its storage backend and deployment target; the server just coordinates initialization.
+- **Fail fast**: init failures prevent request handling rather than silently degrading.
+- **No lazy init**: all I/O happens in `init()`; request latency is predictable.
+- **SSO migration path**: swapping `Auth` for an OIDC implementation only requires updating `Server.init()`.

+ 77 - 0
specs/designs/01-data/catalog.md

@@ -0,0 +1,77 @@
+# Catalog
+
+## Overview
+
+The Catalog is the persistence layer for all agent configuration. Resources (agents, models, tools, knowledge bases) are stored as Kubernetes CR YAML files in a Git-backed store. The `Catalog` class is the top-level entry point; it holds one `WorkspaceCatalog` per workspace, each backed by a `GitClient` appropriate to the workspace's persistence type.
+
+## Architecture
+
+```
+Catalog
+└── Map<workspaceId, { workspace, gitClient, catalog: WorkspaceCatalog }>
+
+WorkspaceCatalog
+├── models: ModelCatalog
+├── tools: ToolCatalog
+├── kbs: KBCatalog
+├── documents: DocumentCatalog
+├── agents: AgentCatalog
+├── stacks: StackCatalog
+└── secrets: SecretCatalog
+
+*Catalog
+├── list()
+├── listChanges(id)    → PR history + commit log for that resource's file
+├── create(entity)
+├── update(entity)
+└── delete(id)
+```
+
+`Catalog` is initialized by `Server` at startup. `getWorkspaceCatalog(workspaceId)` returns the `WorkspaceCatalog` for the requested workspace ID; route handlers call this using `locals.workspace.id`.
+
+## GitCatalog — generic base
+
+`GitCatalog<T>` is an abstract base class that implements the full CRUD lifecycle for any entity type. Concrete subclasses (`GitModelCatalog`, `GitToolCatalog`, `GitKBCatalog`, `GitDocumentCatalog`, `GitAgentCatalog`) extend it, supplying entity-specific YAML parse and serialize functions.
+
+**Initialization + sync**: on the first `list()` call, `GitCatalog` reads the directory tree and parses all YAML files into entity objects. After that, it also registers `gitClient.onFileChange` and reloads entities when matching files change on the configured branch.
+
+**Mutation flow**:
+
+```
+create / update / delete
+  → render CR YAML
+  → gitClient.createFile / updateFile / deleteFile  (commits to branch)
+  → gitClient.createPR(title, branch)
+  → if PR is auto-merged: update in-memory list immediately
+```
+
+For the `local` persistence type, `createPR` returns a synthetic merged PR, so the in-memory list is always updated immediately.
+
+**ID generation**: `create()` derives the resource ID from the entity name via `slugify()` (lowercase, spaces → hyphens, non-alphanumeric stripped).
+
+**Branch naming**: `feat/create-entity-{name}`, `feat/update-entity-{id}`, `feat/delete-entity-{id}`.
+
+## Git Client
+
+`GitClient` is a provider-agnostic interface with operations for reading and writing files, traversing directory trees, listing and creating pull requests, and listing commits. The normalized PR and commit types carry only the fields needed by the catalog.
+
+### LocalClient
+
+Used when `workspace.persistence.type === 'local'`. Reads and writes files directly on the local filesystem using `isomorphic-git` for commit tracking.
+
+- All write operations are serialized through a promise queue to prevent concurrent commit conflicts.
+- `branch` parameter is ignored; all operations target the working tree.
+- `listPRs` returns `[]`; `createPR` returns a synthetic already-merged PR so catalog mutations always take the auto-merge path.
+- Auto-initializes the git repository if `.git` does not exist.
+
+### GitHubClient
+
+Used when `workspace.persistence.type === 'git'`. Auth via `Authorization: Bearer <TOKEN>`. Supports GitHub Enterprise via optional `baseUrl`. Uses `BranchCache` for all read operations.
+
+## Branch Snapshot Cache
+
+`BranchCache` (used by `GitHubClient`) avoids per-file HTTP round-trips by downloading the entire repository as a gzip-compressed tar archive in a single request, parsing it in memory, and serving subsequent reads from the snapshot.
+
+**Refresh strategy**: stale-while-revalidate (TTL: 60 s). A background refresh is triggered on the first access after the TTL elapses or after explicit `invalidate()`. Concurrent accesses during a refresh share the same in-flight promise and see the previous snapshot until the refresh completes.
+
+**Invalidation** happens automatically when any mutation completes (invalidate after write), and lazily when the TTL elapses on the next read.

+ 87 - 0
specs/designs/01-data/resource-events.md

@@ -0,0 +1,87 @@
+# Resource Events (Per-Workspace Subscription API)
+
+## Overview
+
+The Resources layer exposes a per-workspace in-process subscription API for status-change events. This API allows server-side callers to register one handler and receive a unified stream of resource status changes for a specific workspace.
+
+This is currently an internal TypeScript API (not an HTTP endpoint).
+
+## API Surface
+
+Entry point:
+
+- `server.resources.getWorkspaceResources(workspaceId)`
+
+Subscription method on `WorkspaceResources`:
+
+```ts
+subscribe(handler: (event: ResourceEvent) => void): () => void
+```
+
+Behavior:
+
+- Registers `handler` for resource events in one workspace.
+- Returns an unsubscribe function that removes all registrations created by this call.
+- Throws `NotFoundError` when `workspaceId` does not exist.
+
+## Event Shape
+
+`ResourceEvent` payload:
+
+```ts
+type ResourceEvent<T = unknown> = {
+	group: string;
+	version: string;
+	resource: string;
+	namespace: string;
+	name: string;
+	status: T;
+};
+```
+
+Fields identify the changed Kubernetes resource (`group`, `version`, `resource`, `namespace`, `name`) and carry the latest `status` snapshot.
+
+## Current Wiring
+
+Workspace-level `subscribe` currently aggregates these resource modules:
+
+- `models`
+- `tools`
+- `kbs`
+- `documents`
+- `agents`
+
+`stacks` and `secrets` expose their own `subscribe(...)` methods but are not included in the workspace-level aggregate subscription yet.
+
+## Emission Semantics
+
+Underlying emission is implemented by `K8sResource` and follows these rules:
+
+- Events are emitted only for update cycles where a resource already seen before changes `status`.
+- Initial add/list population does not emit an event.
+- Delete events are currently ignored.
+- Change detection is based on JSON string comparison of `status` (`JSON.stringify(status ?? null)`).
+- Handlers are called synchronously in registration order.
+
+## Initialization and Lifecycle
+
+- Subscriptions are attached to informer-backed resources.
+- Informers are lazily initialized on first resource access in each module.
+- The workspace-level subscribe function is lightweight and simply composes per-module unsubscribe callbacks.
+
+## Error and Concurrency Model
+
+- Registering/unregistering handlers is in-memory and synchronous.
+- Event dispatch is best-effort in-process delivery; there is no persistence, replay, cursor, or backpressure protocol.
+- If one handler throws, the exception bubbles from the dispatch loop (handlers should not throw).
+
+## Scope and Non-Goals
+
+This API is intentionally scoped to server-internal observers.
+
+Out of scope in current implementation:
+
+- cross-process fanout
+- durable event log or replay
+- transport protocol (SSE/WebSocket)
+- delivery guarantees (at-least-once/exactly-once)

+ 66 - 0
specs/designs/01-data/resources.md

@@ -0,0 +1,66 @@
+# Resources
+
+See also: `specs/designs/01-data/resource-events.md` for the per-workspace status/event subscription API.
+
+## Overview
+
+The Resources layer provides live runtime status for all managed objects. While configuration is persisted in Git (via the Catalog), runtime state (phase, readiness, error conditions) is read directly from the Kubernetes API server. The `Resources` class is the top-level entry point; it holds one `WorkspaceResources` per workspace, each backed by a `K8sClient` that maintains a synchronized in-memory cache via Kubernetes informers.
+
+All cache reads are **synchronous and allocation-free** — there are no per-request API calls for status data.
+
+## Architecture
+
+```
+Resources
+└── Map<workspaceId, { workspace, k8sClient?, resources: WorkspaceResources }>
+
+WorkspaceResources
+├── models: ModelResources
+├── tools: ToolResources
+├── kbs: KBResources
+├── documents: DocumentResources
+├── agents: AgentResources
+├── stacks: StackResources
+├── secrets: SecretResources
+└── subscribe(handler): unsubscribe
+
+ModelResources / ToolResources / KBResources / DocumentResources / AgentResources
+├── getManagedModels / getManagedTools
+├── getManagedModelResources / getManagedToolResources  → CoreResources (Deployments + Pods + Services)
+├── getManagedModelStatus / getManagedToolStatus
+├── getExternalModels / getExternalTools
+└── getExternalModelStatus / getExternalToolStatus
+```
+
+`Resources` is initialized by `Server` at startup. `getWorkspaceResources(workspaceId)` returns the `WorkspaceResources` for the requested workspace ID; route handlers call this using `locals.workspace.id`. Workspaces with `deployment.type === 'local'` have no `K8sClient` and no live resource data.
+
+## Watched Resources
+
+`K8sModelResources` starts informers for these resource types in the workspace namespace:
+
+| Resource         | Group           | Version    | Label selector                  |
+| ---------------- | --------------- | ---------- | ------------------------------- |
+| `managedmodels`  | `locostack.com` | `v1alpha1` | —                               |
+| `externalmodels` | `locostack.com` | `v1alpha1` | —                               |
+| `deployments`    | `apps`          | `v1`       | `locostack.com/component=Model` |
+| `pods`           | `core`          | `v1`       | `locostack.com/component=Model` |
+| `services`       | `core`          | `v1`       | `locostack.com/component=Model` |
+
+`K8sToolResources` mirrors the same pattern for tools (label `locostack.com/component=Tool`), watching `managedtools` and `externaltools`.
+
+**Lazy initialization**: the first call to any method triggers informer startup and waits for the initial LIST to complete. Concurrent calls share the same initialization promise.
+
+## K8sClient — list-watch cache
+
+`K8sClient` implements the list-watch cache used by both `K8sModelResources` and `K8sToolResources`.
+
+**List-watch pattern**: start with a full LIST to get the current state and a `resourceVersion`, then WATCH from that version to stream deltas. On stream close or error, relist and rewatch. All updates are applied synchronously to an in-memory `Map<name, object>`; reads are O(1) lookups.
+
+**Reconnection**: on informer error, the client logs the failure and restarts the informer after a 5-second delay. The stale cache continues to serve reads during the reconnect window.
+
+**KubeConfig resolution**:
+
+- `apiUrl` + `token` provided → explicit out-of-cluster config
+- both absent → `loadFromDefault()` (in-cluster service account, then `~/.kube/config`)
+
+`skipTlsVerify` disables certificate verification (dev only).