diff --git a/apps/control-plane/package.json b/apps/control-plane/package.json index bf6893b..b69d762 100644 --- a/apps/control-plane/package.json +++ b/apps/control-plane/package.json @@ -33,6 +33,7 @@ "@paws/domain-policy": "workspace:*", "@paws/domain-session": "workspace:*", "@paws/domain-snapshot": "workspace:*", + "@paws/domain-workspace": "workspace:*", "@paws/integrations": "workspace:*", "@paws/logger": "workspace:*", "@paws/provider-aws-ec2": "workspace:*", diff --git a/apps/control-plane/src/app.ts b/apps/control-plane/src/app.ts index 742ceab..ce6dde6 100644 --- a/apps/control-plane/src/app.ts +++ b/apps/control-plane/src/app.ts @@ -3,6 +3,11 @@ import { randomUUID } from 'node:crypto'; import { OpenAPIHono } from '@hono/zod-openapi'; import { createLogger } from '@paws/logger'; import { tracingMiddleware } from '@paws/telemetry'; +import { + createWorkspaceStore, + CreateWorkspaceRequestSchema, + type Workspace, +} from '@paws/domain-workspace'; import type { Hono } from 'hono'; import { verifyWebhookSignature, @@ -188,6 +193,7 @@ export async function createControlPlaneApp(deps: ControlPlaneDeps) { deps.sessionStore ?? (deps.db ? createSqliteSessionStore(deps.db) : createSessionStore()); const daemonStore = deps.daemonStore ?? (deps.db ? createSqliteDaemonStore(deps.db) : createDaemonStore()); + const workspaceStore = createWorkspaceStore(); const governance = deps.governance ?? createGovernanceChecker(); const sessionEvents = createSessionEvents(); @@ -960,6 +966,79 @@ export async function createControlPlaneApp(deps: ControlPlaneDeps) { return c.json({ role, status: 'stopped' as const }, 200); }); + // --- Workspaces --- + + app.post('/v1/workspaces', async (c) => { + const raw = await c.req.json(); + const parsed = CreateWorkspaceRequestSchema.safeParse(raw); + if (!parsed.success) { + return c.json({ error: { code: 'VALIDATION_ERROR', message: parsed.error.message } }, 400); + } + const body = parsed.data; + if (workspaceStore.getByName(body.name)) { + return c.json( + { + error: { + code: 'WORKSPACE_ALREADY_EXISTS', + message: `Workspace '${body.name}' already exists`, + }, + }, + 409, + ); + } + const workspace = workspaceStore.create({ + id: randomUUID(), + name: body.name, + description: body.description ?? '', + type: body.type, + repos: body.repos, + settings: body.settings ?? {}, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + return c.json(workspace, 201); + }); + + app.get('/v1/workspaces', (c) => { + return c.json({ workspaces: workspaceStore.list() }, 200); + }); + + app.get('/v1/workspaces/:id', (c) => { + const workspace = workspaceStore.get(c.req.param('id')); + if (!workspace) { + return c.json( + { error: { code: 'WORKSPACE_NOT_FOUND', message: 'Workspace not found' } }, + 404, + ); + } + return c.json(workspace, 200); + }); + + app.put('/v1/workspaces/:id', async (c) => { + const id = c.req.param('id'); + if (!workspaceStore.get(id)) { + return c.json( + { error: { code: 'WORKSPACE_NOT_FOUND', message: 'Workspace not found' } }, + 404, + ); + } + const body = await c.req.json(); + const updated = workspaceStore.update(id, body as Partial); + return c.json(updated, 200); + }); + + app.delete('/v1/workspaces/:id', (c) => { + const id = c.req.param('id'); + if (!workspaceStore.get(id)) { + return c.json( + { error: { code: 'WORKSPACE_NOT_FOUND', message: 'Workspace not found' } }, + 404, + ); + } + workspaceStore.delete(id); + return c.json({ id, deleted: true }, 200); + }); + // --- Templates --- const templateStore = createTemplateStore(); diff --git a/apps/control-plane/tsconfig.json b/apps/control-plane/tsconfig.json index 943b91a..ad86c47 100644 --- a/apps/control-plane/tsconfig.json +++ b/apps/control-plane/tsconfig.json @@ -18,6 +18,7 @@ { "path": "../../packages/domains/policy" }, { "path": "../../packages/domains/session" }, { "path": "../../packages/domains/snapshot" }, + { "path": "../../packages/domains/workspace" }, { "path": "../../packages/integrations" }, { "path": "../../packages/logger" }, { "path": "../../packages/providers" }, diff --git a/apps/dashboard/src/api/client.ts b/apps/dashboard/src/api/client.ts index dbb88f6..9c6a614 100644 --- a/apps/dashboard/src/api/client.ts +++ b/apps/dashboard/src/api/client.ts @@ -549,6 +549,91 @@ export async function getSystemInfo(): Promise { return res.json(); } +// --- Workspaces --- + +export interface WorkspaceRepo { + name: string; + role?: 'primary' | 'reference'; + branch?: string; +} + +export interface Workspace { + id: string; + name: string; + description?: string; + type: 'monorepo' | 'multi-repo'; + repos: WorkspaceRepo[]; + rootDir?: string; + settings?: { + language?: string; + packageManager?: string; + testCommand?: string; + buildCommand?: string; + }; + daemonCount?: number; + createdAt: string; + updatedAt?: string; +} + +export async function listWorkspaces(): Promise<{ workspaces: Workspace[] }> { + const res = await fetch('/v1/workspaces', { headers: apiKeyHeaders() }); + if (!res.ok) throw new Error(`Failed to fetch workspaces: ${res.status}`); + return res.json(); +} + +export async function getWorkspace(id: string): Promise { + const res = await fetch(`/v1/workspaces/${encodeURIComponent(id)}`, { + headers: apiKeyHeaders(), + }); + if (!res.ok) throw new Error(`Failed to fetch workspace: ${res.status}`); + return res.json(); +} + +export async function createWorkspace( + data: Omit, +): Promise { + const res = await fetch('/v1/workspaces', { + method: 'POST', + headers: apiKeyHeaders(), + body: JSON.stringify(data), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error( + (body as { error?: { message?: string } }).error?.message ?? + `Failed to create workspace: ${res.status}`, + ); + } + return res.json(); +} + +export async function updateWorkspace( + id: string, + data: Partial>, +): Promise { + const res = await fetch(`/v1/workspaces/${encodeURIComponent(id)}`, { + method: 'PATCH', + headers: apiKeyHeaders(), + body: JSON.stringify(data), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error( + (body as { error?: { message?: string } }).error?.message ?? + `Failed to update workspace: ${res.status}`, + ); + } + return res.json(); +} + +export async function deleteWorkspace(id: string): Promise { + const res = await fetch(`/v1/workspaces/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: apiKeyHeaders(), + }); + if (!res.ok) throw new Error(`Failed to delete workspace: ${res.status}`); +} + // --- Cloud Connections (AWS integration) --- export interface CloudConnection { diff --git a/apps/dashboard/src/components/CommandPalette.tsx b/apps/dashboard/src/components/CommandPalette.tsx index ffcb0b5..1f18a0f 100644 --- a/apps/dashboard/src/components/CommandPalette.tsx +++ b/apps/dashboard/src/components/CommandPalette.tsx @@ -15,6 +15,7 @@ const PAGES = [ { name: 'Fleet', path: '/fleet', group: 'Infrastructure' }, { name: 'Servers', path: '/servers', group: 'Infrastructure' }, { name: 'Snapshots', path: '/snapshots', group: 'Infrastructure' }, + { name: 'Workspaces', path: '/workspaces', group: 'Agents' }, { name: 'Daemons', path: '/daemons', group: 'Agents' }, { name: 'Templates', path: '/templates', group: 'Agents' }, { name: 'Sessions', path: '/sessions', group: 'Agents' }, diff --git a/apps/dashboard/src/components/Layout.tsx b/apps/dashboard/src/components/Layout.tsx index a6ea5cd..7aa16eb 100644 --- a/apps/dashboard/src/components/Layout.tsx +++ b/apps/dashboard/src/components/Layout.tsx @@ -95,6 +95,7 @@ function SidebarNav({ Agents + diff --git a/apps/dashboard/src/components/WorkspaceForm.tsx b/apps/dashboard/src/components/WorkspaceForm.tsx new file mode 100644 index 0000000..80c933e --- /dev/null +++ b/apps/dashboard/src/components/WorkspaceForm.tsx @@ -0,0 +1,495 @@ +import { useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { Plus, Trash2 } from 'lucide-react'; + +import { Button } from './ui/button.js'; +import { Input } from './ui/input.js'; +import { Label } from './ui/label.js'; +import { Textarea } from './ui/textarea.js'; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from './ui/sheet.js'; +import { + createWorkspace, + updateWorkspace, + getWorkspace, + type WorkspaceRepo, +} from '../api/client.js'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type WorkspaceType = 'monorepo' | 'multi-repo'; + +interface WorkspaceFormData { + name: string; + description: string; + type: WorkspaceType; + // Monorepo fields + monoRepo: string; + monoRootDir: string; + monoBranch: string; + // Multi-repo fields + repos: Array<{ name: string; role: 'primary' | 'reference'; branch: string }>; + // Settings + language: string; + packageManager: string; + testCommand: string; + buildCommand: string; +} + +const DEFAULT_FORM: WorkspaceFormData = { + name: '', + description: '', + type: 'monorepo', + monoRepo: '', + monoRootDir: '', + monoBranch: 'main', + repos: [{ name: '', role: 'primary', branch: 'main' }], + language: '', + packageManager: '', + testCommand: '', + buildCommand: '', +}; + +const PACKAGE_MANAGERS = [ + { value: '', label: 'Select...' }, + { value: 'bun', label: 'bun' }, + { value: 'npm', label: 'npm' }, + { value: 'pnpm', label: 'pnpm' }, + { value: 'yarn', label: 'yarn' }, + { value: 'go', label: 'go' }, + { value: 'cargo', label: 'cargo' }, + { value: 'pip', label: 'pip' }, +]; + +interface WorkspaceFormProps { + open: boolean; + onOpenChange: (open: boolean) => void; + editId?: string | undefined; + onSaved: () => void; +} + +// --------------------------------------------------------------------------- +// WorkspaceForm +// --------------------------------------------------------------------------- + +export function WorkspaceForm({ open, onOpenChange, editId, onSaved }: WorkspaceFormProps) { + const [form, setForm] = useState(DEFAULT_FORM); + const [saving, setSaving] = useState(false); + const [loading, setLoading] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + + const isEdit = Boolean(editId); + + // Load workspace data when editing + useEffect(() => { + if (!open || !editId) { + if (!editId) setForm(DEFAULT_FORM); + return; + } + setLoading(true); + getWorkspace(editId) + .then((ws) => { + const partial: Partial = { + name: ws.name, + description: ws.description ?? '', + type: ws.type, + }; + + if (ws.type === 'monorepo') { + const repo = ws.repos[0]; + partial.monoRepo = repo?.name ?? ''; + partial.monoRootDir = ws.rootDir ?? ''; + partial.monoBranch = repo?.branch ?? 'main'; + } else { + partial.repos = ws.repos.map((r) => ({ + name: r.name, + role: r.role ?? 'primary', + branch: r.branch ?? 'main', + })); + } + + if (ws.settings) { + partial.language = ws.settings.language ?? ''; + partial.packageManager = ws.settings.packageManager ?? ''; + partial.testCommand = ws.settings.testCommand ?? ''; + partial.buildCommand = ws.settings.buildCommand ?? ''; + // Auto-open settings if any are set + if ( + ws.settings.language || + ws.settings.packageManager || + ws.settings.testCommand || + ws.settings.buildCommand + ) { + setSettingsOpen(true); + } + } + + setForm({ ...DEFAULT_FORM, ...partial }); + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + toast.error(`Failed to load workspace: ${message}`); + }) + .finally(() => setLoading(false)); + }, [open, editId]); + + function update(key: K, value: WorkspaceFormData[K]) { + setForm((prev) => ({ ...prev, [key]: value })); + } + + function updateRepo(index: number, field: 'name' | 'role' | 'branch', value: string) { + setForm((prev) => { + const repos = [...prev.repos]; + const repo = repos[index]; + if (!repo) return prev; + repos[index] = { + ...repo, + [field]: field === 'role' ? (value as 'primary' | 'reference') : value, + }; + return { ...prev, repos }; + }); + } + + function addRepo() { + setForm((prev) => ({ + ...prev, + repos: [...prev.repos, { name: '', role: 'reference' as const, branch: 'main' }], + })); + } + + function removeRepo(index: number) { + setForm((prev) => ({ + ...prev, + repos: prev.repos.filter((_, i) => i !== index), + })); + } + + function buildPayload() { + const repos: WorkspaceRepo[] = + form.type === 'monorepo' + ? [ + { + name: form.monoRepo, + role: 'primary', + ...(form.monoBranch ? { branch: form.monoBranch } : {}), + }, + ] + : form.repos + .filter((r) => r.name.trim() !== '') + .map((r) => ({ + name: r.name, + role: r.role, + ...(r.branch ? { branch: r.branch } : {}), + })); + + const settings: Record = {}; + if (form.language) settings.language = form.language; + if (form.packageManager) settings.packageManager = form.packageManager; + if (form.testCommand) settings.testCommand = form.testCommand; + if (form.buildCommand) settings.buildCommand = form.buildCommand; + + return { + name: form.name, + ...(form.description ? { description: form.description } : {}), + type: form.type, + repos, + ...(form.type === 'monorepo' && form.monoRootDir ? { rootDir: form.monoRootDir } : {}), + ...(Object.keys(settings).length > 0 ? { settings } : {}), + }; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSaving(true); + + try { + const payload = buildPayload(); + + if (isEdit && editId) { + await updateWorkspace(editId, payload); + toast.success(`Workspace "${form.name}" updated`); + } else { + await createWorkspace(payload); + toast.success(`Workspace "${form.name}" created`); + } + + onSaved(); + onOpenChange(false); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + toast.error(message); + } finally { + setSaving(false); + } + } + + const hasRepos = + form.type === 'monorepo' + ? form.monoRepo.trim().length > 0 + : form.repos.some((r) => r.name.trim().length > 0); + + const isValid = form.name.length > 0 && hasRepos; + + const typeOptions: { value: WorkspaceType; label: string }[] = [ + { value: 'monorepo', label: 'Monorepo' }, + { value: 'multi-repo', label: 'Multi-repo' }, + ]; + + return ( + + + + {isEdit ? 'Edit Workspace' : 'Create Workspace'} + + {isEdit + ? 'Update workspace configuration' + : 'Configure a workspace to group related repositories'} + + + + {loading ? ( +
Loading...
+ ) : ( +
+ {/* Basic */} +
+

+ Basic +

+
+ + + update('name', e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-')) + } + placeholder="my-workspace" + required + className="bg-zinc-800 border-zinc-700 text-zinc-100 placeholder-zinc-600 focus-visible:border-emerald-400/50 focus-visible:ring-emerald-400/20" + /> +

+ Lowercase alphanumeric and hyphens only +

+
+
+ +