Implement multi-tenant platform API and standalone Vite control plane
All checks were successful
Build and Push Docker Image / build (pull_request) Successful in 1m32s

This commit is contained in:
2026-03-12 22:16:54 +01:00
parent 7da55c01a0
commit c24a4721bd
33 changed files with 7686 additions and 4 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.pi
node_modules
dist
data
.platform
.env
.env.*
.DS_Store
platform-ui

View File

@@ -85,3 +85,30 @@ SESSION_PERSIST=true
# ── Verbose tool logging ─────────────────────────────────────────────────────
# true → log tool start/end events to stderr
VERBOSE_TOOLS=false
# ── Multi-tenant platform API ────────────────────────────────────────────────
# Enables /v1/platform/* endpoints for auth, tenants, swarm agents and audit logs.
PLATFORM_ENABLED=true
# PLATFORM_DATA_DIR=/app/.platform
# JWT-style auth signing secret (set a strong random value in production).
PLATFORM_AUTH_SECRET=change-me-platform-secret
# 7 days
PLATFORM_AUTH_TOKEN_TTL_MINUTES=10080
# Swarm orchestration mode:
# auto -> use Docker Swarm when available, fallback to mock orchestrator
# docker -> require Docker Swarm (startup fails otherwise)
# mock -> always simulate swarm lifecycle in-process
PLATFORM_SWARM_MODE=auto
# PLATFORM_SWARM_DOCKER_BIN=docker
# Optional pre-existing swarm overlay network to attach services to.
# PLATFORM_SWARM_NETWORK=
# How often to poll swarm service status updates
PLATFORM_SWARM_POLL_INTERVAL_MS=5000
# Default image + tenant quotas
PLATFORM_DEFAULT_AGENT_IMAGE=moa-agent:latest
PLATFORM_TENANT_MAX_AGENTS=25
PLATFORM_TENANT_MAX_REPLICAS_PER_AGENT=5
PLATFORM_TENANT_MAX_TOTAL_REPLICAS=50

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
node_modules/
dist/
data/**
.platform/
.pi/
.DS_Store
.env

View File

@@ -18,8 +18,11 @@ RUN npm prune --omit=dev
# ─── runtime stage ────────────────────────────────────────────────────────────
FROM node:24-slim
# Install pi globally so it is available as a shell tool (e.g. inside bash tool)
RUN npm install -g @mariozechner/pi-coding-agent
# Install docker CLI for swarm orchestration + pi globally for shell tool access
RUN apt-get update \
&& apt-get install -y --no-install-recommends docker.io \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g @mariozechner/pi-coding-agent
WORKDIR /agent

View File

@@ -17,6 +17,7 @@ You can also keep one-shot mode (`RUN_MODE=single`) for script usage.
- Gateway internals: `docs/gateway.md`
- Web UI internals: `docs/web-ui.md`
- Channel integrations (Web UI, Slack, Matrix, custom): `docs/channels.md`
- Multi-tenant platform API + Swarm orchestration: `docs/platform.md`
---
@@ -137,3 +138,34 @@ Then post messages to `/v1/chat` or `/v1/chat/stream` with that `conversationId`
This keeps one agent session per thread across transports.
For detailed setup guidance per transport, see `docs/channels.md`.
---
## Multi-tenant Platform API
Additional endpoints exist under `/v1/platform/*` for:
- user register/login
- tenant workspaces + RBAC
- spawning/scaling/restarting/removing agent services on Docker Swarm
- tenant-scoped audit logs and SSE lifecycle events
See `docs/platform.md` for full endpoint reference and env vars.
---
## Separate Vite Platform UI
A standalone Vite app now lives in `platform-ui/`.
It is intended as the frontend control plane for a multi-tenant setup (tenant onboarding + spawning agent services on Docker Swarm).
Run it locally:
```bash
cd platform-ui
npm install
npm run dev
```
This UI is **not included** in the backend Docker runtime image.

View File

@@ -71,8 +71,24 @@ services:
# ── Verbose tool logging ───────────────────────────────────────────────
- VERBOSE_TOOLS=${VERBOSE_TOOLS:-false}
# ── Multi-tenant platform API ──────────────────────────────────────────
- PLATFORM_ENABLED=${PLATFORM_ENABLED:-true}
- PLATFORM_DATA_DIR=${PLATFORM_DATA_DIR:-/app/.platform}
- PLATFORM_AUTH_SECRET=${PLATFORM_AUTH_SECRET:-change-me-platform-secret}
- PLATFORM_AUTH_TOKEN_TTL_MINUTES=${PLATFORM_AUTH_TOKEN_TTL_MINUTES:-10080}
- PLATFORM_SWARM_MODE=${PLATFORM_SWARM_MODE:-auto}
- PLATFORM_SWARM_DOCKER_BIN=${PLATFORM_SWARM_DOCKER_BIN:-docker}
- PLATFORM_SWARM_NETWORK=${PLATFORM_SWARM_NETWORK:-}
- PLATFORM_SWARM_POLL_INTERVAL_MS=${PLATFORM_SWARM_POLL_INTERVAL_MS:-5000}
- PLATFORM_DEFAULT_AGENT_IMAGE=${PLATFORM_DEFAULT_AGENT_IMAGE:-moa-agent:latest}
- PLATFORM_TENANT_MAX_AGENTS=${PLATFORM_TENANT_MAX_AGENTS:-25}
- PLATFORM_TENANT_MAX_REPLICAS_PER_AGENT=${PLATFORM_TENANT_MAX_REPLICAS_PER_AGENT:-5}
- PLATFORM_TENANT_MAX_TOTAL_REPLICAS=${PLATFORM_TENANT_MAX_TOTAL_REPLICAS:-50}
volumes:
- ./data:/app
# Required for PLATFORM_SWARM_MODE=docker/auto to control host swarm.
- /var/run/docker.sock:/var/run/docker.sock
# Allows the container to reach Ollama (or any service) on the Docker host.
# On Mac/Windows host.docker.internal works without this; on Linux it needs

148
docs/platform.md Normal file
View File

@@ -0,0 +1,148 @@
# Multi-tenant platform API
This repository now includes a tenant-aware platform API under:
- `/v1/platform/*`
It is designed for a separate frontend (`platform-ui/`) that lets users register, manage tenant workspaces, and spawn agent services on Docker Swarm.
---
## Features
- User auth (register/login/me) with signed bearer tokens
- Multi-tenant RBAC (`owner`, `admin`, `member`, `viewer`)
- Tenant membership management
- Docker Swarm service lifecycle management (or mock fallback)
- Agent status polling + SSE event streaming
- Tenant-scoped audit logs
---
## Environment variables
See `.env.example` for all variables.
Important ones:
- `PLATFORM_ENABLED=true`
- `PLATFORM_AUTH_SECRET=<strong-secret>`
- `PLATFORM_SWARM_MODE=auto|docker|mock`
- `PLATFORM_DEFAULT_AGENT_IMAGE=moa-agent:latest`
Swarm mode behavior:
- `auto`: try Docker Swarm; fallback to mock orchestrator
- `docker`: require Swarm to be active
- `mock`: always simulate deployments (good for local UI development)
---
## Auth flow
### Register
`POST /v1/platform/auth/register`
```json
{
"name": "Alice",
"email": "alice@example.com",
"password": "supersecret",
"tenantName": "Acme"
}
```
### Login
`POST /v1/platform/auth/login`
### Current user
`GET /v1/platform/auth/me`
Use:
`Authorization: Bearer <token>`
---
## Tenants
- `GET /v1/platform/tenants`
- `POST /v1/platform/tenants`
Members:
- `GET /v1/platform/tenants/:tenantId/members`
- `POST /v1/platform/tenants/:tenantId/members`
`POST` body:
```json
{
"email": "bob@example.com",
"role": "admin"
}
```
---
## Agents (Swarm-backed)
List/create:
- `GET /v1/platform/tenants/:tenantId/agents`
- `POST /v1/platform/tenants/:tenantId/agents`
Create body:
```json
{
"name": "customer-support",
"image": "moa-agent:latest",
"replicas": 2,
"env": {
"PROVIDER": "anthropic",
"MODEL": "claude-sonnet-4-20250514"
}
}
```
Actions:
- `POST /v1/platform/tenants/:tenantId/agents/:agentId/scale`
- `POST /v1/platform/tenants/:tenantId/agents/:agentId/restart`
- `DELETE /v1/platform/tenants/:tenantId/agents/:agentId`
---
## Events and audit
Audit logs:
- `GET /v1/platform/tenants/:tenantId/audit?limit=200`
Buffered events:
- `GET /v1/platform/tenants/:tenantId/events?since=<seq>&limit=100`
Live stream (SSE):
- `GET /v1/platform/tenants/:tenantId/events/stream?since=<seq>`
SSE events:
- `ready`
- `platform_event` (agent lifecycle updates)
- `ping` (keepalive)
---
## Isolation model
Every platform endpoint enforces tenant membership.
- Tenant data is always resolved by `tenantId + authenticated user`
- Agent operations cannot cross tenant boundaries
- Tenant actions are audit logged

1
platform-ui/.env.example Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8787

24
platform-ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

44
platform-ui/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Platform UI (Vite + React)
This is a **separate frontend project** for the multi-tenant control plane.
It talks to backend endpoints under `/v1/platform/*` and provides:
- register/login
- tenant selection + creation
- tenant membership management (RBAC roles)
- agent create/scale/restart/remove actions
- live status updates from tenant event stream
- audit log view
## Local development
```bash
cd platform-ui
npm install
npm run dev
```
By default Vite runs on `http://localhost:5173`.
## API base URL
Create `platform-ui/.env.local`:
```bash
VITE_API_BASE_URL=http://localhost:8787
```
## Build
```bash
npm run build
npm run preview
```
## Important note
This frontend is intentionally **not part of the backend runtime image**.
- root Dockerfile copies only backend files
- root `.dockerignore` excludes `platform-ui/`

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
platform-ui/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>platform-ui</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2988
platform-ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
platform-ui/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "platform-ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

326
platform-ui/src/App.css Normal file
View File

@@ -0,0 +1,326 @@
.app {
max-width: 1200px;
margin: 0 auto;
padding: 1.25rem;
display: grid;
gap: 1rem;
}
.app.auth {
max-width: 680px;
}
.panel {
border: 1px solid #243354;
border-radius: 0.75rem;
padding: 1rem;
background: #111a2f;
}
.topbar {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.topbar-actions {
text-align: right;
}
.topbar-actions p {
margin: 0 0 0.5rem;
color: #c6d2ec;
}
.hero h1 {
margin: 0.25rem 0 0.55rem;
font-size: 1.7rem;
}
.hero p {
margin: 0;
color: #bbc7e2;
}
.eyebrow {
margin: 0;
color: #8ba1d8;
text-transform: uppercase;
font-size: 0.75rem;
letter-spacing: 0.08em;
font-weight: 700;
}
h2 {
margin: 0 0 0.75rem;
font-size: 1.1rem;
}
.muted {
margin: 0;
color: #97a4c2;
font-size: 0.9rem;
}
.notice,
.error {
margin: 0;
border-radius: 0.6rem;
padding: 0.65rem 0.8rem;
}
.notice {
background: #173455;
border: 1px solid #295382;
color: #d7e8ff;
}
.error {
background: #4d1d2a;
border: 1px solid #7f3147;
color: #ffc8d6;
}
.inline-error {
margin: 0.35rem 0 0;
font-size: 0.75rem;
padding: 0.25rem 0.4rem;
}
.form {
display: grid;
gap: 0.75rem;
}
.form.compact {
grid-template-columns: 1fr 160px auto;
align-items: center;
}
label {
display: grid;
gap: 0.35rem;
color: #bec9e3;
font-size: 0.85rem;
}
input,
select,
textarea,
button {
font: inherit;
color: inherit;
}
input,
select,
textarea {
border: 1px solid #324672;
border-radius: 0.55rem;
background: #0f1729;
color: #f1f5ff;
padding: 0.55rem 0.65rem;
}
textarea {
min-height: 100px;
resize: vertical;
}
button {
border: 1px solid #3f67c4;
border-radius: 0.55rem;
background: #2d58be;
color: #f5f8ff;
padding: 0.55rem 0.8rem;
cursor: pointer;
font-weight: 600;
}
button:hover {
filter: brightness(1.05);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button.danger {
border-color: #9f3650;
background: #79283d;
}
.auth-switcher {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
}
.auth-switcher button {
background: #1b2845;
border-color: #344a78;
}
.auth-switcher button.active {
background: #2d58be;
border-color: #3f67c4;
}
.tenant-row {
display: grid;
grid-template-columns: minmax(240px, 1fr) minmax(220px, 1fr);
gap: 1rem;
align-items: end;
}
.inline-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 0.5rem;
}
.grid.two-columns {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.simple-list,
.audit-list {
margin: 0;
padding-left: 1rem;
display: grid;
gap: 0.4rem;
}
.simple-list li,
.audit-list li {
color: #ced8ef;
}
.audit-list li {
display: grid;
grid-template-columns: 170px 1fr auto;
align-items: center;
gap: 0.7rem;
}
code {
font-family: 'SFMono-Regular', Menlo, Monaco, Consolas, 'Liberation Mono',
'Courier New', monospace;
font-size: 0.8rem;
border: 1px solid #2f466f;
border-radius: 0.35rem;
background: #0f1729;
padding: 0.12rem 0.35rem;
}
.section-header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: center;
margin-bottom: 0.75rem;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.88rem;
}
th,
td {
text-align: left;
border-bottom: 1px solid #2a3c64;
padding: 0.55rem 0.35rem;
vertical-align: top;
}
th {
color: #95a9d6;
font-weight: 600;
}
.status {
display: inline-block;
border-radius: 999px;
padding: 0.18rem 0.55rem;
border: 1px solid transparent;
text-transform: capitalize;
font-size: 0.75rem;
font-weight: 600;
}
.status-pending,
.status-unknown {
border-color: #ab8433;
background: rgba(173, 133, 53, 0.2);
color: #ffd890;
}
.status-running {
border-color: #2f854a;
background: rgba(52, 157, 84, 0.2);
color: #98f1b7;
}
.status-failed {
border-color: #b2394f;
background: rgba(178, 57, 79, 0.2);
color: #ffb8c7;
}
.status-stopped,
.status-removing {
border-color: #5a6586;
background: rgba(81, 94, 126, 0.2);
color: #d3dbef;
}
.replicas-cell {
display: grid;
gap: 0.25rem;
}
.replicas-cell input {
max-width: 84px;
}
.replicas-cell small {
color: #96a3c2;
}
.actions {
display: flex;
gap: 0.35rem;
flex-wrap: wrap;
}
@media (max-width: 980px) {
.topbar,
.tenant-row,
.grid.two-columns {
grid-template-columns: 1fr;
display: grid;
}
.topbar-actions {
text-align: left;
}
.form.compact {
grid-template-columns: 1fr;
}
.audit-list li {
grid-template-columns: 1fr;
gap: 0.15rem;
}
table {
font-size: 0.8rem;
}
}

1368
platform-ui/src/App.tsx Normal file

File diff suppressed because it is too large Load Diff

24
platform-ui/src/index.css Normal file
View File

@@ -0,0 +1,24 @@
:root {
font-family: Inter, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
sans-serif;
line-height: 1.45;
font-weight: 400;
color: #edf2ff;
background: #0a1020;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
}
a {
color: inherit;
}

10
platform-ui/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})

View File

@@ -28,9 +28,27 @@ export interface GatewayConfig {
enableWebUi: boolean;
}
export type PlatformSwarmMode = "auto" | "docker" | "mock";
export interface PlatformConfig {
enabled: boolean;
dataDir: string;
authSecret: string;
authTokenTtlMinutes: number;
swarmMode: PlatformSwarmMode;
swarmDockerBin: string;
swarmNetwork?: string;
swarmPollIntervalMs: number;
defaultAgentImage: string;
tenantDefaultMaxAgents: number;
tenantDefaultMaxReplicasPerAgent: number;
tenantDefaultMaxTotalReplicas: number;
}
export interface AppConfig {
agent: AgentConfig;
gateway: GatewayConfig;
platform: PlatformConfig;
}
function env(name: string): string | undefined {
@@ -74,6 +92,26 @@ function parseBoolEnv(name: string, fallback: boolean): boolean {
}
}
function parseEnumEnv<T extends string>(
name: string,
fallback: T,
allowed: readonly T[],
): T {
const value = env(name);
if (!value) {
return fallback;
}
const normalized = value.toLowerCase() as T;
if (!allowed.includes(normalized)) {
throw new Error(
`Environment variable ${name} must be one of: ${allowed.join(", ")} (received ${value}).`,
);
}
return normalized;
}
function resolveThinkingLevel(value: string | undefined): ThinkingLevel {
const normalized = (value ?? "off").toLowerCase();
const valid: ThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"];
@@ -86,7 +124,7 @@ function resolveThinkingLevel(value: string | undefined): ThinkingLevel {
}
export function loadConfig(): AppConfig {
const cwd = path.resolve(env("CWD") ?? "/app");
const cwd = path.resolve(env("CWD") ?? process.cwd());
const runModeRaw = (env("RUN_MODE") ?? "gateway").toLowerCase();
const runMode = runModeRaw === "single" ? "single" : "gateway";
@@ -116,6 +154,27 @@ export function loadConfig(): AppConfig {
authToken: env("GATEWAY_AUTH_TOKEN"),
enableWebUi: parseBoolEnv("GATEWAY_ENABLE_WEB_UI", true),
},
platform: {
enabled: parseBoolEnv("PLATFORM_ENABLED", true),
dataDir: path.resolve(env("PLATFORM_DATA_DIR") ?? path.join(cwd, ".platform")),
authSecret: env("PLATFORM_AUTH_SECRET") ?? "dev-platform-secret",
authTokenTtlMinutes: parseIntEnv("PLATFORM_AUTH_TOKEN_TTL_MINUTES", 60 * 24 * 7),
swarmMode: parseEnumEnv<PlatformSwarmMode>("PLATFORM_SWARM_MODE", "auto", [
"auto",
"docker",
"mock",
]),
swarmDockerBin: env("PLATFORM_SWARM_DOCKER_BIN") ?? "docker",
swarmNetwork: env("PLATFORM_SWARM_NETWORK"),
swarmPollIntervalMs: parseIntEnv("PLATFORM_SWARM_POLL_INTERVAL_MS", 5000),
defaultAgentImage: env("PLATFORM_DEFAULT_AGENT_IMAGE") ?? "moa-agent:latest",
tenantDefaultMaxAgents: parseIntEnv("PLATFORM_TENANT_MAX_AGENTS", 25),
tenantDefaultMaxReplicasPerAgent: parseIntEnv(
"PLATFORM_TENANT_MAX_REPLICAS_PER_AGENT",
5,
),
tenantDefaultMaxTotalReplicas: parseIntEnv("PLATFORM_TENANT_MAX_TOTAL_REPLICAS", 50),
},
};
}

View File

@@ -6,6 +6,7 @@ import type { ChatRequest } from "../conversation-manager.js";
import { ConversationManager } from "../conversation-manager.js";
import { toGatewayEvents } from "./events.js";
import { getWebUiHtml } from "./web-ui.js";
import { PlatformHttpApi } from "../platform/http-api.js";
interface ChatRequestBody {
conversationId?: unknown;
@@ -38,6 +39,7 @@ export class GatewayHttpServer {
constructor(
private readonly manager: ConversationManager,
private readonly config: GatewayConfig,
private readonly platformApi?: PlatformHttpApi,
) {
this.server = createServer((req, res) => {
this.handleRequest(req, res).catch((error) => {
@@ -93,6 +95,13 @@ export class GatewayHttpServer {
const { pathname } = url;
const method = req.method ?? "GET";
if (this.platformApi && pathname.startsWith("/v1/platform")) {
const handled = await this.platformApi.handle(req, res, url, method);
if (handled) {
return;
}
}
if (method === "GET" && pathname === "/health") {
this.sendJson(res, 200, { ok: true });
return;

View File

@@ -2,6 +2,8 @@ import { loadConfig } from "./config.js";
import { runSingleShot } from "./single-shot.js";
import { ConversationManager } from "./conversation-manager.js";
import { GatewayHttpServer } from "./gateway/server.js";
import { PlatformService } from "./platform/service.js";
import { PlatformHttpApi } from "./platform/http-api.js";
async function run(): Promise<void> {
const config = loadConfig();
@@ -14,7 +16,12 @@ async function run(): Promise<void> {
const manager = new ConversationManager(config.agent);
await manager.init();
const server = new GatewayHttpServer(manager, config.gateway);
const platformService = new PlatformService(config.platform);
await platformService.init();
const platformApi = config.platform.enabled ? new PlatformHttpApi(platformService) : undefined;
const server = new GatewayHttpServer(manager, config.gateway, platformApi);
await server.start();
let shuttingDown = false;
@@ -27,6 +34,7 @@ async function run(): Promise<void> {
console.error(`\n[gateway] Received ${signal}, shutting down...`);
await server.stop();
await manager.shutdown();
await platformService.shutdown();
process.exit(0);
};

111
src/platform/auth.ts Normal file
View File

@@ -0,0 +1,111 @@
import {
createHmac,
randomBytes,
scrypt as scryptCallback,
timingSafeEqual,
} from "node:crypto";
import { promisify } from "node:util";
import type { AuthTokenPayload } from "./types.js";
const scrypt = promisify(scryptCallback);
function base64UrlEncode(value: string | Buffer): string {
return Buffer.from(value)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function base64UrlDecode(value: string): Buffer {
const normalized = value.replace(/-/g, "+").replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
return Buffer.from(padded, "base64");
}
function secureCompare(left: string, right: string): boolean {
const leftBuffer = Buffer.from(left);
const rightBuffer = Buffer.from(right);
if (leftBuffer.length !== rightBuffer.length) {
return false;
}
return timingSafeEqual(leftBuffer, rightBuffer);
}
export async function hashPassword(password: string): Promise<{
salt: string;
hash: string;
}> {
const salt = randomBytes(16).toString("hex");
const derived = (await scrypt(password, salt, 64)) as Buffer;
return {
salt,
hash: derived.toString("hex"),
};
}
export async function verifyPassword(
password: string,
salt: string,
expectedHash: string,
): Promise<boolean> {
const derived = (await scrypt(password, salt, 64)) as Buffer;
return secureCompare(derived.toString("hex"), expectedHash);
}
export function signAuthToken(payload: AuthTokenPayload, secret: string): string {
const header = { alg: "HS256", typ: "JWT" };
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const input = `${encodedHeader}.${encodedPayload}`;
const signature = createHmac("sha256", secret).update(input).digest("base64url");
return `${input}.${signature}`;
}
export function parseAndVerifyAuthToken(
token: string,
secret: string,
): AuthTokenPayload | undefined {
const parts = token.split(".");
if (parts.length !== 3) {
return undefined;
}
const [encodedHeader, encodedPayload, signature] = parts;
const input = `${encodedHeader}.${encodedPayload}`;
const expectedSignature = createHmac("sha256", secret).update(input).digest("base64url");
if (!secureCompare(signature, expectedSignature)) {
return undefined;
}
try {
const header = JSON.parse(base64UrlDecode(encodedHeader).toString("utf8")) as {
alg?: string;
typ?: string;
};
if (header.alg !== "HS256" || header.typ !== "JWT") {
return undefined;
}
const payload = JSON.parse(base64UrlDecode(encodedPayload).toString("utf8")) as AuthTokenPayload;
if (typeof payload.sub !== "string" || typeof payload.email !== "string") {
return undefined;
}
if (typeof payload.iat !== "number" || typeof payload.exp !== "number") {
return undefined;
}
const nowSeconds = Math.floor(Date.now() / 1000);
if (payload.exp <= nowSeconds) {
return undefined;
}
return payload;
} catch {
return undefined;
}
}

362
src/platform/http-api.ts Normal file
View File

@@ -0,0 +1,362 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { URL } from "node:url";
import { PlatformError, type AddTenantMemberInput, type PlatformService } from "./service.js";
import type { TenantRole } from "./types.js";
interface RegisterBody {
name?: unknown;
email?: unknown;
password?: unknown;
tenantName?: unknown;
}
interface LoginBody {
email?: unknown;
password?: unknown;
}
interface CreateTenantBody {
name?: unknown;
}
interface AddMemberBody {
email?: unknown;
role?: unknown;
}
interface CreateAgentBody {
name?: unknown;
image?: unknown;
replicas?: unknown;
env?: unknown;
}
interface ScaleAgentBody {
replicas?: unknown;
}
function parseIntQuery(value: string | null, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
if (Number.isNaN(parsed)) {
return fallback;
}
return parsed;
}
function asRole(value: unknown): TenantRole | undefined {
if (value === "owner" || value === "admin" || value === "member" || value === "viewer") {
return value;
}
return undefined;
}
export class PlatformHttpApi {
constructor(private readonly service: PlatformService) {}
async handle(
req: IncomingMessage,
res: ServerResponse,
url: URL,
method: string,
): Promise<boolean> {
const path = url.pathname;
if (!path.startsWith("/v1/platform")) {
return false;
}
try {
if (method === "GET" && path === "/v1/platform/health") {
this.sendJson(res, 200, {
ok: true,
swarmDriver: this.service.swarmDriver,
swarmMode: this.service.swarmMode,
});
return true;
}
if (method === "POST" && path === "/v1/platform/auth/register") {
const body = (await this.readJsonBody(req)) as RegisterBody;
const session = await this.service.register({
name: String(body.name ?? ""),
email: String(body.email ?? ""),
password: String(body.password ?? ""),
tenantName: typeof body.tenantName === "string" ? body.tenantName : undefined,
});
this.sendJson(res, 201, session);
return true;
}
if (method === "POST" && path === "/v1/platform/auth/login") {
const body = (await this.readJsonBody(req)) as LoginBody;
const session = await this.service.login({
email: String(body.email ?? ""),
password: String(body.password ?? ""),
});
this.sendJson(res, 200, session);
return true;
}
if (method === "GET" && path === "/v1/platform/auth/me") {
const principal = await this.service.authenticate(req.headers.authorization);
const me = await this.service.getCurrentUser(principal.user.id);
this.sendJson(res, 200, me);
return true;
}
if (method === "GET" && path === "/v1/platform/tenants") {
const principal = await this.service.authenticate(req.headers.authorization);
const tenants = await this.service.listTenants(principal.user.id);
this.sendJson(res, 200, { tenants });
return true;
}
if (method === "POST" && path === "/v1/platform/tenants") {
const principal = await this.service.authenticate(req.headers.authorization);
const body = (await this.readJsonBody(req)) as CreateTenantBody;
const tenant = await this.service.createTenant(principal.user.id, {
name: String(body.name ?? ""),
});
this.sendJson(res, 201, tenant);
return true;
}
const membersMatch = path.match(/^\/v1\/platform\/tenants\/([^/]+)\/members$/);
if (membersMatch && method === "GET") {
const principal = await this.service.authenticate(req.headers.authorization);
const tenantId = this.decodePathSegment(membersMatch[1]);
const members = await this.service.listTenantMembers(principal.user.id, tenantId);
this.sendJson(res, 200, { members });
return true;
}
if (membersMatch && method === "POST") {
const principal = await this.service.authenticate(req.headers.authorization);
const tenantId = this.decodePathSegment(membersMatch[1]);
const body = (await this.readJsonBody(req)) as AddMemberBody;
const role = asRole(body.role);
if (!role) {
throw new PlatformError(400, "role must be one of owner, admin, member, viewer.");
}
const payload: AddTenantMemberInput = {
email: String(body.email ?? ""),
role,
};
const membership = await this.service.addOrUpdateTenantMember(
principal.user.id,
tenantId,
payload,
);
this.sendJson(res, 200, membership);
return true;
}
const agentsMatch = path.match(/^\/v1\/platform\/tenants\/([^/]+)\/agents$/);
if (agentsMatch && method === "GET") {
const principal = await this.service.authenticate(req.headers.authorization);
const tenantId = this.decodePathSegment(agentsMatch[1]);
const agents = await this.service.listAgents(principal.user.id, tenantId);
this.sendJson(res, 200, { agents });
return true;
}
if (agentsMatch && method === "POST") {
const principal = await this.service.authenticate(req.headers.authorization);
const tenantId = this.decodePathSegment(agentsMatch[1]);
const body = (await this.readJsonBody(req)) as CreateAgentBody;
const agent = await this.service.createAgent(principal.user.id, tenantId, {
name: String(body.name ?? ""),
image: typeof body.image === "string" ? body.image : undefined,
replicas: typeof body.replicas === "number" ? body.replicas : undefined,
env: body.env,
});
this.sendJson(res, 201, agent);
return true;
}
const scaleMatch = path.match(/^\/v1\/platform\/tenants\/([^/]+)\/agents\/([^/]+)\/scale$/);
if (scaleMatch && method === "POST") {
const principal = await this.service.authenticate(req.headers.authorization);
const body = (await this.readJsonBody(req)) as ScaleAgentBody;
const replicas = Number(body.replicas ?? NaN);
if (!Number.isFinite(replicas)) {
throw new PlatformError(400, "replicas must be a number.");
}
const tenantId = this.decodePathSegment(scaleMatch[1]);
const agentId = this.decodePathSegment(scaleMatch[2]);
const agent = await this.service.scaleAgent(principal.user.id, tenantId, agentId, {
replicas,
});
this.sendJson(res, 200, agent);
return true;
}
const restartMatch = path.match(
/^\/v1\/platform\/tenants\/([^/]+)\/agents\/([^/]+)\/restart$/,
);
if (restartMatch && method === "POST") {
const principal = await this.service.authenticate(req.headers.authorization);
const tenantId = this.decodePathSegment(restartMatch[1]);
const agentId = this.decodePathSegment(restartMatch[2]);
const agent = await this.service.restartAgent(principal.user.id, tenantId, agentId);
this.sendJson(res, 200, agent);
return true;
}
const removeMatch = path.match(/^\/v1\/platform\/tenants\/([^/]+)\/agents\/([^/]+)$/);
if (removeMatch && method === "DELETE") {
const principal = await this.service.authenticate(req.headers.authorization);
const tenantId = this.decodePathSegment(removeMatch[1]);
const agentId = this.decodePathSegment(removeMatch[2]);
const result = await this.service.removeAgent(principal.user.id, tenantId, agentId);
this.sendJson(res, 200, result);
return true;
}
const auditMatch = path.match(/^\/v1\/platform\/tenants\/([^/]+)\/audit$/);
if (auditMatch && method === "GET") {
const principal = await this.service.authenticate(req.headers.authorization);
const tenantId = this.decodePathSegment(auditMatch[1]);
const limit = parseIntQuery(url.searchParams.get("limit"), 200);
const entries = await this.service.listAuditLogs(principal.user.id, tenantId, limit);
this.sendJson(res, 200, { entries });
return true;
}
const eventsMatch = path.match(/^\/v1\/platform\/tenants\/([^/]+)\/events$/);
if (eventsMatch && method === "GET") {
const principal = await this.service.authenticate(req.headers.authorization);
const tenantId = this.decodePathSegment(eventsMatch[1]);
const since = parseIntQuery(url.searchParams.get("since"), 0);
const limit = parseIntQuery(url.searchParams.get("limit"), 100);
const events = await this.service.listTenantEvents(principal.user.id, tenantId, since, limit);
this.sendJson(res, 200, { events });
return true;
}
const streamMatch = path.match(/^\/v1\/platform\/tenants\/([^/]+)\/events\/stream$/);
if (streamMatch && method === "GET") {
const principal = await this.service.authenticate(req.headers.authorization);
const tenantId = this.decodePathSegment(streamMatch[1]);
await this.service.ensureTenantAccess(principal.user.id, tenantId);
const since = parseIntQuery(url.searchParams.get("since"), 0);
this.writeSseHeaders(res);
this.writeSse(res, "ready", { ok: true, tenantId, since });
const historical = await this.service.listTenantEvents(principal.user.id, tenantId, since, 200);
for (const event of historical) {
this.writeSse(res, "platform_event", event);
}
const heartbeat = setInterval(() => {
this.writeSse(res, "ping", { ts: new Date().toISOString() });
}, 15_000);
heartbeat.unref();
const unsubscribe = this.service.subscribeTenantEvents(tenantId, (event) => {
this.writeSse(res, "platform_event", event);
});
req.on("close", () => {
clearInterval(heartbeat);
unsubscribe();
res.end();
});
return true;
}
this.sendJson(res, 404, { error: "Not found" });
return true;
} catch (error) {
if (error instanceof PlatformError) {
this.sendJson(res, error.statusCode, { error: error.message });
return true;
}
const message = error instanceof Error ? error.message : String(error);
console.error("[platform] API error:", error);
this.sendJson(res, 500, { error: message || "Internal server error" });
return true;
}
}
private async readJsonBody(req: IncomingMessage, maxBytes = 1024 * 1024): Promise<unknown> {
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of req) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += buffer.length;
if (total > maxBytes) {
throw new PlatformError(413, `Request body exceeds ${maxBytes} bytes.`);
}
chunks.push(buffer);
}
if (chunks.length === 0) {
return {};
}
const raw = Buffer.concat(chunks).toString("utf8").trim();
if (!raw) {
return {};
}
try {
return JSON.parse(raw);
} catch {
throw new PlatformError(400, "Request body must be valid JSON.");
}
}
private decodePathSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
throw new PlatformError(400, "Invalid path segment encoding.");
}
}
private sendJson(res: ServerResponse, statusCode: number, body: unknown): void {
res.statusCode = statusCode;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(`${JSON.stringify(body)}\n`);
}
private writeSseHeaders(res: ServerResponse): void {
res.statusCode = 200;
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache, no-transform");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
}
private writeSse(res: ServerResponse, event: string, data: unknown): void {
if (res.writableEnded || res.destroyed) {
return;
}
const payload = typeof data === "string" ? data : JSON.stringify(data);
res.write(`event: ${event}\n`);
res.write(`data: ${payload}\n\n`);
}
}

1303
src/platform/service.ts Normal file

File diff suppressed because it is too large Load Diff

90
src/platform/store.ts Normal file
View File

@@ -0,0 +1,90 @@
import fs from "node:fs/promises";
import path from "node:path";
import { createEmptyPlatformState, type PlatformState } from "./types.js";
function normalizeState(raw: unknown): PlatformState {
if (!raw || typeof raw !== "object") {
return createEmptyPlatformState();
}
const candidate = raw as Partial<PlatformState>;
return {
version: 1,
users: Array.isArray(candidate.users) ? candidate.users : [],
tenants: Array.isArray(candidate.tenants) ? candidate.tenants : [],
memberships: Array.isArray(candidate.memberships) ? candidate.memberships : [],
agents: Array.isArray(candidate.agents) ? candidate.agents : [],
auditLogs: Array.isArray(candidate.auditLogs) ? candidate.auditLogs : [],
};
}
export class PlatformStore {
private readonly statePath: string;
private initialized = false;
private state: PlatformState = createEmptyPlatformState();
private queue: Promise<void> = Promise.resolve();
constructor(private readonly dataDir: string) {
this.statePath = path.join(this.dataDir, "state.json");
}
async init(): Promise<void> {
if (this.initialized) {
return;
}
try {
const raw = await fs.readFile(this.statePath, "utf8");
this.state = normalizeState(JSON.parse(raw));
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code !== "ENOENT") {
throw error;
}
this.state = createEmptyPlatformState();
}
this.initialized = true;
}
async read<T>(reader: (state: PlatformState) => T): Promise<T> {
await this.init();
return reader(this.state);
}
async update<T>(updater: (state: PlatformState) => T | Promise<T>): Promise<T> {
await this.init();
let taskResult: Promise<T> | undefined;
const commitTask = async (): Promise<void> => {
const draft = structuredClone(this.state);
const result = await updater(draft);
this.state = draft;
await this.persistState();
taskResult = Promise.resolve(result);
};
const run = this.queue.then(commitTask, commitTask);
this.queue = run.then(
() => undefined,
() => undefined,
);
await run;
if (!taskResult) {
throw new Error("Failed to commit state update.");
}
return taskResult;
}
private async persistState(): Promise<void> {
await fs.mkdir(this.dataDir, { recursive: true });
const serialized = `${JSON.stringify(this.state, null, 2)}\n`;
const tempPath = `${this.statePath}.tmp`;
await fs.writeFile(tempPath, serialized, "utf8");
await fs.rename(tempPath, this.statePath);
}
}

366
src/platform/swarm.ts Normal file
View File

@@ -0,0 +1,366 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import type { PlatformConfig } from "../config.js";
import { createId, nowIso } from "./utils.js";
const execFileAsync = promisify(execFile);
export type SwarmDriver = "docker" | "mock";
export interface SwarmServiceSpec {
serviceName: string;
image: string;
replicas: number;
env: Record<string, string>;
labels: Record<string, string>;
network?: string;
}
export interface SwarmServiceInfo {
serviceName: string;
serviceId?: string;
desiredReplicas: number;
runningReplicas: number;
status: "pending" | "running" | "failed" | "stopped";
message?: string;
}
export interface SwarmOrchestrator {
readonly driver: SwarmDriver;
init(): Promise<void>;
createService(spec: SwarmServiceSpec): Promise<{ serviceId?: string }>;
scaleService(serviceName: string, replicas: number): Promise<void>;
restartService(serviceName: string): Promise<void>;
removeService(serviceName: string): Promise<void>;
inspectService(serviceName: string): Promise<SwarmServiceInfo | undefined>;
}
interface DockerTask {
CurrentState?: string;
DesiredState?: string;
Error?: string;
}
class DockerSwarmOrchestrator implements SwarmOrchestrator {
readonly driver: SwarmDriver = "docker";
constructor(private readonly dockerBin: string) {}
async init(): Promise<void> {
const stateRaw = await this.run([
"info",
"--format",
"{{json .Swarm.LocalNodeState}}",
]);
const nodeState = stateRaw.replace(/^"|"$/g, "");
if (nodeState !== "active") {
throw new Error(
`Docker swarm is not active (LocalNodeState=${nodeState || "unknown"}).`,
);
}
}
async createService(spec: SwarmServiceSpec): Promise<{ serviceId?: string }> {
const args = [
"service",
"create",
"--detach=true",
"--name",
spec.serviceName,
"--replicas",
String(spec.replicas),
];
for (const [key, value] of Object.entries(spec.labels)) {
args.push("--label", `${key}=${value}`);
}
for (const [key, value] of Object.entries(spec.env)) {
args.push("--env", `${key}=${value}`);
}
if (spec.network) {
args.push("--network", spec.network);
}
args.push(spec.image);
const output = await this.run(args);
const serviceId = output.split(/\s+/).at(-1) ?? undefined;
return { serviceId };
}
async scaleService(serviceName: string, replicas: number): Promise<void> {
await this.run(["service", "scale", `${serviceName}=${replicas}`]);
}
async restartService(serviceName: string): Promise<void> {
await this.run(["service", "update", "--force", serviceName]);
}
async removeService(serviceName: string): Promise<void> {
try {
await this.run(["service", "rm", serviceName]);
} catch (error) {
const message = this.extractErrorMessage(error);
if (message.includes("No such service")) {
return;
}
throw error;
}
}
async inspectService(serviceName: string): Promise<SwarmServiceInfo | undefined> {
let inspectRaw: string;
try {
inspectRaw = await this.run([
"service",
"inspect",
serviceName,
"--format",
"{{json .}}",
]);
} catch (error) {
const message = this.extractErrorMessage(error);
if (message.includes("No such service")) {
return undefined;
}
throw error;
}
const inspect = JSON.parse(inspectRaw) as {
ID?: string;
Spec?: {
Mode?: {
Replicated?: {
Replicas?: number;
};
};
};
};
const desiredReplicas = inspect.Spec?.Mode?.Replicated?.Replicas ?? 0;
const taskOutput = await this.run([
"service",
"ps",
serviceName,
"--no-trunc",
"--format",
"{{json .}}",
]);
const tasks: DockerTask[] = taskOutput
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.map((line) => JSON.parse(line) as DockerTask);
let runningReplicas = 0;
let failedReplicas = 0;
let message = "";
for (const task of tasks) {
const currentState = task.CurrentState ?? "";
const desiredState = task.DesiredState ?? "";
if (desiredState.toLowerCase() === "shutdown") {
continue;
}
if (currentState.startsWith("Running")) {
runningReplicas += 1;
continue;
}
if (
currentState.startsWith("Failed") ||
currentState.startsWith("Rejected") ||
currentState.startsWith("Complete")
) {
failedReplicas += 1;
if (!message) {
message = task.Error || currentState;
}
}
}
let status: SwarmServiceInfo["status"] = "pending";
if (desiredReplicas === 0) {
status = "stopped";
} else if (runningReplicas >= desiredReplicas && failedReplicas === 0) {
status = "running";
} else if (failedReplicas > 0 && runningReplicas === 0) {
status = "failed";
}
return {
serviceName,
serviceId: inspect.ID,
desiredReplicas,
runningReplicas,
status,
message: message || undefined,
};
}
private async run(args: string[]): Promise<string> {
const { stdout } = await execFileAsync(this.dockerBin, args, {
timeout: 30_000,
maxBuffer: 1024 * 1024,
});
return stdout.trim();
}
private extractErrorMessage(error: unknown): string {
if (!(error instanceof Error)) {
return String(error);
}
const errorWithStderr = error as Error & { stderr?: string };
return (errorWithStderr.stderr ?? error.message).trim();
}
}
interface MockService {
id: string;
serviceName: string;
image: string;
desiredReplicas: number;
runningReplicas: number;
state: "pending" | "running" | "failed" | "stopped";
message?: string;
updatedAt: string;
}
class MockSwarmOrchestrator implements SwarmOrchestrator {
readonly driver: SwarmDriver = "mock";
private readonly services = new Map<string, MockService>();
async init(): Promise<void> {
// No-op
}
async createService(spec: SwarmServiceSpec): Promise<{ serviceId?: string }> {
const id = createId("svc");
const service: MockService = {
id,
serviceName: spec.serviceName,
image: spec.image,
desiredReplicas: spec.replicas,
runningReplicas: 0,
state: "pending",
updatedAt: nowIso(),
};
if (spec.image.includes("invalid") || spec.image.includes("fail")) {
service.state = "failed";
service.message = "Mock deployment failed: invalid image reference.";
this.services.set(spec.serviceName, service);
return { serviceId: id };
}
this.services.set(spec.serviceName, service);
this.transitionToRunning(spec.serviceName);
return { serviceId: id };
}
async scaleService(serviceName: string, replicas: number): Promise<void> {
const service = this.services.get(serviceName);
if (!service) {
throw new Error(`Mock service ${serviceName} not found.`);
}
service.desiredReplicas = replicas;
service.runningReplicas = 0;
service.state = replicas === 0 ? "stopped" : "pending";
service.updatedAt = nowIso();
if (replicas > 0) {
this.transitionToRunning(serviceName);
}
}
async restartService(serviceName: string): Promise<void> {
const service = this.services.get(serviceName);
if (!service) {
throw new Error(`Mock service ${serviceName} not found.`);
}
if (service.desiredReplicas === 0) {
service.state = "stopped";
return;
}
service.runningReplicas = 0;
service.state = "pending";
service.updatedAt = nowIso();
this.transitionToRunning(serviceName);
}
async removeService(serviceName: string): Promise<void> {
this.services.delete(serviceName);
}
async inspectService(serviceName: string): Promise<SwarmServiceInfo | undefined> {
const service = this.services.get(serviceName);
if (!service) {
return undefined;
}
return {
serviceName,
serviceId: service.id,
desiredReplicas: service.desiredReplicas,
runningReplicas: service.runningReplicas,
status: service.state,
message: service.message,
};
}
private transitionToRunning(serviceName: string): void {
setTimeout(() => {
const service = this.services.get(serviceName);
if (!service || service.state === "failed" || service.state === "stopped") {
return;
}
service.state = "running";
service.runningReplicas = service.desiredReplicas;
service.updatedAt = nowIso();
}, 800);
}
}
export async function createSwarmOrchestrator(config: PlatformConfig): Promise<SwarmOrchestrator> {
if (config.swarmMode === "mock") {
const orchestrator = new MockSwarmOrchestrator();
await orchestrator.init();
return orchestrator;
}
const dockerOrchestrator = new DockerSwarmOrchestrator(config.swarmDockerBin);
if (config.swarmMode === "docker") {
await dockerOrchestrator.init();
return dockerOrchestrator;
}
try {
await dockerOrchestrator.init();
console.error("[platform] Docker Swarm mode active.");
return dockerOrchestrator;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[platform] Falling back to mock swarm orchestrator: ${message}`);
const mock = new MockSwarmOrchestrator();
await mock.init();
return mock;
}
}

163
src/platform/types.ts Normal file
View File

@@ -0,0 +1,163 @@
export type TenantRole = "owner" | "admin" | "member" | "viewer";
export type AgentLifecycleStatus =
| "pending"
| "running"
| "failed"
| "stopped"
| "removing"
| "unknown";
export interface TenantQuotas {
maxAgents: number;
maxReplicasPerAgent: number;
maxTotalReplicas: number;
}
export interface PlatformUser {
id: string;
email: string;
name: string;
passwordSalt: string;
passwordHash: string;
createdAt: string;
updatedAt: string;
lastLoginAt?: string;
}
export interface PlatformTenant {
id: string;
slug: string;
name: string;
createdAt: string;
updatedAt: string;
createdBy: string;
quotas: TenantQuotas;
}
export interface PlatformMembership {
id: string;
tenantId: string;
userId: string;
role: TenantRole;
createdAt: string;
updatedAt: string;
}
export interface PlatformAgent {
id: string;
tenantId: string;
name: string;
slug: string;
image: string;
desiredReplicas: number;
runningReplicas: number;
status: AgentLifecycleStatus;
serviceName: string;
swarmDriver: "docker" | "mock";
swarmServiceId?: string;
swarmNetwork?: string;
env: Record<string, string>;
createdBy: string;
createdAt: string;
updatedAt: string;
lastError?: string;
}
export interface PlatformAuditLog {
id: string;
tenantId: string;
actorUserId: string | "system";
action: string;
resourceType: "tenant" | "membership" | "agent" | "user";
resourceId: string;
details: Record<string, unknown>;
createdAt: string;
}
export interface PlatformState {
version: 1;
users: PlatformUser[];
tenants: PlatformTenant[];
memberships: PlatformMembership[];
agents: PlatformAgent[];
auditLogs: PlatformAuditLog[];
}
export interface PublicUser {
id: string;
email: string;
name: string;
createdAt: string;
updatedAt: string;
lastLoginAt?: string;
}
export interface PublicTenant {
id: string;
slug: string;
name: string;
createdAt: string;
updatedAt: string;
createdBy: string;
role: TenantRole;
quotas: TenantQuotas;
}
export interface PublicTenantMembership {
id: string;
tenantId: string;
userId: string;
userEmail: string;
userName: string;
role: TenantRole;
createdAt: string;
updatedAt: string;
}
export interface PublicAgent {
id: string;
tenantId: string;
name: string;
slug: string;
image: string;
desiredReplicas: number;
runningReplicas: number;
status: AgentLifecycleStatus;
serviceName: string;
swarmDriver: "docker" | "mock";
swarmServiceId?: string;
swarmNetwork?: string;
env: Record<string, string>;
createdBy: string;
createdAt: string;
updatedAt: string;
lastError?: string;
}
export interface PlatformTenantEvent {
seq: number;
tenantId: string;
type: "agent.created" | "agent.updated" | "agent.removed";
timestamp: string;
actorUserId: string | "system";
agent: PublicAgent;
}
export interface AuthTokenPayload {
sub: string;
email: string;
iat: number;
exp: number;
}
export function createEmptyPlatformState(): PlatformState {
return {
version: 1,
users: [],
tenants: [],
memberships: [],
agents: [],
auditLogs: [],
};
}

54
src/platform/utils.ts Normal file
View File

@@ -0,0 +1,54 @@
import { randomUUID } from "node:crypto";
export function nowIso(): string {
return new Date().toISOString();
}
export function createId(prefix: string): string {
return `${prefix}_${randomUUID().replace(/-/g, "").slice(0, 12)}`;
}
export function slugify(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48);
}
export function sanitizeServiceName(value: string): string {
const slug = slugify(value);
const fallback = `svc-${Math.random().toString(36).slice(2, 8)}`;
const safe = slug || fallback;
return safe.slice(0, 63);
}
export function normalizeEmail(value: string): string {
return value.trim().toLowerCase();
}
export function parseEnvList(raw: unknown): Record<string, string> {
if (!raw || typeof raw !== "object") {
return {};
}
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (!key || /\s/.test(key)) {
continue;
}
if (typeof value !== "string") {
continue;
}
result[key] = value;
}
return result;
}
export function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}