Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c24a4721bd |
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
.git
|
||||
.pi
|
||||
node_modules
|
||||
dist
|
||||
data
|
||||
.platform
|
||||
.env
|
||||
.env.*
|
||||
.DS_Store
|
||||
platform-ui
|
||||
27
.env.example
27
.env.example
@@ -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
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
data/**
|
||||
.platform/
|
||||
.pi/
|
||||
.DS_Store
|
||||
.env
|
||||
@@ -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
|
||||
|
||||
|
||||
32
README.md
32
README.md
@@ -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.
|
||||
|
||||
16
compose.yaml
16
compose.yaml
@@ -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
148
docs/platform.md
Normal 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
1
platform-ui/.env.example
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8787
|
||||
24
platform-ui/.gitignore
vendored
Normal file
24
platform-ui/.gitignore
vendored
Normal 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
44
platform-ui/README.md
Normal 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/`
|
||||
23
platform-ui/eslint.config.js
Normal file
23
platform-ui/eslint.config.js
Normal 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
13
platform-ui/index.html
Normal 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
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
30
platform-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
platform-ui/public/favicon.svg
Normal file
1
platform-ui/public/favicon.svg
Normal file
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
326
platform-ui/src/App.css
Normal 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
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
24
platform-ui/src/index.css
Normal 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
10
platform-ui/src/main.tsx
Normal 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>,
|
||||
)
|
||||
28
platform-ui/tsconfig.app.json
Normal file
28
platform-ui/tsconfig.app.json
Normal 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"]
|
||||
}
|
||||
7
platform-ui/tsconfig.json
Normal file
7
platform-ui/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
platform-ui/tsconfig.node.json
Normal file
26
platform-ui/tsconfig.node.json
Normal 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"]
|
||||
}
|
||||
7
platform-ui/vite.config.ts
Normal file
7
platform-ui/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
10
src/index.ts
10
src/index.ts
@@ -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
111
src/platform/auth.ts
Normal 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
362
src/platform/http-api.ts
Normal 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
1303
src/platform/service.ts
Normal file
File diff suppressed because it is too large
Load Diff
90
src/platform/store.ts
Normal file
90
src/platform/store.ts
Normal 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
366
src/platform/swarm.ts
Normal 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
163
src/platform/types.ts
Normal 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
54
src/platform/utils.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user