From 4e3f18128aa646e87a34aabca7bffccd283c5925 Mon Sep 17 00:00:00 2001 From: Alexander Eklund Date: Sat, 4 Apr 2026 12:37:23 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(workspace):=20add=20workspace=20domain?= =?UTF-8?q?=20=E2=80=94=20schema,=20store,=20routes,=20daemon=20linking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New domain package `packages/domains/workspace/` with Zod schemas for workspace entities (monorepo/multi-repo), CRUD routes via @hono/zod-openapi, in-memory store, and 21 unit tests for schema validation. Adds optional `workspace` field to daemon domain (CreateDaemonRequestSchema, DaemonListItemSchema, StoredDaemon) to link daemons to workspaces. Co-Authored-By: Claude Opus 4.6 (1M context) --- bun.lock | 14 ++ packages/domains/daemon/src/store.ts | 2 + packages/domains/daemon/src/types.ts | 3 + packages/domains/workspace/package.json | 25 +++ packages/domains/workspace/src/index.ts | 27 +++ packages/domains/workspace/src/routes.ts | 120 +++++++++++++ packages/domains/workspace/src/store.ts | 57 ++++++ packages/domains/workspace/src/types.test.ts | 172 +++++++++++++++++++ packages/domains/workspace/src/types.ts | 78 +++++++++ packages/domains/workspace/tsconfig.json | 11 ++ packages/domains/workspace/vitest.config.ts | 11 ++ tsconfig.json | 1 + 12 files changed, 521 insertions(+) create mode 100644 packages/domains/workspace/package.json create mode 100644 packages/domains/workspace/src/index.ts create mode 100644 packages/domains/workspace/src/routes.ts create mode 100644 packages/domains/workspace/src/store.ts create mode 100644 packages/domains/workspace/src/types.test.ts create mode 100644 packages/domains/workspace/src/types.ts create mode 100644 packages/domains/workspace/tsconfig.json create mode 100644 packages/domains/workspace/vitest.config.ts diff --git a/bun.lock b/bun.lock index a243184..96ebd05 100644 --- a/bun.lock +++ b/bun.lock @@ -293,6 +293,18 @@ "vitest": "catalog:", }, }, + "packages/domains/workspace": { + "name": "@paws/domain-workspace", + "version": "0.1.0", + "dependencies": { + "@hono/zod-openapi": "catalog:", + "@paws/domain-common": "workspace:*", + "zod": "catalog:", + }, + "devDependencies": { + "vitest": "catalog:", + }, + }, "packages/firecracker": { "name": "@paws/firecracker", "version": "0.1.0", @@ -1130,6 +1142,8 @@ "@paws/domain-snapshot": ["@paws/domain-snapshot@workspace:packages/domains/snapshot"], + "@paws/domain-workspace": ["@paws/domain-workspace@workspace:packages/domains/workspace"], + "@paws/firecracker": ["@paws/firecracker@workspace:packages/firecracker"], "@paws/integrations": ["@paws/integrations@workspace:packages/integrations"], diff --git a/packages/domains/daemon/src/store.ts b/packages/domains/daemon/src/store.ts index 1bcd019..65c4550 100644 --- a/packages/domains/daemon/src/store.ts +++ b/packages/domains/daemon/src/store.ts @@ -10,6 +10,7 @@ export interface StoredDaemon { status: DaemonStatus; snapshot: string; trigger: Trigger; + workspace?: string | undefined; workload?: Workload | undefined; agent?: AgentConfig | undefined; resources?: Resources | undefined; @@ -46,6 +47,7 @@ export function createDaemonStore(): DaemonStore { status: 'active', snapshot: request.snapshot, trigger: request.trigger, + workspace: request.workspace, workload: request.workload, agent: request.agent, resources: request.resources, diff --git a/packages/domains/daemon/src/types.ts b/packages/domains/daemon/src/types.ts index 59cb7c7..a123961 100644 --- a/packages/domains/daemon/src/types.ts +++ b/packages/domains/daemon/src/types.ts @@ -87,6 +87,8 @@ export const CreateDaemonRequestSchema = z description: z.string().default(''), snapshot: NonEmptyStringSchema, trigger: TriggerSchema, + /** Workspace this daemon belongs to */ + workspace: z.string().optional(), /** Workload script — required unless agent is configured */ workload: WorkloadSchema.optional(), /** Agent framework config — auto-generates the workload script */ @@ -120,6 +122,7 @@ export const DaemonListItemSchema = z.object({ status: DaemonStatus, trigger: TriggerSchema, stats: DaemonStatsSchema, + workspace: z.string().optional(), }); export type DaemonListItem = z.infer; diff --git a/packages/domains/workspace/package.json b/packages/domains/workspace/package.json new file mode 100644 index 0000000..aee66bc --- /dev/null +++ b/packages/domains/workspace/package.json @@ -0,0 +1,25 @@ +{ + "name": "@paws/domain-workspace", + "version": "0.1.0", + "private": true, + "description": "Workspace domain — Workspace, WorkspaceStore, routes", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } + }, + "scripts": { + "test": "vitest run --passWithNoTests", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@hono/zod-openapi": "catalog:", + "@paws/domain-common": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "vitest": "catalog:" + } +} diff --git a/packages/domains/workspace/src/index.ts b/packages/domains/workspace/src/index.ts new file mode 100644 index 0000000..262eb24 --- /dev/null +++ b/packages/domains/workspace/src/index.ts @@ -0,0 +1,27 @@ +export { + CreateWorkspaceRequestSchema, + UpdateWorkspaceRequestSchema, + WorkspaceListResponseSchema, + WorkspaceRepoSchema, + WorkspaceSchema, + WorkspaceSettingsSchema, +} from './types.js'; +export type { + CreateWorkspaceRequest, + UpdateWorkspaceRequest, + Workspace, + WorkspaceListResponse, + WorkspaceRepo, + WorkspaceSettings, +} from './types.js'; + +export { createWorkspaceStore } from './store.js'; +export type { WorkspaceStore } from './store.js'; + +export { + createWorkspaceRoute, + deleteWorkspaceRoute, + getWorkspaceRoute, + listWorkspacesRoute, + updateWorkspaceRoute, +} from './routes.js'; diff --git a/packages/domains/workspace/src/routes.ts b/packages/domains/workspace/src/routes.ts new file mode 100644 index 0000000..d94cffd --- /dev/null +++ b/packages/domains/workspace/src/routes.ts @@ -0,0 +1,120 @@ +import { createRoute, z } from '@hono/zod-openapi'; +import { ErrorResponseSchema } from '@paws/domain-common'; +import { + CreateWorkspaceRequestSchema, + UpdateWorkspaceRequestSchema, + WorkspaceListResponseSchema, + WorkspaceSchema, +} from './types.js'; + +export const createWorkspaceRoute = createRoute({ + method: 'post', + path: '/v1/workspaces', + tags: ['Workspaces'], + request: { + body: { + content: { + 'application/json': { + schema: CreateWorkspaceRequestSchema, + }, + }, + }, + }, + responses: { + 201: { + description: 'Workspace created', + content: { 'application/json': { schema: WorkspaceSchema } }, + }, + 400: { + description: 'Validation error', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + 409: { + description: 'Workspace name already exists', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + }, +}); + +export const listWorkspacesRoute = createRoute({ + method: 'get', + path: '/v1/workspaces', + tags: ['Workspaces'], + responses: { + 200: { + description: 'List of workspaces', + content: { 'application/json': { schema: WorkspaceListResponseSchema } }, + }, + }, +}); + +export const getWorkspaceRoute = createRoute({ + method: 'get', + path: '/v1/workspaces/{id}', + tags: ['Workspaces'], + request: { + params: z.object({ id: z.string().min(1) }), + }, + responses: { + 200: { + description: 'Workspace detail', + content: { 'application/json': { schema: WorkspaceSchema } }, + }, + 404: { + description: 'Workspace not found', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + }, +}); + +export const updateWorkspaceRoute = createRoute({ + method: 'put', + path: '/v1/workspaces/{id}', + tags: ['Workspaces'], + request: { + params: z.object({ id: z.string().min(1) }), + body: { + content: { + 'application/json': { + schema: UpdateWorkspaceRequestSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Workspace updated', + content: { 'application/json': { schema: WorkspaceSchema } }, + }, + 404: { + description: 'Workspace not found', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + }, +}); + +export const deleteWorkspaceRoute = createRoute({ + method: 'delete', + path: '/v1/workspaces/{id}', + tags: ['Workspaces'], + request: { + params: z.object({ id: z.string().min(1) }), + }, + responses: { + 200: { + description: 'Workspace deleted', + content: { + 'application/json': { + schema: z.object({ + id: z.string(), + deleted: z.literal(true), + }), + }, + }, + }, + 404: { + description: 'Workspace not found', + content: { 'application/json': { schema: ErrorResponseSchema } }, + }, + }, +}); diff --git a/packages/domains/workspace/src/store.ts b/packages/domains/workspace/src/store.ts new file mode 100644 index 0000000..00c9e60 --- /dev/null +++ b/packages/domains/workspace/src/store.ts @@ -0,0 +1,57 @@ +import type { CreateWorkspaceRequest, Workspace } from './types.js'; + +export interface WorkspaceStore { + create(workspace: Workspace): Workspace; + get(id: string): Workspace | undefined; + getByName(name: string): Workspace | undefined; + list(): Workspace[]; + update(id: string, partial: Partial): Workspace | undefined; + delete(id: string): boolean; +} + +/** In-memory workspace store */ +export function createWorkspaceStore(): WorkspaceStore { + const workspaces = new Map(); + + return { + create(workspace) { + workspaces.set(workspace.id, workspace); + return workspace; + }, + + get(id) { + return workspaces.get(id); + }, + + getByName(name) { + for (const workspace of workspaces.values()) { + if (workspace.name === name) return workspace; + } + return undefined; + }, + + list() { + return [...workspaces.values()]; + }, + + update(id, partial) { + const workspace = workspaces.get(id); + if (!workspace) return undefined; + const updated: Workspace = { + ...workspace, + ...(partial.name !== undefined && { name: partial.name }), + ...(partial.description !== undefined && { description: partial.description }), + ...(partial.type !== undefined && { type: partial.type }), + ...(partial.repos !== undefined && { repos: partial.repos }), + ...(partial.settings !== undefined && { settings: partial.settings }), + updatedAt: new Date().toISOString(), + }; + workspaces.set(id, updated); + return updated; + }, + + delete(id) { + return workspaces.delete(id); + }, + }; +} diff --git a/packages/domains/workspace/src/types.test.ts b/packages/domains/workspace/src/types.test.ts new file mode 100644 index 0000000..e7b881c --- /dev/null +++ b/packages/domains/workspace/src/types.test.ts @@ -0,0 +1,172 @@ +import { describe, expect, test } from 'vitest'; + +import { + CreateWorkspaceRequestSchema, + UpdateWorkspaceRequestSchema, + WorkspaceRepoSchema, + WorkspaceSchema, + WorkspaceSettingsSchema, +} from './types.js'; + +describe('WorkspaceRepoSchema', () => { + test('accepts valid repo with defaults', () => { + const result = WorkspaceRepoSchema.parse({ repo: 'org/repo' }); + expect(result.repo).toBe('org/repo'); + expect(result.role).toBe('primary'); + expect(result.rootDir).toBe('/'); + expect(result.branch).toBe('main'); + }); + + test('accepts repo with overrides', () => { + const result = WorkspaceRepoSchema.parse({ + repo: 'org/repo', + role: 'reference', + rootDir: '/packages/lib', + branch: 'develop', + }); + expect(result.role).toBe('reference'); + expect(result.rootDir).toBe('/packages/lib'); + expect(result.branch).toBe('develop'); + }); + + test('rejects empty repo string', () => { + const result = WorkspaceRepoSchema.safeParse({ repo: '' }); + expect(result.success).toBe(false); + }); + + test('rejects invalid role', () => { + const result = WorkspaceRepoSchema.safeParse({ repo: 'org/repo', role: 'invalid' }); + expect(result.success).toBe(false); + }); +}); + +describe('WorkspaceSettingsSchema', () => { + test('accepts empty object', () => { + const result = WorkspaceSettingsSchema.parse({}); + expect(result).toEqual({}); + }); + + test('accepts all optional fields', () => { + const result = WorkspaceSettingsSchema.parse({ + language: 'typescript', + packageManager: 'bun', + testCommand: 'bun test', + buildCommand: 'bun run build', + }); + expect(result.language).toBe('typescript'); + expect(result.packageManager).toBe('bun'); + }); +}); + +describe('WorkspaceSchema', () => { + const validWorkspace = { + id: 'ws-1', + name: 'my-project', + type: 'monorepo' as const, + repos: [{ repo: 'org/repo' }], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + }; + + test('accepts valid workspace with defaults', () => { + const result = WorkspaceSchema.parse(validWorkspace); + expect(result.id).toBe('ws-1'); + expect(result.name).toBe('my-project'); + expect(result.description).toBe(''); + expect(result.settings).toEqual({}); + }); + + test('rejects name with uppercase', () => { + const result = WorkspaceSchema.safeParse({ ...validWorkspace, name: 'MyProject' }); + expect(result.success).toBe(false); + }); + + test('rejects name with spaces', () => { + const result = WorkspaceSchema.safeParse({ ...validWorkspace, name: 'my project' }); + expect(result.success).toBe(false); + }); + + test('rejects name with underscores', () => { + const result = WorkspaceSchema.safeParse({ ...validWorkspace, name: 'my_project' }); + expect(result.success).toBe(false); + }); + + test('accepts name with hyphens and numbers', () => { + const result = WorkspaceSchema.parse({ ...validWorkspace, name: 'my-project-123' }); + expect(result.name).toBe('my-project-123'); + }); + + test('rejects empty repos array', () => { + const result = WorkspaceSchema.safeParse({ ...validWorkspace, repos: [] }); + expect(result.success).toBe(false); + }); + + test('rejects empty id', () => { + const result = WorkspaceSchema.safeParse({ ...validWorkspace, id: '' }); + expect(result.success).toBe(false); + }); + + test('accepts multi-repo type', () => { + const result = WorkspaceSchema.parse({ ...validWorkspace, type: 'multi-repo' }); + expect(result.type).toBe('multi-repo'); + }); +}); + +describe('CreateWorkspaceRequestSchema', () => { + test('accepts valid create request', () => { + const result = CreateWorkspaceRequestSchema.parse({ + name: 'my-workspace', + type: 'monorepo', + repos: [{ repo: 'org/repo' }], + }); + expect(result.name).toBe('my-workspace'); + expect(result.type).toBe('monorepo'); + }); + + test('accepts optional description and settings', () => { + const result = CreateWorkspaceRequestSchema.parse({ + name: 'my-workspace', + description: 'A test workspace', + type: 'multi-repo', + repos: [{ repo: 'org/repo' }], + settings: { language: 'typescript' }, + }); + expect(result.description).toBe('A test workspace'); + expect(result.settings?.language).toBe('typescript'); + }); + + test('rejects missing name', () => { + const result = CreateWorkspaceRequestSchema.safeParse({ + type: 'monorepo', + repos: [{ repo: 'org/repo' }], + }); + expect(result.success).toBe(false); + }); + + test('rejects missing repos', () => { + const result = CreateWorkspaceRequestSchema.safeParse({ + name: 'test', + type: 'monorepo', + }); + expect(result.success).toBe(false); + }); +}); + +describe('UpdateWorkspaceRequestSchema', () => { + test('accepts partial update with name only', () => { + const result = UpdateWorkspaceRequestSchema.parse({ name: 'new-name' }); + expect(result.name).toBe('new-name'); + }); + + test('accepts partial update with repos only', () => { + const result = UpdateWorkspaceRequestSchema.parse({ + repos: [{ repo: 'org/new-repo' }], + }); + expect(result.repos).toHaveLength(1); + }); + + test('rejects empty object', () => { + const result = UpdateWorkspaceRequestSchema.safeParse({}); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/domains/workspace/src/types.ts b/packages/domains/workspace/src/types.ts new file mode 100644 index 0000000..040c825 --- /dev/null +++ b/packages/domains/workspace/src/types.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; + +/** A repository linked to a workspace */ +export const WorkspaceRepoSchema = z.object({ + repo: z.string().min(1), // "owner/repo" + role: z.enum(['primary', 'reference']).default('primary'), + rootDir: z.string().default('/'), + branch: z.string().default('main'), +}); + +export type WorkspaceRepo = z.infer; + +/** Workspace-level settings for builds and tests */ +export const WorkspaceSettingsSchema = z.object({ + language: z.string().optional(), + packageManager: z.string().optional(), + testCommand: z.string().optional(), + buildCommand: z.string().optional(), +}); + +export type WorkspaceSettings = z.infer; + +/** Full workspace entity */ +export const WorkspaceSchema = z.object({ + id: z.string().min(1), + name: z + .string() + .min(1) + .regex(/^[a-z0-9-]+$/, 'Must be lowercase alphanumeric with hyphens'), + description: z.string().default(''), + type: z.enum(['monorepo', 'multi-repo']), + repos: z.array(WorkspaceRepoSchema).min(1), + settings: WorkspaceSettingsSchema.default({}), + createdAt: z.string(), + updatedAt: z.string(), +}); + +export type Workspace = z.infer; + +/** Create workspace request body */ +export const CreateWorkspaceRequestSchema = z.object({ + name: z + .string() + .min(1) + .regex(/^[a-z0-9-]+$/, 'Must be lowercase alphanumeric with hyphens'), + description: z.string().optional(), + type: z.enum(['monorepo', 'multi-repo']), + repos: z.array(WorkspaceRepoSchema).min(1), + settings: WorkspaceSettingsSchema.optional(), +}); + +export type CreateWorkspaceRequest = z.infer; + +/** Update workspace request body (partial) */ +export const UpdateWorkspaceRequestSchema = z + .object({ + name: z + .string() + .min(1) + .regex(/^[a-z0-9-]+$/, 'Must be lowercase alphanumeric with hyphens') + .optional(), + description: z.string().optional(), + type: z.enum(['monorepo', 'multi-repo']).optional(), + repos: z.array(WorkspaceRepoSchema).min(1).optional(), + settings: WorkspaceSettingsSchema.optional(), + }) + .refine((data) => Object.keys(data).length > 0, { + message: 'At least one field must be provided', + }); + +export type UpdateWorkspaceRequest = z.infer; + +/** Workspace list response */ +export const WorkspaceListResponseSchema = z.object({ + workspaces: z.array(WorkspaceSchema), +}); + +export type WorkspaceListResponse = z.infer; diff --git a/packages/domains/workspace/tsconfig.json b/packages/domains/workspace/tsconfig.json new file mode 100644 index 0000000..521f5a9 --- /dev/null +++ b/packages/domains/workspace/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../packages/typescript-config/library.json", + "compilerOptions": { + "composite": true, + "outDir": "dist", + "rootDir": "src", + "types": [] + }, + "include": ["src"], + "references": [{ "path": "../common" }] +} diff --git a/packages/domains/workspace/vitest.config.ts b/packages/domains/workspace/vitest.config.ts new file mode 100644 index 0000000..cb2ef7e --- /dev/null +++ b/packages/domains/workspace/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + coverage: { + include: ['src/**/*.ts'], + exclude: ['src/**/*.test.ts', 'src/index.ts'], + }, + }, +}); diff --git a/tsconfig.json b/tsconfig.json index 8c8d972..ab8317e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ { "path": "packages/domains/agent" }, { "path": "packages/domains/credential" }, { "path": "packages/domains/policy" }, + { "path": "packages/domains/workspace" }, { "path": "packages/runtime" }, { "path": "packages/runtime-firecracker" }, { "path": "packages/logger" }, From b72e9f1b1e94f80b3bf45627a4ed3b40fd480ca9 Mon Sep 17 00:00:00 2001 From: Alexander Eklund Date: Sat, 4 Apr 2026 12:40:13 +0200 Subject: [PATCH 2/3] fix: remove project reference from workspace tsconfig (matches other domains) Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/domains/workspace/tsconfig.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/domains/workspace/tsconfig.json b/packages/domains/workspace/tsconfig.json index 521f5a9..a5102fb 100644 --- a/packages/domains/workspace/tsconfig.json +++ b/packages/domains/workspace/tsconfig.json @@ -6,6 +6,5 @@ "rootDir": "src", "types": [] }, - "include": ["src"], - "references": [{ "path": "../common" }] + "include": ["src"] } From ef779fd30a57649d217f2411ac862379cee36511 Mon Sep 17 00:00:00 2001 From: Alexander Eklund Date: Sat, 4 Apr 2026 12:42:17 +0200 Subject: [PATCH 3/3] fix(dashboard): remove unused imports in AuthGate (fixes CI typecheck) Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/dashboard/src/components/AuthGate.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/dashboard/src/components/AuthGate.tsx b/apps/dashboard/src/components/AuthGate.tsx index ffc97eb..df63a09 100644 --- a/apps/dashboard/src/components/AuthGate.tsx +++ b/apps/dashboard/src/components/AuthGate.tsx @@ -4,8 +4,6 @@ import { Alert, AlertDescription } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { setSessionMode } from '../api/client.js'; - type AuthMode = 'loading' | 'authenticated' | 'create-account' | 'login'; interface SetupStatus { @@ -28,7 +26,6 @@ export function AuthGate({ children }: { children: React.ReactNode }) { async function checkAuth() { // Check setup status // Check OIDC session first (before setup status, to avoid redirect loops) - let isOidcAuthenticated = false; try { const meRes = await fetch('/auth/me', { credentials: 'include' }); if (meRes.ok) {