Compare commits
24 Commits
a7fbc1ccc8
...
v0.20.0
| Author | SHA1 | Date | |
|---|---|---|---|
| d2c51eda91 | |||
| 78b643a945 | |||
| f93503ebcf | |||
| c0e679ab7c | |||
| 6ac63fe755 | |||
| 1667f28d71 | |||
| 66fe475fa1 | |||
| d39ab6aafc | |||
| 147e8ac574 | |||
| c38bfae16c | |||
| 36b4d8323d | |||
| 833662a64f | |||
| b3922e1d5b | |||
| 78b71a0ecc | |||
| dd0568cf15 | |||
| 8964226163 | |||
| 11f22a7e96 | |||
| edcff6a0e0 | |||
| e3cba37e8c | |||
| 21bf7e050f | |||
| 83d5aee53a | |||
| cc5b108b2f | |||
| bf299bb672 | |||
| ad99cb9a03 |
30
.env.example
30
.env.example
@@ -79,7 +79,7 @@ OIDC_CLIENT_ID=your-client-id-here
|
||||
OIDC_CLIENT_SECRET=your-client-secret-here
|
||||
# Redirect URI must match what's configured in Authentik
|
||||
# Development: http://localhost:3001/auth/oauth2/callback/authentik
|
||||
# Production: https://api.mosaicstack.dev/auth/oauth2/callback/authentik
|
||||
# Production: https://mosaic-api.woltje.com/auth/oauth2/callback/authentik
|
||||
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
|
||||
|
||||
# Authentik PostgreSQL Database
|
||||
@@ -314,17 +314,19 @@ COORDINATOR_ENABLED=true
|
||||
# TTL is in seconds, limits are per TTL window
|
||||
|
||||
# Global rate limit (applies to all endpoints unless overridden)
|
||||
RATE_LIMIT_TTL=60 # Time window in seconds
|
||||
RATE_LIMIT_GLOBAL_LIMIT=100 # Requests per window
|
||||
# Time window in seconds
|
||||
RATE_LIMIT_TTL=60
|
||||
# Requests per window
|
||||
RATE_LIMIT_GLOBAL_LIMIT=100
|
||||
|
||||
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch)
|
||||
RATE_LIMIT_WEBHOOK_LIMIT=60 # Requests per minute
|
||||
# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch) — requests per minute
|
||||
RATE_LIMIT_WEBHOOK_LIMIT=60
|
||||
|
||||
# Coordinator endpoints (/coordinator/*)
|
||||
RATE_LIMIT_COORDINATOR_LIMIT=100 # Requests per minute
|
||||
# Coordinator endpoints (/coordinator/*) — requests per minute
|
||||
RATE_LIMIT_COORDINATOR_LIMIT=100
|
||||
|
||||
# Health check endpoints (/coordinator/health)
|
||||
RATE_LIMIT_HEALTH_LIMIT=300 # Requests per minute (higher for monitoring)
|
||||
# Health check endpoints (/coordinator/health) — requests per minute (higher for monitoring)
|
||||
RATE_LIMIT_HEALTH_LIMIT=300
|
||||
|
||||
# Storage backend for rate limiting (redis or memory)
|
||||
# redis: Uses Valkey for distributed rate limiting (recommended for production)
|
||||
@@ -359,17 +361,17 @@ RATE_LIMIT_STORAGE=redis
|
||||
# a single workspace.
|
||||
MATRIX_HOMESERVER_URL=http://synapse:8008
|
||||
MATRIX_ACCESS_TOKEN=
|
||||
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.example.com
|
||||
MATRIX_SERVER_NAME=matrix.example.com
|
||||
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.example.com
|
||||
MATRIX_BOT_USER_ID=@mosaic-bot:matrix.woltje.com
|
||||
MATRIX_SERVER_NAME=matrix.woltje.com
|
||||
# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.woltje.com
|
||||
# MATRIX_WORKSPACE_ID=your-workspace-uuid
|
||||
|
||||
# ======================
|
||||
# Matrix / Synapse Deployment
|
||||
# ======================
|
||||
# Domains for Traefik routing to Matrix services
|
||||
MATRIX_DOMAIN=matrix.example.com
|
||||
ELEMENT_DOMAIN=chat.example.com
|
||||
MATRIX_DOMAIN=matrix.woltje.com
|
||||
ELEMENT_DOMAIN=chat.woltje.com
|
||||
|
||||
# Synapse database (created automatically by synapse-db-init in the swarm compose)
|
||||
SYNAPSE_POSTGRES_DB=synapse
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"status": "active",
|
||||
"task_prefix": "",
|
||||
"quality_gates": "",
|
||||
"milestone_version": "0.0.1",
|
||||
"milestone_version": "0.0.20",
|
||||
"milestones": [],
|
||||
"sessions": []
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ variables:
|
||||
pnpm install --frozen-lockfile
|
||||
- &use_deps |
|
||||
corepack enable
|
||||
- &turbo_env
|
||||
TURBO_API:
|
||||
from_secret: turbo_api
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
TURBO_TEAM:
|
||||
from_secret: turbo_team
|
||||
- &kaniko_setup |
|
||||
mkdir -p /kaniko/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||
@@ -52,17 +59,6 @@ steps:
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/api" lint
|
||||
depends_on:
|
||||
- prisma-generate
|
||||
- build-shared
|
||||
|
||||
prisma-generate:
|
||||
image: *node_image
|
||||
environment:
|
||||
@@ -73,26 +69,27 @@ steps:
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
build-shared:
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/shared" build
|
||||
- pnpm turbo lint --filter=@mosaic/api
|
||||
depends_on:
|
||||
- install
|
||||
- prisma-generate
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/api" typecheck
|
||||
- pnpm turbo typecheck --filter=@mosaic/api
|
||||
depends_on:
|
||||
- prisma-generate
|
||||
- build-shared
|
||||
|
||||
prisma-migrate:
|
||||
image: *node_image
|
||||
@@ -124,6 +121,7 @@ steps:
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
NODE_ENV: "production"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm turbo build --filter=@mosaic/api
|
||||
|
||||
@@ -24,6 +24,13 @@ variables:
|
||||
pnpm install --frozen-lockfile
|
||||
- &use_deps |
|
||||
corepack enable
|
||||
- &turbo_env
|
||||
TURBO_API:
|
||||
from_secret: turbo_api
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
TURBO_TEAM:
|
||||
from_secret: turbo_team
|
||||
- &kaniko_setup |
|
||||
mkdir -p /kaniko/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||
@@ -48,9 +55,10 @@ steps:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/orchestrator" lint
|
||||
- pnpm turbo lint --filter=@mosaic/orchestrator
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
@@ -58,9 +66,10 @@ steps:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/orchestrator" typecheck
|
||||
- pnpm turbo typecheck --filter=@mosaic/orchestrator
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
@@ -68,9 +77,10 @@ steps:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/orchestrator" test
|
||||
- pnpm turbo test --filter=@mosaic/orchestrator
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
@@ -81,6 +91,7 @@ steps:
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
NODE_ENV: "production"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm turbo build --filter=@mosaic/orchestrator
|
||||
|
||||
@@ -24,6 +24,13 @@ variables:
|
||||
pnpm install --frozen-lockfile
|
||||
- &use_deps |
|
||||
corepack enable
|
||||
- &turbo_env
|
||||
TURBO_API:
|
||||
from_secret: turbo_api
|
||||
TURBO_TOKEN:
|
||||
from_secret: turbo_token
|
||||
TURBO_TEAM:
|
||||
from_secret: turbo_team
|
||||
- &kaniko_setup |
|
||||
mkdir -p /kaniko/.docker
|
||||
echo "{\"auths\":{\"git.mosaicstack.dev\":{\"username\":\"$GITEA_USER\",\"password\":\"$GITEA_TOKEN\"}}}" > /kaniko/.docker/config.json
|
||||
@@ -44,46 +51,38 @@ steps:
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
build-shared:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/shared" build
|
||||
- pnpm --filter "@mosaic/ui" build
|
||||
depends_on:
|
||||
- install
|
||||
|
||||
lint:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/web" lint
|
||||
- pnpm turbo lint --filter=@mosaic/web
|
||||
depends_on:
|
||||
- build-shared
|
||||
- install
|
||||
|
||||
typecheck:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/web" typecheck
|
||||
- pnpm turbo typecheck --filter=@mosaic/web
|
||||
depends_on:
|
||||
- build-shared
|
||||
- install
|
||||
|
||||
test:
|
||||
image: *node_image
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm --filter "@mosaic/web" test
|
||||
- pnpm turbo test --filter=@mosaic/web
|
||||
depends_on:
|
||||
- build-shared
|
||||
- install
|
||||
|
||||
# === Build ===
|
||||
|
||||
@@ -92,6 +91,7 @@ steps:
|
||||
environment:
|
||||
SKIP_ENV_VALIDATION: "true"
|
||||
NODE_ENV: "production"
|
||||
<<: *turbo_env
|
||||
commands:
|
||||
- *use_deps
|
||||
- pnpm turbo build --filter=@mosaic/web
|
||||
|
||||
15
AGENTS.md
15
AGENTS.md
@@ -46,6 +46,21 @@ pnpm lint
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## Versioning Protocol (HARD GATE)
|
||||
|
||||
**This project is ALPHA. All versions MUST be `0.0.x`.**
|
||||
|
||||
- The `0.1.0` release is FORBIDDEN until Jason explicitly authorizes it.
|
||||
- Every milestone bump increments the patch: `0.0.20` → `0.0.21` → `0.0.22`, etc.
|
||||
- ALL package.json files in the monorepo MUST stay in sync at the same version.
|
||||
- Use `scripts/version-bump.sh <version>` to bump — it enforces the alpha constraint and updates all packages atomically.
|
||||
- The script rejects any version >= `0.1.0`.
|
||||
- When creating a release tag, the tag MUST match the package version: `v0.0.x`.
|
||||
|
||||
**Milestone-to-version mapping** is defined in the PRD (`docs/PRD.md`) under "Delivery/Milestone Intent". Agents MUST use the version from that table when tagging a milestone release.
|
||||
|
||||
**Violation of this protocol is a blocking error.** If an agent attempts to set a version >= `0.1.0`, stop and escalate.
|
||||
|
||||
## Standards and Quality
|
||||
|
||||
- Enforce strict typing and no unsafe shortcuts.
|
||||
|
||||
@@ -31,7 +31,11 @@ COPY packages/config/package.json ./packages/config/
|
||||
COPY apps/api/package.json ./apps/api/
|
||||
|
||||
# Install dependencies (no cache mount — Kaniko builds are ephemeral in CI)
|
||||
RUN pnpm install --frozen-lockfile
|
||||
# Then explicitly rebuild node-pty from source since pnpm may skip postinstall
|
||||
# scripts or fail to find prebuilt binaries for this Node.js version
|
||||
RUN pnpm install --frozen-lockfile \
|
||||
&& cd node_modules/.pnpm/node-pty@*/node_modules/node-pty \
|
||||
&& npx node-gyp rebuild 2>&1 || true
|
||||
|
||||
# ======================
|
||||
# Builder stage
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/api",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable: add tone and formality_level columns to personalities
|
||||
ALTER TABLE "personalities" ADD COLUMN "tone" TEXT NOT NULL DEFAULT 'neutral';
|
||||
ALTER TABLE "personalities" ADD COLUMN "formality_level" "FormalityLevel" NOT NULL DEFAULT 'NEUTRAL';
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x"]
|
||||
previewFeatures = ["postgresqlExtensions"]
|
||||
}
|
||||
|
||||
@@ -1067,6 +1068,10 @@ model Personality {
|
||||
displayName String @map("display_name")
|
||||
description String? @db.Text
|
||||
|
||||
// Tone and formality
|
||||
tone String @default("neutral")
|
||||
formalityLevel FormalityLevel @default(NEUTRAL) @map("formality_level")
|
||||
|
||||
// System prompt
|
||||
systemPrompt String @map("system_prompt") @db.Text
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ import { MosaicTelemetryModule } from "./mosaic-telemetry";
|
||||
import { SpeechModule } from "./speech/speech.module";
|
||||
import { DashboardModule } from "./dashboard/dashboard.module";
|
||||
import { TerminalModule } from "./terminal/terminal.module";
|
||||
import { PersonalitiesModule } from "./personalities/personalities.module";
|
||||
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
|
||||
|
||||
@Module({
|
||||
@@ -105,6 +106,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
|
||||
SpeechModule,
|
||||
DashboardModule,
|
||||
TerminalModule,
|
||||
PersonalitiesModule,
|
||||
],
|
||||
controllers: [AppController, CsrfController],
|
||||
providers: [
|
||||
|
||||
@@ -110,10 +110,10 @@ export class WorkspaceGuard implements CanActivate {
|
||||
return paramWorkspaceId;
|
||||
}
|
||||
|
||||
// 3. Check request body
|
||||
const bodyWorkspaceId = request.body.workspaceId;
|
||||
if (typeof bodyWorkspaceId === "string") {
|
||||
return bodyWorkspaceId;
|
||||
// 3. Check request body (body may be undefined for GET requests despite Express typings)
|
||||
const body = request.body as Record<string, unknown> | undefined;
|
||||
if (body && typeof body.workspaceId === "string") {
|
||||
return body.workspaceId;
|
||||
}
|
||||
|
||||
// 4. Check query string (backward compatibility for existing clients)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from "class-validator";
|
||||
import { IsOptional, IsEnum, IsString, IsInt, IsIn, Min, Max } from "class-validator";
|
||||
import { Type } from "class-transformer";
|
||||
import { EntryStatus } from "@prisma/client";
|
||||
import { EntryStatus, Visibility } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* DTO for querying knowledge entries (list endpoint)
|
||||
@@ -10,10 +10,28 @@ export class EntryQueryDto {
|
||||
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
|
||||
status?: EntryStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(Visibility, { message: "visibility must be a valid Visibility" })
|
||||
visibility?: Visibility;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "tag must be a string" })
|
||||
tag?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString({ message: "search must be a string" })
|
||||
search?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["updatedAt", "createdAt", "title"], {
|
||||
message: "sortBy must be updatedAt, createdAt, or title",
|
||||
})
|
||||
sortBy?: "updatedAt" | "createdAt" | "title";
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["asc", "desc"], { message: "sortOrder must be asc or desc" })
|
||||
sortOrder?: "asc" | "desc";
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt({ message: "page must be an integer" })
|
||||
|
||||
@@ -48,6 +48,10 @@ export class KnowledgeService {
|
||||
where.status = query.status;
|
||||
}
|
||||
|
||||
if (query.visibility) {
|
||||
where.visibility = query.visibility;
|
||||
}
|
||||
|
||||
if (query.tag) {
|
||||
where.tags = {
|
||||
some: {
|
||||
@@ -58,6 +62,20 @@ export class KnowledgeService {
|
||||
};
|
||||
}
|
||||
|
||||
if (query.search) {
|
||||
where.OR = [
|
||||
{ title: { contains: query.search, mode: "insensitive" } },
|
||||
{ content: { contains: query.search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
// Build orderBy
|
||||
const sortField = query.sortBy ?? "updatedAt";
|
||||
const sortDirection = query.sortOrder ?? "desc";
|
||||
const orderBy: Prisma.KnowledgeEntryOrderByWithRelationInput = {
|
||||
[sortField]: sortDirection,
|
||||
};
|
||||
|
||||
// Get total count
|
||||
const total = await this.prisma.knowledgeEntry.count({ where });
|
||||
|
||||
@@ -71,9 +89,7 @@ export class KnowledgeService {
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
orderBy,
|
||||
skip,
|
||||
take: limit,
|
||||
});
|
||||
|
||||
@@ -1,59 +1,38 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsInt,
|
||||
IsUUID,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
} from "class-validator";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for creating a new personality/assistant configuration
|
||||
* DTO for creating a new personality
|
||||
* Field names match the frontend API contract from @mosaic/shared Personality type.
|
||||
*/
|
||||
export class CreatePersonalityDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name!: string; // unique identifier slug
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
displayName!: string; // human-readable name
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
@IsString({ message: "description must be a string" })
|
||||
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
systemPrompt!: string;
|
||||
@IsString({ message: "tone must be a string" })
|
||||
@MinLength(1, { message: "tone must not be empty" })
|
||||
@MaxLength(100, { message: "tone must not exceed 100 characters" })
|
||||
tone!: string;
|
||||
|
||||
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
|
||||
formalityLevel!: FormalityLevel;
|
||||
|
||||
@IsString({ message: "systemPromptTemplate must be a string" })
|
||||
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
|
||||
systemPromptTemplate!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(2)
|
||||
temperature?: number; // null = use provider default
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxTokens?: number; // null = use provider default
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID("4")
|
||||
llmProviderInstanceId?: string; // FK to LlmProviderInstance
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@IsBoolean({ message: "isDefault must be a boolean" })
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./create-personality.dto";
|
||||
export * from "./update-personality.dto";
|
||||
export * from "./personality-query.dto";
|
||||
|
||||
12
apps/api/src/personalities/dto/personality-query.dto.ts
Normal file
12
apps/api/src/personalities/dto/personality-query.dto.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IsBoolean, IsOptional } from "class-validator";
|
||||
import { Transform } from "class-transformer";
|
||||
|
||||
/**
|
||||
* DTO for querying/filtering personalities
|
||||
*/
|
||||
export class PersonalityQueryDto {
|
||||
@IsOptional()
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
@Transform(({ value }) => value === "true" || value === true)
|
||||
isActive?: boolean;
|
||||
}
|
||||
@@ -1,62 +1,42 @@
|
||||
import {
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsInt,
|
||||
IsUUID,
|
||||
MinLength,
|
||||
MaxLength,
|
||||
Min,
|
||||
Max,
|
||||
} from "class-validator";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* DTO for updating an existing personality/assistant configuration
|
||||
* DTO for updating an existing personality
|
||||
* All fields are optional; only provided fields are updated.
|
||||
*/
|
||||
export class UpdatePersonalityDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
name?: string; // unique identifier slug
|
||||
@IsString({ message: "name must be a string" })
|
||||
@MinLength(1, { message: "name must not be empty" })
|
||||
@MaxLength(255, { message: "name must not exceed 255 characters" })
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
displayName?: string; // human-readable name
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
@IsString({ message: "description must be a string" })
|
||||
@MaxLength(2000, { message: "description must not exceed 2000 characters" })
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(10)
|
||||
systemPrompt?: string;
|
||||
@IsString({ message: "tone must be a string" })
|
||||
@MinLength(1, { message: "tone must not be empty" })
|
||||
@MaxLength(100, { message: "tone must not exceed 100 characters" })
|
||||
tone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(2)
|
||||
temperature?: number; // null = use provider default
|
||||
@IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
|
||||
formalityLevel?: FormalityLevel;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
maxTokens?: number; // null = use provider default
|
||||
@IsString({ message: "systemPromptTemplate must be a string" })
|
||||
@MinLength(1, { message: "systemPromptTemplate must not be empty" })
|
||||
systemPromptTemplate?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID("4")
|
||||
llmProviderInstanceId?: string; // FK to LlmProviderInstance
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@IsBoolean({ message: "isDefault must be a boolean" })
|
||||
isDefault?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isEnabled?: boolean;
|
||||
@IsBoolean({ message: "isActive must be a boolean" })
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import type { Personality as PrismaPersonality } from "@prisma/client";
|
||||
import type { FormalityLevel } from "@prisma/client";
|
||||
|
||||
/**
|
||||
* Personality entity representing an assistant configuration
|
||||
* Personality response entity
|
||||
* Maps Prisma Personality fields to the frontend API contract.
|
||||
*
|
||||
* Field mapping (Prisma -> API):
|
||||
* systemPrompt -> systemPromptTemplate
|
||||
* isEnabled -> isActive
|
||||
* (tone, formalityLevel are identical in both)
|
||||
*/
|
||||
export class Personality implements PrismaPersonality {
|
||||
id!: string;
|
||||
workspaceId!: string;
|
||||
name!: string; // unique identifier slug
|
||||
displayName!: string; // human-readable name
|
||||
description!: string | null;
|
||||
systemPrompt!: string;
|
||||
temperature!: number | null; // null = use provider default
|
||||
maxTokens!: number | null; // null = use provider default
|
||||
llmProviderInstanceId!: string | null; // FK to LlmProviderInstance
|
||||
isDefault!: boolean;
|
||||
isEnabled!: boolean;
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
export interface PersonalityResponse {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
tone: string;
|
||||
formalityLevel: FormalityLevel;
|
||||
systemPromptTemplate: string;
|
||||
isDefault: boolean;
|
||||
isActive: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
@@ -2,36 +2,32 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PersonalitiesController } from "./personalities.controller";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
|
||||
describe("PersonalitiesController", () => {
|
||||
let controller: PersonalitiesController;
|
||||
let service: PersonalitiesService;
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockUserId = "user-123";
|
||||
const mockPersonalityId = "personality-123";
|
||||
|
||||
/** API response shape (frontend field names) */
|
||||
const mockPersonality = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
displayName: "Professional Assistant",
|
||||
description: "A professional communication assistant",
|
||||
systemPrompt: "You are a professional assistant who helps with tasks.",
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
llmProviderInstanceId: "provider-123",
|
||||
tone: "professional",
|
||||
formalityLevel: FormalityLevel.FORMAL,
|
||||
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
|
||||
isDefault: true,
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockRequest = {
|
||||
user: { id: mockUserId },
|
||||
workspaceId: mockWorkspaceId,
|
||||
isActive: true,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
};
|
||||
|
||||
const mockPersonalitiesService = {
|
||||
@@ -57,46 +53,43 @@ describe("PersonalitiesController", () => {
|
||||
})
|
||||
.overrideGuard(AuthGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.overrideGuard(WorkspaceGuard)
|
||||
.useValue({
|
||||
canActivate: (ctx: {
|
||||
switchToHttp: () => { getRequest: () => { workspaceId: string } };
|
||||
}) => {
|
||||
const req = ctx.switchToHttp().getRequest();
|
||||
req.workspaceId = mockWorkspaceId;
|
||||
return true;
|
||||
},
|
||||
})
|
||||
.overrideGuard(PermissionGuard)
|
||||
.useValue({ canActivate: () => true })
|
||||
.compile();
|
||||
|
||||
controller = module.get<PersonalitiesController>(PersonalitiesController);
|
||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return all personalities", async () => {
|
||||
const mockPersonalities = [mockPersonality];
|
||||
mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities);
|
||||
it("should return success response with personalities list", async () => {
|
||||
const mockList = [mockPersonality];
|
||||
mockPersonalitiesService.findAll.mockResolvedValue(mockList);
|
||||
|
||||
const result = await controller.findAll(mockRequest);
|
||||
const result = await controller.findAll(mockWorkspaceId, {});
|
||||
|
||||
expect(result).toEqual(mockPersonalities);
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId);
|
||||
expect(result).toEqual({ success: true, data: mockList });
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, {});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
||||
it("should pass isActive query filter to service", async () => {
|
||||
mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]);
|
||||
|
||||
const result = await controller.findOne(mockRequest, mockPersonalityId);
|
||||
await controller.findAll(mockWorkspaceId, { isActive: true });
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByName", () => {
|
||||
it("should return a personality by name", async () => {
|
||||
mockPersonalitiesService.findByName.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findByName(mockRequest, "professional-assistant");
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findByName).toHaveBeenCalledWith(mockWorkspaceId, "professional-assistant");
|
||||
expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, { isActive: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -104,32 +97,40 @@ describe("PersonalitiesController", () => {
|
||||
it("should return the default personality", async () => {
|
||||
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findDefault(mockRequest);
|
||||
const result = await controller.findDefault(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
|
||||
|
||||
const result = await controller.findOne(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("should create a new personality", async () => {
|
||||
const createDto: CreatePersonalityDto = {
|
||||
name: "casual-helper",
|
||||
displayName: "Casual Helper",
|
||||
description: "A casual helper",
|
||||
systemPrompt: "You are a casual assistant.",
|
||||
temperature: 0.8,
|
||||
maxTokens: 1500,
|
||||
tone: "casual",
|
||||
formalityLevel: FormalityLevel.CASUAL,
|
||||
systemPromptTemplate: "You are a casual assistant.",
|
||||
};
|
||||
|
||||
mockPersonalitiesService.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDto,
|
||||
});
|
||||
const created = { ...mockPersonality, ...createDto, isActive: true, isDefault: false };
|
||||
mockPersonalitiesService.create.mockResolvedValue(created);
|
||||
|
||||
const result = await controller.create(mockRequest, createDto);
|
||||
const result = await controller.create(mockWorkspaceId, createDto);
|
||||
|
||||
expect(result).toMatchObject(createDto);
|
||||
expect(result).toMatchObject({ name: createDto.name, tone: createDto.tone });
|
||||
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
|
||||
});
|
||||
});
|
||||
@@ -138,15 +139,13 @@ describe("PersonalitiesController", () => {
|
||||
it("should update a personality", async () => {
|
||||
const updateDto: UpdatePersonalityDto = {
|
||||
description: "Updated description",
|
||||
temperature: 0.9,
|
||||
tone: "enthusiastic",
|
||||
};
|
||||
|
||||
mockPersonalitiesService.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...updateDto,
|
||||
});
|
||||
const updated = { ...mockPersonality, ...updateDto };
|
||||
mockPersonalitiesService.update.mockResolvedValue(updated);
|
||||
|
||||
const result = await controller.update(mockRequest, mockPersonalityId, updateDto);
|
||||
const result = await controller.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
|
||||
expect(result).toMatchObject(updateDto);
|
||||
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
@@ -157,7 +156,7 @@ describe("PersonalitiesController", () => {
|
||||
it("should delete a personality", async () => {
|
||||
mockPersonalitiesService.delete.mockResolvedValue(undefined);
|
||||
|
||||
await controller.delete(mockRequest, mockPersonalityId);
|
||||
await controller.delete(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
});
|
||||
@@ -165,12 +164,10 @@ describe("PersonalitiesController", () => {
|
||||
|
||||
describe("setDefault", () => {
|
||||
it("should set a personality as default", async () => {
|
||||
mockPersonalitiesService.setDefault.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
isDefault: true,
|
||||
});
|
||||
const updated = { ...mockPersonality, isDefault: true };
|
||||
mockPersonalitiesService.setDefault.mockResolvedValue(updated);
|
||||
|
||||
const result = await controller.setDefault(mockRequest, mockPersonalityId);
|
||||
const result = await controller.setDefault(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toMatchObject({ isDefault: true });
|
||||
expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
@@ -6,105 +6,122 @@ import {
|
||||
Delete,
|
||||
Body,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards,
|
||||
Req,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
} from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
|
||||
import { Workspace, Permission, RequirePermission } from "../common/decorators";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import { Personality } from "./entities/personality.entity";
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: { id: string };
|
||||
workspaceId: string;
|
||||
}
|
||||
import { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import { PersonalityQueryDto } from "./dto/personality-query.dto";
|
||||
import type { PersonalityResponse } from "./entities/personality.entity";
|
||||
|
||||
/**
|
||||
* Controller for managing personality/assistant configurations
|
||||
* Controller for personality CRUD endpoints.
|
||||
* Route: /api/personalities
|
||||
*
|
||||
* Guards applied in order:
|
||||
* 1. AuthGuard - verifies the user is authenticated
|
||||
* 2. WorkspaceGuard - validates workspace access
|
||||
* 3. PermissionGuard - checks role-based permissions
|
||||
*/
|
||||
@Controller("personality")
|
||||
@UseGuards(AuthGuard)
|
||||
@Controller("personalities")
|
||||
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
|
||||
export class PersonalitiesController {
|
||||
constructor(private readonly personalitiesService: PersonalitiesService) {}
|
||||
|
||||
/**
|
||||
* List all personalities for the workspace
|
||||
* GET /api/personalities
|
||||
* List all personalities for the workspace.
|
||||
* Supports ?isActive=true|false filter.
|
||||
*/
|
||||
@Get()
|
||||
async findAll(@Req() req: AuthenticatedRequest): Promise<Personality[]> {
|
||||
return this.personalitiesService.findAll(req.workspaceId);
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findAll(
|
||||
@Workspace() workspaceId: string,
|
||||
@Query() query: PersonalityQueryDto
|
||||
): Promise<{ success: true; data: PersonalityResponse[] }> {
|
||||
const data = await this.personalitiesService.findAll(workspaceId, query);
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default personality for the workspace
|
||||
* GET /api/personalities/default
|
||||
* Get the default personality for the workspace.
|
||||
* Must be declared before :id to avoid route conflicts.
|
||||
*/
|
||||
@Get("default")
|
||||
async findDefault(@Req() req: AuthenticatedRequest): Promise<Personality> {
|
||||
return this.personalitiesService.findDefault(req.workspaceId);
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findDefault(@Workspace() workspaceId: string): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.findDefault(workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a personality by its unique name
|
||||
*/
|
||||
@Get("by-name/:name")
|
||||
async findByName(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Param("name") name: string
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.findByName(req.workspaceId, name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a personality by ID
|
||||
* GET /api/personalities/:id
|
||||
* Get a single personality by ID.
|
||||
*/
|
||||
@Get(":id")
|
||||
async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<Personality> {
|
||||
return this.personalitiesService.findOne(req.workspaceId, id);
|
||||
@RequirePermission(Permission.WORKSPACE_ANY)
|
||||
async findOne(
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("id") id: string
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.findOne(workspaceId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personality
|
||||
* POST /api/personalities
|
||||
* Create a new personality.
|
||||
*/
|
||||
@Post()
|
||||
@HttpCode(HttpStatus.CREATED)
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async create(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Workspace() workspaceId: string,
|
||||
@Body() dto: CreatePersonalityDto
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.create(req.workspaceId, dto);
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.create(workspaceId, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a personality
|
||||
* PATCH /api/personalities/:id
|
||||
* Update an existing personality.
|
||||
*/
|
||||
@Patch(":id")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async update(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("id") id: string,
|
||||
@Body() dto: UpdatePersonalityDto
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.update(req.workspaceId, id, dto);
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.update(workspaceId, id, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a personality
|
||||
* DELETE /api/personalities/:id
|
||||
* Delete a personality.
|
||||
*/
|
||||
@Delete(":id")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise<void> {
|
||||
return this.personalitiesService.delete(req.workspaceId, id);
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async delete(@Workspace() workspaceId: string, @Param("id") id: string): Promise<void> {
|
||||
return this.personalitiesService.delete(workspaceId, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a personality as the default
|
||||
* POST /api/personalities/:id/set-default
|
||||
* Convenience endpoint to set a personality as the default.
|
||||
*/
|
||||
@Post(":id/set-default")
|
||||
@RequirePermission(Permission.WORKSPACE_MEMBER)
|
||||
async setDefault(
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@Workspace() workspaceId: string,
|
||||
@Param("id") id: string
|
||||
): Promise<Personality> {
|
||||
return this.personalitiesService.setDefault(req.workspaceId, id);
|
||||
): Promise<PersonalityResponse> {
|
||||
return this.personalitiesService.setDefault(workspaceId, id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import { PersonalitiesService } from "./personalities.service";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import { NotFoundException, ConflictException } from "@nestjs/common";
|
||||
import { FormalityLevel } from "@prisma/client";
|
||||
|
||||
describe("PersonalitiesService", () => {
|
||||
let service: PersonalitiesService;
|
||||
@@ -11,22 +13,39 @@ describe("PersonalitiesService", () => {
|
||||
|
||||
const mockWorkspaceId = "workspace-123";
|
||||
const mockPersonalityId = "personality-123";
|
||||
const mockProviderId = "provider-123";
|
||||
|
||||
const mockPersonality = {
|
||||
/** Raw Prisma record shape (uses Prisma field names) */
|
||||
const mockPrismaRecord = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
displayName: "Professional Assistant",
|
||||
description: "A professional communication assistant",
|
||||
tone: "professional",
|
||||
formalityLevel: FormalityLevel.FORMAL,
|
||||
systemPrompt: "You are a professional assistant who helps with tasks.",
|
||||
temperature: 0.7,
|
||||
maxTokens: 2000,
|
||||
llmProviderInstanceId: mockProviderId,
|
||||
llmProviderInstanceId: "provider-123",
|
||||
isDefault: true,
|
||||
isEnabled: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
};
|
||||
|
||||
/** Expected API response shape (uses frontend field names) */
|
||||
const mockResponse = {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
description: "A professional communication assistant",
|
||||
tone: "professional",
|
||||
formalityLevel: FormalityLevel.FORMAL,
|
||||
systemPromptTemplate: "You are a professional assistant who helps with tasks.",
|
||||
isDefault: true,
|
||||
isActive: true,
|
||||
createdAt: new Date("2026-01-01"),
|
||||
updatedAt: new Date("2026-01-01"),
|
||||
};
|
||||
|
||||
const mockPrismaService = {
|
||||
@@ -37,9 +56,7 @@ describe("PersonalitiesService", () => {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn((callback) => callback(mockPrismaService)),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -56,44 +73,54 @@ describe("PersonalitiesService", () => {
|
||||
service = module.get<PersonalitiesService>(PersonalitiesService);
|
||||
prisma = module.get<PrismaService>(PrismaService);
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
const createDto: CreatePersonalityDto = {
|
||||
name: "casual-helper",
|
||||
displayName: "Casual Helper",
|
||||
description: "A casual communication helper",
|
||||
systemPrompt: "You are a casual assistant.",
|
||||
temperature: 0.8,
|
||||
maxTokens: 1500,
|
||||
llmProviderInstanceId: mockProviderId,
|
||||
tone: "casual",
|
||||
formalityLevel: FormalityLevel.CASUAL,
|
||||
systemPromptTemplate: "You are a casual assistant.",
|
||||
isDefault: false,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
it("should create a new personality", async () => {
|
||||
const createdRecord = {
|
||||
...mockPrismaRecord,
|
||||
name: createDto.name,
|
||||
description: createDto.description,
|
||||
tone: createDto.tone,
|
||||
formalityLevel: createDto.formalityLevel,
|
||||
systemPrompt: createDto.systemPromptTemplate,
|
||||
isDefault: false,
|
||||
isEnabled: true,
|
||||
id: "new-personality-id",
|
||||
};
|
||||
|
||||
it("should create a new personality and return API response shape", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.personality.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDto,
|
||||
id: "new-personality-id",
|
||||
isDefault: false,
|
||||
isEnabled: true,
|
||||
});
|
||||
mockPrismaService.personality.create.mockResolvedValue(createdRecord);
|
||||
|
||||
const result = await service.create(mockWorkspaceId, createDto);
|
||||
|
||||
expect(result).toMatchObject(createDto);
|
||||
expect(result.name).toBe(createDto.name);
|
||||
expect(result.tone).toBe(createDto.tone);
|
||||
expect(result.formalityLevel).toBe(createDto.formalityLevel);
|
||||
expect(result.systemPromptTemplate).toBe(createDto.systemPromptTemplate);
|
||||
expect(result.isActive).toBe(true);
|
||||
expect(result.isDefault).toBe(false);
|
||||
|
||||
expect(prisma.personality.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: createDto.name,
|
||||
displayName: createDto.displayName,
|
||||
displayName: createDto.name,
|
||||
description: createDto.description ?? null,
|
||||
systemPrompt: createDto.systemPrompt,
|
||||
temperature: createDto.temperature ?? null,
|
||||
maxTokens: createDto.maxTokens ?? null,
|
||||
llmProviderInstanceId: createDto.llmProviderInstanceId ?? null,
|
||||
tone: createDto.tone,
|
||||
formalityLevel: createDto.formalityLevel,
|
||||
systemPrompt: createDto.systemPromptTemplate,
|
||||
isDefault: false,
|
||||
isEnabled: true,
|
||||
},
|
||||
@@ -101,68 +128,73 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
it("should throw ConflictException when name already exists", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it("should unset other defaults when creating a new default personality", async () => {
|
||||
const createDefaultDto = { ...createDto, isDefault: true };
|
||||
// First call to findFirst checks for name conflict (should be null)
|
||||
// Second call to findFirst finds the existing default personality
|
||||
const createDefaultDto: CreatePersonalityDto = { ...createDto, isDefault: true };
|
||||
const otherDefault = { ...mockPrismaRecord, id: "other-id" };
|
||||
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(null) // No name conflict
|
||||
.mockResolvedValueOnce(mockPersonality); // Existing default
|
||||
mockPrismaService.personality.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
isDefault: false,
|
||||
});
|
||||
.mockResolvedValueOnce(null) // name conflict check
|
||||
.mockResolvedValueOnce(otherDefault); // existing default lookup
|
||||
mockPrismaService.personality.update.mockResolvedValue({ ...otherDefault, isDefault: false });
|
||||
mockPrismaService.personality.create.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...createDefaultDto,
|
||||
...createdRecord,
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
await service.create(mockWorkspaceId, createDefaultDto);
|
||||
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
where: { id: "other-id" },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findAll", () => {
|
||||
it("should return all personalities for a workspace", async () => {
|
||||
const mockPersonalities = [mockPersonality];
|
||||
mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
|
||||
it("should return mapped response list for a workspace", async () => {
|
||||
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
|
||||
|
||||
const result = await service.findAll(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonalities);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toEqual(mockResponse);
|
||||
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId },
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by isActive when provided", async () => {
|
||||
mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
|
||||
|
||||
await service.findAll(mockWorkspaceId, { isActive: true });
|
||||
|
||||
expect(prisma.personality.findMany).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId, isEnabled: true },
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("findOne", () => {
|
||||
it("should return a personality by id", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
it("should return a mapped personality response by id", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(prisma.personality.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: mockPersonalityId,
|
||||
workspaceId: mockWorkspaceId,
|
||||
},
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId, workspaceId: mockWorkspaceId },
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
@@ -171,17 +203,14 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
describe("findByName", () => {
|
||||
it("should return a personality by name", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
it("should return a mapped personality response by name", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
const result = await service.findByName(mockWorkspaceId, "professional-assistant");
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
workspaceId: mockWorkspaceId,
|
||||
name: "professional-assistant",
|
||||
},
|
||||
where: { workspaceId: mockWorkspaceId, name: "professional-assistant" },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,11 +225,11 @@ describe("PersonalitiesService", () => {
|
||||
|
||||
describe("findDefault", () => {
|
||||
it("should return the default personality", async () => {
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
|
||||
const result = await service.findDefault(mockWorkspaceId);
|
||||
|
||||
expect(result).toEqual(mockPersonality);
|
||||
expect(result).toEqual(mockResponse);
|
||||
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
|
||||
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
|
||||
});
|
||||
@@ -216,41 +245,45 @@ describe("PersonalitiesService", () => {
|
||||
describe("update", () => {
|
||||
const updateDto: UpdatePersonalityDto = {
|
||||
description: "Updated description",
|
||||
temperature: 0.9,
|
||||
tone: "formal",
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
it("should update a personality", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
mockPrismaService.personality.update.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
...updateDto,
|
||||
});
|
||||
it("should update a personality and return mapped response", async () => {
|
||||
const updatedRecord = {
|
||||
...mockPrismaRecord,
|
||||
description: updateDto.description,
|
||||
tone: updateDto.tone,
|
||||
isEnabled: false,
|
||||
};
|
||||
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(null); // name conflict check (no dto.name here)
|
||||
mockPrismaService.personality.update.mockResolvedValue(updatedRecord);
|
||||
|
||||
const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto);
|
||||
|
||||
expect(result).toMatchObject(updateDto);
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
data: updateDto,
|
||||
});
|
||||
expect(result.description).toBe(updateDto.description);
|
||||
expect(result.tone).toBe(updateDto.tone);
|
||||
expect(result.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow(
|
||||
NotFoundException
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw ConflictException when updating to existing name", async () => {
|
||||
const updateNameDto = { name: "existing-name" };
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue({
|
||||
...mockPersonality,
|
||||
id: "different-id",
|
||||
});
|
||||
it("should throw ConflictException when updating to an existing name", async () => {
|
||||
const updateNameDto: UpdatePersonalityDto = { name: "existing-name" };
|
||||
const conflictRecord = { ...mockPrismaRecord, id: "different-id" };
|
||||
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(conflictRecord); // name conflict
|
||||
|
||||
await expect(
|
||||
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
|
||||
@@ -258,14 +291,16 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
it("should unset other defaults when setting as default", async () => {
|
||||
const updateDefaultDto = { isDefault: true };
|
||||
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
|
||||
const updateDefaultDto: UpdatePersonalityDto = { isDefault: true };
|
||||
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
|
||||
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
|
||||
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
|
||||
mockPrismaService.personality.update
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
|
||||
.mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
|
||||
.mockResolvedValueOnce(updatedRecord);
|
||||
|
||||
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
|
||||
|
||||
@@ -273,16 +308,12 @@ describe("PersonalitiesService", () => {
|
||||
where: { id: "other-id" },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
|
||||
where: { id: mockPersonalityId },
|
||||
data: updateDefaultDto,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete", () => {
|
||||
it("should delete a personality", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
|
||||
mockPrismaService.personality.delete.mockResolvedValue(undefined);
|
||||
|
||||
await service.delete(mockWorkspaceId, mockPersonalityId);
|
||||
@@ -293,7 +324,7 @@ describe("PersonalitiesService", () => {
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
@@ -303,30 +334,27 @@ describe("PersonalitiesService", () => {
|
||||
|
||||
describe("setDefault", () => {
|
||||
it("should set a personality as default", async () => {
|
||||
const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
|
||||
const updatedPersonality = { ...mockPersonality, isDefault: true };
|
||||
const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
|
||||
const updatedRecord = { ...mockPrismaRecord, isDefault: true };
|
||||
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality);
|
||||
mockPrismaService.personality.findFirst
|
||||
.mockResolvedValueOnce(mockPrismaRecord) // findOne check
|
||||
.mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
|
||||
mockPrismaService.personality.update
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
|
||||
.mockResolvedValueOnce(updatedPersonality); // Set new default
|
||||
.mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
|
||||
.mockResolvedValueOnce(updatedRecord);
|
||||
|
||||
const result = await service.setDefault(mockWorkspaceId, mockPersonalityId);
|
||||
|
||||
expect(result).toMatchObject({ isDefault: true });
|
||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(1, {
|
||||
where: { id: "other-id" },
|
||||
data: { isDefault: false },
|
||||
});
|
||||
expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
|
||||
expect(result.isDefault).toBe(true);
|
||||
expect(prisma.personality.update).toHaveBeenCalledWith({
|
||||
where: { id: mockPersonalityId },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("should throw NotFoundException when personality not found", async () => {
|
||||
mockPrismaService.personality.findUnique.mockResolvedValue(null);
|
||||
mockPrismaService.personality.findFirst.mockResolvedValue(null);
|
||||
|
||||
await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
|
||||
NotFoundException
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
|
||||
import type { FormalityLevel, Personality } from "@prisma/client";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
|
||||
import { Personality } from "./entities/personality.entity";
|
||||
import type { CreatePersonalityDto } from "./dto/create-personality.dto";
|
||||
import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
|
||||
import type { PersonalityQueryDto } from "./dto/personality-query.dto";
|
||||
import type { PersonalityResponse } from "./entities/personality.entity";
|
||||
|
||||
/**
|
||||
* Service for managing personality/assistant configurations
|
||||
* Service for managing personality/assistant configurations.
|
||||
*
|
||||
* Field mapping:
|
||||
* Prisma `systemPrompt` <-> API/frontend `systemPromptTemplate`
|
||||
* Prisma `isEnabled` <-> API/frontend `isActive`
|
||||
*/
|
||||
@Injectable()
|
||||
export class PersonalitiesService {
|
||||
@@ -12,11 +19,30 @@ export class PersonalitiesService {
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/**
|
||||
* Map a Prisma Personality record to the API response shape.
|
||||
*/
|
||||
private toResponse(personality: Personality): PersonalityResponse {
|
||||
return {
|
||||
id: personality.id,
|
||||
workspaceId: personality.workspaceId,
|
||||
name: personality.name,
|
||||
description: personality.description,
|
||||
tone: personality.tone,
|
||||
formalityLevel: personality.formalityLevel,
|
||||
systemPromptTemplate: personality.systemPrompt,
|
||||
isDefault: personality.isDefault,
|
||||
isActive: personality.isEnabled,
|
||||
createdAt: personality.createdAt,
|
||||
updatedAt: personality.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new personality
|
||||
*/
|
||||
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<Personality> {
|
||||
// Check for duplicate name
|
||||
async create(workspaceId: string, dto: CreatePersonalityDto): Promise<PersonalityResponse> {
|
||||
// Check for duplicate name within workspace
|
||||
const existing = await this.prisma.personality.findFirst({
|
||||
where: { workspaceId, name: dto.name },
|
||||
});
|
||||
@@ -25,7 +51,7 @@ export class PersonalitiesService {
|
||||
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
|
||||
}
|
||||
|
||||
// If creating a default personality, unset other defaults
|
||||
// If creating as default, unset other defaults first
|
||||
if (dto.isDefault) {
|
||||
await this.unsetOtherDefaults(workspaceId);
|
||||
}
|
||||
@@ -34,36 +60,43 @@ export class PersonalitiesService {
|
||||
data: {
|
||||
workspaceId,
|
||||
name: dto.name,
|
||||
displayName: dto.displayName,
|
||||
displayName: dto.name, // use name as displayName since frontend doesn't send displayName separately
|
||||
description: dto.description ?? null,
|
||||
systemPrompt: dto.systemPrompt,
|
||||
temperature: dto.temperature ?? null,
|
||||
maxTokens: dto.maxTokens ?? null,
|
||||
llmProviderInstanceId: dto.llmProviderInstanceId ?? null,
|
||||
tone: dto.tone,
|
||||
formalityLevel: dto.formalityLevel,
|
||||
systemPrompt: dto.systemPromptTemplate,
|
||||
isDefault: dto.isDefault ?? false,
|
||||
isEnabled: dto.isEnabled ?? true,
|
||||
isEnabled: dto.isActive ?? true,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all personalities for a workspace
|
||||
* Find all personalities for a workspace with optional active filter
|
||||
*/
|
||||
async findAll(workspaceId: string): Promise<Personality[]> {
|
||||
return this.prisma.personality.findMany({
|
||||
where: { workspaceId },
|
||||
async findAll(workspaceId: string, query?: PersonalityQueryDto): Promise<PersonalityResponse[]> {
|
||||
const where: { workspaceId: string; isEnabled?: boolean } = { workspaceId };
|
||||
|
||||
if (query?.isActive !== undefined) {
|
||||
where.isEnabled = query.isActive;
|
||||
}
|
||||
|
||||
const personalities = await this.prisma.personality.findMany({
|
||||
where,
|
||||
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
|
||||
});
|
||||
|
||||
return personalities.map((p) => this.toResponse(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific personality by ID
|
||||
*/
|
||||
async findOne(workspaceId: string, id: string): Promise<Personality> {
|
||||
const personality = await this.prisma.personality.findUnique({
|
||||
async findOne(workspaceId: string, id: string): Promise<PersonalityResponse> {
|
||||
const personality = await this.prisma.personality.findFirst({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
@@ -71,13 +104,13 @@ export class PersonalitiesService {
|
||||
throw new NotFoundException(`Personality with ID ${id} not found`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a personality by name
|
||||
* Find a personality by name slug
|
||||
*/
|
||||
async findByName(workspaceId: string, name: string): Promise<Personality> {
|
||||
async findByName(workspaceId: string, name: string): Promise<PersonalityResponse> {
|
||||
const personality = await this.prisma.personality.findFirst({
|
||||
where: { workspaceId, name },
|
||||
});
|
||||
@@ -86,13 +119,13 @@ export class PersonalitiesService {
|
||||
throw new NotFoundException(`Personality with name "${name}" not found`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the default personality for a workspace
|
||||
* Find the default (and enabled) personality for a workspace
|
||||
*/
|
||||
async findDefault(workspaceId: string): Promise<Personality> {
|
||||
async findDefault(workspaceId: string): Promise<PersonalityResponse> {
|
||||
const personality = await this.prisma.personality.findFirst({
|
||||
where: { workspaceId, isDefault: true, isEnabled: true },
|
||||
});
|
||||
@@ -101,14 +134,18 @@ export class PersonalitiesService {
|
||||
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
|
||||
}
|
||||
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing personality
|
||||
*/
|
||||
async update(workspaceId: string, id: string, dto: UpdatePersonalityDto): Promise<Personality> {
|
||||
// Check existence
|
||||
async update(
|
||||
workspaceId: string,
|
||||
id: string,
|
||||
dto: UpdatePersonalityDto
|
||||
): Promise<PersonalityResponse> {
|
||||
// Verify existence
|
||||
await this.findOne(workspaceId, id);
|
||||
|
||||
// Check for duplicate name if updating name
|
||||
@@ -127,20 +164,43 @@ export class PersonalitiesService {
|
||||
await this.unsetOtherDefaults(workspaceId, id);
|
||||
}
|
||||
|
||||
// Build update data with field mapping
|
||||
const updateData: {
|
||||
name?: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
tone?: string;
|
||||
formalityLevel?: FormalityLevel;
|
||||
systemPrompt?: string;
|
||||
isDefault?: boolean;
|
||||
isEnabled?: boolean;
|
||||
} = {};
|
||||
|
||||
if (dto.name !== undefined) {
|
||||
updateData.name = dto.name;
|
||||
updateData.displayName = dto.name;
|
||||
}
|
||||
if (dto.description !== undefined) updateData.description = dto.description;
|
||||
if (dto.tone !== undefined) updateData.tone = dto.tone;
|
||||
if (dto.formalityLevel !== undefined) updateData.formalityLevel = dto.formalityLevel;
|
||||
if (dto.systemPromptTemplate !== undefined) updateData.systemPrompt = dto.systemPromptTemplate;
|
||||
if (dto.isDefault !== undefined) updateData.isDefault = dto.isDefault;
|
||||
if (dto.isActive !== undefined) updateData.isEnabled = dto.isActive;
|
||||
|
||||
const personality = await this.prisma.personality.update({
|
||||
where: { id },
|
||||
data: dto,
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a personality
|
||||
*/
|
||||
async delete(workspaceId: string, id: string): Promise<void> {
|
||||
// Check existence
|
||||
// Verify existence
|
||||
await this.findOne(workspaceId, id);
|
||||
|
||||
await this.prisma.personality.delete({
|
||||
@@ -151,23 +211,22 @@ export class PersonalitiesService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a personality as the default
|
||||
* Set a personality as the default (convenience endpoint)
|
||||
*/
|
||||
async setDefault(workspaceId: string, id: string): Promise<Personality> {
|
||||
// Check existence
|
||||
async setDefault(workspaceId: string, id: string): Promise<PersonalityResponse> {
|
||||
// Verify existence
|
||||
await this.findOne(workspaceId, id);
|
||||
|
||||
// Unset other defaults
|
||||
await this.unsetOtherDefaults(workspaceId, id);
|
||||
|
||||
// Set this one as default
|
||||
const personality = await this.prisma.personality.update({
|
||||
where: { id },
|
||||
data: { isDefault: true },
|
||||
});
|
||||
|
||||
this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`);
|
||||
return personality;
|
||||
return this.toResponse(personality);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,7 +237,7 @@ export class PersonalitiesService {
|
||||
where: {
|
||||
workspaceId,
|
||||
isDefault: true,
|
||||
...(excludeId && { id: { not: excludeId } }),
|
||||
...(excludeId !== undefined && { id: { not: excludeId } }),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -140,8 +140,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
workspaceId: string,
|
||||
client: PrismaClient = this
|
||||
): Promise<void> {
|
||||
await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
|
||||
await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
|
||||
// Use set_config() instead of SET LOCAL so values are safely parameterized.
|
||||
// SET LOCAL with Prisma's tagged template produces invalid SQL (bind parameter $1
|
||||
// is not supported in SET statements by PostgreSQL).
|
||||
await client.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`;
|
||||
await client.$executeRaw`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,8 +154,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
|
||||
* @param client - Optional Prisma client (uses 'this' if not provided)
|
||||
*/
|
||||
async clearWorkspaceContext(client: PrismaClient = this): Promise<void> {
|
||||
await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
|
||||
await client.$executeRaw`SET LOCAL app.current_workspace_id = NULL`;
|
||||
await client.$executeRaw`SELECT set_config('app.current_user_id', '', true)`;
|
||||
await client.$executeRaw`SELECT set_config('app.current_workspace_id', '', true)`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,7 +46,7 @@ describe("TerminalService", () => {
|
||||
let service: TerminalService;
|
||||
let mockSocket: Socket;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
// Reset mock implementations
|
||||
mockPtyProcess.onData.mockImplementation((_cb: (data: string) => void) => {});
|
||||
@@ -54,6 +54,8 @@ describe("TerminalService", () => {
|
||||
(_cb: (e: { exitCode: number; signal?: number }) => void) => {}
|
||||
);
|
||||
service = new TerminalService();
|
||||
// Trigger lazy import of node-pty (uses dynamic import(), intercepted by vi.mock)
|
||||
await service.onModuleInit();
|
||||
mockSocket = createMockSocket();
|
||||
});
|
||||
|
||||
|
||||
@@ -13,11 +13,19 @@
|
||||
* - closeWorkspaceSessions: kill all sessions for a workspace (on disconnect)
|
||||
*/
|
||||
|
||||
import { Injectable, Logger } from "@nestjs/common";
|
||||
import * as pty from "node-pty";
|
||||
import { Injectable, Logger, OnModuleInit } from "@nestjs/common";
|
||||
import type { IPty } from "node-pty";
|
||||
import type { Socket } from "socket.io";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// Lazy-loaded in onModuleInit via dynamic import() to prevent crash
|
||||
// if the native binary is missing. node-pty requires a compiled .node
|
||||
// binary which may not be available in all Docker environments.
|
||||
interface NodePtyModule {
|
||||
spawn: (file: string, args: string[], options: Record<string, unknown>) => IPty;
|
||||
}
|
||||
let pty: NodePtyModule | null = null;
|
||||
|
||||
/** Maximum concurrent PTY sessions per workspace */
|
||||
export const MAX_SESSIONS_PER_WORKSPACE = parseInt(
|
||||
process.env.TERMINAL_MAX_SESSIONS_PER_WORKSPACE ?? "10",
|
||||
@@ -31,7 +39,7 @@ const DEFAULT_ROWS = 24;
|
||||
export interface TerminalSession {
|
||||
sessionId: string;
|
||||
workspaceId: string;
|
||||
pty: pty.IPty;
|
||||
pty: IPty;
|
||||
name?: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
@@ -53,7 +61,7 @@ export interface SessionCreatedResult {
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class TerminalService {
|
||||
export class TerminalService implements OnModuleInit {
|
||||
private readonly logger = new Logger(TerminalService.name);
|
||||
|
||||
/**
|
||||
@@ -66,13 +74,30 @@ export class TerminalService {
|
||||
*/
|
||||
private readonly workspaceSessions = new Map<string, Set<string>>();
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
if (!pty) {
|
||||
try {
|
||||
pty = await import("node-pty");
|
||||
this.logger.log("node-pty loaded successfully — terminal sessions available");
|
||||
} catch {
|
||||
this.logger.warn(
|
||||
"node-pty native module not available — terminal sessions will be disabled. " +
|
||||
"Install build tools (python3, make, g++) and rebuild node-pty to enable."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new PTY session for the given workspace and socket.
|
||||
* Wires PTY onData -> emit terminal:output and onExit -> emit terminal:exit.
|
||||
*
|
||||
* @throws Error if workspace session limit is exceeded
|
||||
* @throws Error if workspace session limit is exceeded or node-pty is unavailable
|
||||
*/
|
||||
createSession(socket: Socket, options: CreateSessionOptions): SessionCreatedResult {
|
||||
if (!pty) {
|
||||
throw new Error("Terminal sessions are unavailable: node-pty native module failed to load");
|
||||
}
|
||||
const { workspaceId, name, cwd, socketId } = options;
|
||||
const cols = options.cols ?? DEFAULT_COLS;
|
||||
const rows = options.rows ?? DEFAULT_ROWS;
|
||||
|
||||
112
apps/api/src/users/preferences.controller.spec.ts
Normal file
112
apps/api/src/users/preferences.controller.spec.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { UnauthorizedException } from "@nestjs/common";
|
||||
import { PreferencesController } from "./preferences.controller";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import type { UpdatePreferencesDto, PreferencesResponseDto } from "./dto";
|
||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
|
||||
describe("PreferencesController", () => {
|
||||
let controller: PreferencesController;
|
||||
let service: PreferencesService;
|
||||
|
||||
const mockPreferencesService = {
|
||||
getPreferences: vi.fn(),
|
||||
updatePreferences: vi.fn(),
|
||||
};
|
||||
|
||||
const mockUserId = "user-uuid-123";
|
||||
|
||||
const mockPreferencesResponse: PreferencesResponseDto = {
|
||||
id: "pref-uuid-456",
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
timezone: null,
|
||||
settings: {},
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
function makeRequest(userId?: string): AuthenticatedRequest {
|
||||
return {
|
||||
user: userId ? { id: userId } : undefined,
|
||||
} as unknown as AuthenticatedRequest;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
service = mockPreferencesService as unknown as PreferencesService;
|
||||
controller = new PreferencesController(service);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/users/me/preferences", () => {
|
||||
it("should return preferences for authenticated user", async () => {
|
||||
mockPreferencesService.getPreferences.mockResolvedValue(mockPreferencesResponse);
|
||||
|
||||
const result = await controller.getPreferences(makeRequest(mockUserId));
|
||||
|
||||
expect(result).toEqual(mockPreferencesResponse);
|
||||
expect(mockPreferencesService.getPreferences).toHaveBeenCalledWith(mockUserId);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||
await expect(controller.getPreferences(makeRequest())).rejects.toThrow(UnauthorizedException);
|
||||
expect(mockPreferencesService.getPreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/users/me/preferences", () => {
|
||||
const updateDto: UpdatePreferencesDto = {
|
||||
theme: "dark",
|
||||
locale: "fr",
|
||||
timezone: "Europe/Paris",
|
||||
};
|
||||
|
||||
it("should update and return preferences for authenticated user", async () => {
|
||||
const updatedResponse: PreferencesResponseDto = {
|
||||
...mockPreferencesResponse,
|
||||
theme: "dark",
|
||||
locale: "fr",
|
||||
timezone: "Europe/Paris",
|
||||
};
|
||||
mockPreferencesService.updatePreferences.mockResolvedValue(updatedResponse);
|
||||
|
||||
const result = await controller.updatePreferences(updateDto, makeRequest(mockUserId));
|
||||
|
||||
expect(result).toEqual(updatedResponse);
|
||||
expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, updateDto);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||
await expect(controller.updatePreferences(updateDto, makeRequest())).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/users/me/preferences", () => {
|
||||
const patchDto: UpdatePreferencesDto = {
|
||||
theme: "light",
|
||||
};
|
||||
|
||||
it("should partially update and return preferences for authenticated user", async () => {
|
||||
const patchedResponse: PreferencesResponseDto = {
|
||||
...mockPreferencesResponse,
|
||||
theme: "light",
|
||||
};
|
||||
mockPreferencesService.updatePreferences.mockResolvedValue(patchedResponse);
|
||||
|
||||
const result = await controller.patchPreferences(patchDto, makeRequest(mockUserId));
|
||||
|
||||
expect(result).toEqual(patchedResponse);
|
||||
expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, patchDto);
|
||||
});
|
||||
|
||||
it("should throw UnauthorizedException when user is not authenticated", async () => {
|
||||
await expect(controller.patchPreferences(patchDto, makeRequest())).rejects.toThrow(
|
||||
UnauthorizedException
|
||||
);
|
||||
expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
Controller,
|
||||
Get,
|
||||
Put,
|
||||
Patch,
|
||||
Body,
|
||||
UseGuards,
|
||||
Request,
|
||||
@@ -38,7 +39,7 @@ export class PreferencesController {
|
||||
|
||||
/**
|
||||
* PUT /api/users/me/preferences
|
||||
* Update current user's preferences
|
||||
* Full replace of current user's preferences
|
||||
*/
|
||||
@Put()
|
||||
async updatePreferences(
|
||||
@@ -53,4 +54,22 @@ export class PreferencesController {
|
||||
|
||||
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH /api/users/me/preferences
|
||||
* Partial update of current user's preferences
|
||||
*/
|
||||
@Patch()
|
||||
async patchPreferences(
|
||||
@Body() updatePreferencesDto: UpdatePreferencesDto,
|
||||
@Request() req: AuthenticatedRequest
|
||||
) {
|
||||
const userId = req.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
throw new UnauthorizedException("Authentication required");
|
||||
}
|
||||
|
||||
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
|
||||
}
|
||||
}
|
||||
|
||||
141
apps/api/src/users/preferences.service.spec.ts
Normal file
141
apps/api/src/users/preferences.service.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { PreferencesService } from "./preferences.service";
|
||||
import type { PrismaService } from "../prisma/prisma.service";
|
||||
import type { UpdatePreferencesDto } from "./dto";
|
||||
|
||||
describe("PreferencesService", () => {
|
||||
let service: PreferencesService;
|
||||
|
||||
const mockPrisma = {
|
||||
userPreference: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const mockUserId = "user-uuid-123";
|
||||
|
||||
const mockDbPreference = {
|
||||
id: "pref-uuid-456",
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
timezone: null,
|
||||
settings: {},
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
service = new PreferencesService(mockPrisma as unknown as PrismaService);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("getPreferences", () => {
|
||||
it("should return existing preferences", async () => {
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
|
||||
const result = await service.getPreferences(mockUserId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: mockDbPreference.id,
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
timezone: null,
|
||||
settings: {},
|
||||
});
|
||||
expect(mockPrisma.userPreference.findUnique).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
});
|
||||
expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create default preferences when none exist", async () => {
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.userPreference.create.mockResolvedValue(mockDbPreference);
|
||||
|
||||
const result = await service.getPreferences(mockUserId);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: mockDbPreference.id,
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
});
|
||||
expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: mockUserId,
|
||||
theme: "system",
|
||||
locale: "en",
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("updatePreferences", () => {
|
||||
it("should update existing preferences", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { theme: "dark", locale: "fr" };
|
||||
const updatedPreference = { ...mockDbPreference, theme: "dark", locale: "fr" };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result).toMatchObject({ theme: "dark", locale: "fr" });
|
||||
expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
data: expect.objectContaining({ theme: "dark", locale: "fr" }),
|
||||
});
|
||||
expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should create preferences when updating non-existent record", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { theme: "light" };
|
||||
const createdPreference = { ...mockDbPreference, theme: "light" };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.userPreference.create.mockResolvedValue(createdPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result).toMatchObject({ theme: "light" });
|
||||
expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: mockUserId,
|
||||
theme: "light",
|
||||
}),
|
||||
});
|
||||
expect(mockPrisma.userPreference.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle timezone update", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { timezone: "America/New_York" };
|
||||
const updatedPreference = { ...mockDbPreference, timezone: "America/New_York" };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result.timezone).toBe("America/New_York");
|
||||
expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
|
||||
where: { userId: mockUserId },
|
||||
data: expect.objectContaining({ timezone: "America/New_York" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle settings update", async () => {
|
||||
const updateDto: UpdatePreferencesDto = { settings: { notifications: true } };
|
||||
const updatedPreference = { ...mockDbPreference, settings: { notifications: true } };
|
||||
|
||||
mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
|
||||
mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
|
||||
|
||||
const result = await service.updatePreferences(mockUserId, updateDto);
|
||||
|
||||
expect(result.settings).toEqual({ notifications: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import { Logger } from "@nestjs/common";
|
||||
import { Server, Socket } from "socket.io";
|
||||
import { AuthService } from "../auth/auth.service";
|
||||
import { getTrustedOrigins } from "../auth/auth.config";
|
||||
import { PrismaService } from "../prisma/prisma.service";
|
||||
|
||||
interface AuthenticatedSocket extends Socket {
|
||||
@@ -77,7 +78,7 @@ interface StepOutputData {
|
||||
*/
|
||||
@WSGateway({
|
||||
cors: {
|
||||
origin: process.env.WEB_URL ?? "http://localhost:3000",
|
||||
origin: getTrustedOrigins(),
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
@@ -167,17 +168,36 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Extract authentication token from Socket.IO handshake
|
||||
* @description Extract authentication token from Socket.IO handshake.
|
||||
*
|
||||
* Checks sources in order:
|
||||
* 1. handshake.auth.token — explicit token (e.g. from API clients)
|
||||
* 2. handshake.headers.cookie — session cookie sent by browser via withCredentials
|
||||
* 3. query.token — URL query parameter fallback
|
||||
* 4. Authorization header — Bearer token fallback
|
||||
*
|
||||
* @param client - The socket client
|
||||
* @returns The token string or undefined if not found
|
||||
*/
|
||||
private extractTokenFromHandshake(client: Socket): string | undefined {
|
||||
// Check handshake.auth.token (preferred method)
|
||||
// Check handshake.auth.token (preferred method for non-browser clients)
|
||||
const authToken = client.handshake.auth.token as unknown;
|
||||
if (typeof authToken === "string" && authToken.length > 0) {
|
||||
return authToken;
|
||||
}
|
||||
|
||||
// Fallback: parse session cookie from request headers.
|
||||
// Browsers send httpOnly cookies automatically when withCredentials: true is set
|
||||
// on the socket.io client. BetterAuth uses one of these cookie names depending
|
||||
// on whether the connection is HTTPS (Secure prefix) or HTTP (dev).
|
||||
const cookieHeader = client.handshake.headers.cookie;
|
||||
if (typeof cookieHeader === "string" && cookieHeader.length > 0) {
|
||||
const cookieToken = this.extractTokenFromCookieHeader(cookieHeader);
|
||||
if (cookieToken) {
|
||||
return cookieToken;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: check query parameters
|
||||
const queryToken = client.handshake.query.token as unknown;
|
||||
if (typeof queryToken === "string" && queryToken.length > 0) {
|
||||
@@ -197,6 +217,45 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Parse the BetterAuth session token from a raw Cookie header string.
|
||||
*
|
||||
* BetterAuth names the session cookie differently based on the security context:
|
||||
* - `__Secure-better-auth.session_token` — HTTPS with Secure flag
|
||||
* - `better-auth.session_token` — HTTP (development)
|
||||
* - `__Host-better-auth.session_token` — HTTPS with Host prefix
|
||||
*
|
||||
* @param cookieHeader - The raw Cookie header value
|
||||
* @returns The session token value or undefined if no matching cookie found
|
||||
*/
|
||||
private extractTokenFromCookieHeader(cookieHeader: string): string | undefined {
|
||||
const SESSION_COOKIE_NAMES = [
|
||||
"__Secure-better-auth.session_token",
|
||||
"better-auth.session_token",
|
||||
"__Host-better-auth.session_token",
|
||||
] as const;
|
||||
|
||||
// Parse the Cookie header into a key-value map
|
||||
const cookies = Object.fromEntries(
|
||||
cookieHeader.split(";").map((pair) => {
|
||||
const eqIndex = pair.indexOf("=");
|
||||
if (eqIndex === -1) {
|
||||
return [pair.trim(), ""];
|
||||
}
|
||||
return [pair.slice(0, eqIndex).trim(), pair.slice(eqIndex + 1).trim()];
|
||||
})
|
||||
);
|
||||
|
||||
for (const name of SESSION_COOKIE_NAMES) {
|
||||
const value = cookies[name];
|
||||
if (typeof value === "string" && value.length > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Handle client disconnect by leaving the workspace room.
|
||||
* @param client - The socket client containing workspaceId in data.
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Body,
|
||||
Param,
|
||||
UseGuards,
|
||||
Request,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
|
||||
import { WidgetsService } from "./widgets.service";
|
||||
import { WidgetDataService } from "./widget-data.service";
|
||||
import { AuthGuard } from "../auth/guards/auth.guard";
|
||||
import { WorkspaceGuard } from "../common/guards/workspace.guard";
|
||||
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
|
||||
import type { AuthenticatedRequest } from "../common/types/user.types";
|
||||
import type { RequestWithWorkspace } from "../common/types/user.types";
|
||||
|
||||
/**
|
||||
* Controller for widget definition and data endpoints
|
||||
* All endpoints require authentication
|
||||
* All endpoints require authentication; data endpoints also require workspace context
|
||||
*/
|
||||
@Controller("widgets")
|
||||
@UseGuards(AuthGuard)
|
||||
@@ -51,12 +43,9 @@ export class WidgetsController {
|
||||
* Get stat card widget data
|
||||
*/
|
||||
@Post("data/stat-card")
|
||||
async getStatCardData(@Request() req: AuthenticatedRequest, @Body() query: StatCardQueryDto) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getStatCardData(workspaceId, query);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
|
||||
return this.widgetDataService.getStatCardData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,12 +53,9 @@ export class WidgetsController {
|
||||
* Get chart widget data
|
||||
*/
|
||||
@Post("data/chart")
|
||||
async getChartData(@Request() req: AuthenticatedRequest, @Body() query: ChartQueryDto) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getChartData(workspaceId, query);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
|
||||
return this.widgetDataService.getChartData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -77,12 +63,9 @@ export class WidgetsController {
|
||||
* Get list widget data
|
||||
*/
|
||||
@Post("data/list")
|
||||
async getListData(@Request() req: AuthenticatedRequest, @Body() query: ListQueryDto) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getListData(workspaceId, query);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
|
||||
return this.widgetDataService.getListData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,15 +73,12 @@ export class WidgetsController {
|
||||
* Get calendar preview widget data
|
||||
*/
|
||||
@Post("data/calendar-preview")
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getCalendarPreviewData(
|
||||
@Request() req: AuthenticatedRequest,
|
||||
@Request() req: RequestWithWorkspace,
|
||||
@Body() query: CalendarPreviewQueryDto
|
||||
) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getCalendarPreviewData(workspaceId, query);
|
||||
return this.widgetDataService.getCalendarPreviewData(req.workspace.id, query);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,12 +86,9 @@ export class WidgetsController {
|
||||
* Get active projects widget data
|
||||
*/
|
||||
@Post("data/active-projects")
|
||||
async getActiveProjectsData(@Request() req: AuthenticatedRequest) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getActiveProjectsData(workspaceId);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
|
||||
return this.widgetDataService.getActiveProjectsData(req.workspace.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,11 +96,8 @@ export class WidgetsController {
|
||||
* Get agent chains widget data (active agent sessions)
|
||||
*/
|
||||
@Post("data/agent-chains")
|
||||
async getAgentChainsData(@Request() req: AuthenticatedRequest) {
|
||||
const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
|
||||
if (!workspaceId) {
|
||||
throw new UnauthorizedException("Workspace ID required");
|
||||
}
|
||||
return this.widgetDataService.getAgentChainsData(workspaceId);
|
||||
@UseGuards(WorkspaceGuard)
|
||||
async getAgentChainsData(@Request() req: RequestWithWorkspace) {
|
||||
return this.widgetDataService.getAgentChainsData(req.workspace.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/orchestrator",
|
||||
"version": "0.0.6",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/web",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "next build",
|
||||
|
||||
@@ -326,7 +326,7 @@ function LoginPageContent(): ReactElement {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-center">
|
||||
<AuthStatusPill label="Mosaic v0.1" tone="neutral" />
|
||||
<AuthStatusPill label="Mosaic v0.0.20" tone="neutral" />
|
||||
</div>
|
||||
</AuthCard>
|
||||
</AuthShell>
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function ProfilePage(): ReactElement {
|
||||
setPrefsError(null);
|
||||
|
||||
try {
|
||||
const data = await apiGet<UserPreferences>("/users/me/preferences");
|
||||
const data = await apiGet<UserPreferences>("/api/users/me/preferences");
|
||||
setPreferences(data);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Could not load preferences";
|
||||
|
||||
@@ -240,7 +240,7 @@ export default function AppearanceSettingsPage(): ReactElement {
|
||||
setLocalTheme(themeId);
|
||||
setSaving(true);
|
||||
try {
|
||||
await apiPatch("/users/me/preferences", { theme: themeId });
|
||||
await apiPatch("/api/users/me/preferences", { theme: themeId });
|
||||
} catch {
|
||||
// Theme is still applied locally even if API save fails
|
||||
} finally {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
const ACTIVITY_ACTIONS = [
|
||||
{ value: "CREDENTIAL_CREATED", label: "Created" },
|
||||
@@ -39,17 +40,17 @@ export default function CredentialAuditPage(): React.ReactElement {
|
||||
const [filters, setFilters] = useState<FilterState>({});
|
||||
const [hasFilters, setHasFilters] = useState(false);
|
||||
|
||||
// TODO: Get workspace ID from context/auth
|
||||
const workspaceId = "default-workspace-id"; // Placeholder
|
||||
const workspaceId = useWorkspaceId();
|
||||
|
||||
useEffect(() => {
|
||||
void loadLogs();
|
||||
}, [page, filters]);
|
||||
if (!workspaceId) return;
|
||||
void loadLogs(workspaceId);
|
||||
}, [workspaceId, page, filters]);
|
||||
|
||||
async function loadLogs(): Promise<void> {
|
||||
async function loadLogs(wsId: string): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchCredentialAuditLog(workspaceId, {
|
||||
const response = await fetchCredentialAuditLog(wsId, {
|
||||
...filters,
|
||||
page,
|
||||
limit,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,23 +1,383 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, type SyntheticEvent } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import type { Domain } from "@mosaic/shared";
|
||||
import { DomainList } from "@/components/domains/DomainList";
|
||||
import { fetchDomains, deleteDomain } from "@/lib/api/domains";
|
||||
import { fetchDomains, createDomain, deleteDomain } from "@/lib/api/domains";
|
||||
import type { CreateDomainDto } from "@/lib/api/domains";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
export default function DomainsPage(): React.ReactElement {
|
||||
/* ---------------------------------------------------------------------------
|
||||
Slug generation helper
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
function generateSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Create Domain Dialog
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
interface CreateDomainDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSubmit: (data: CreateDomainDto) => Promise<void>;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function CreateDomainDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: CreateDomainDialogProps): ReactElement | null {
|
||||
const [name, setName] = useState("");
|
||||
const [slug, setSlug] = useState("");
|
||||
const [slugTouched, setSlugTouched] = useState(false);
|
||||
const [description, setDescription] = useState("");
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
function resetForm(): void {
|
||||
setName("");
|
||||
setSlug("");
|
||||
setSlugTouched(false);
|
||||
setDescription("");
|
||||
setFormError(null);
|
||||
}
|
||||
|
||||
function handleNameChange(value: string): void {
|
||||
setName(value);
|
||||
if (!slugTouched) {
|
||||
setSlug(generateSlug(value));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSlugChange(value: string): void {
|
||||
setSlugTouched(true);
|
||||
setSlug(value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
|
||||
}
|
||||
|
||||
async function handleSubmit(e: SyntheticEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setFormError("Domain name is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedSlug = slug.trim();
|
||||
if (!trimmedSlug) {
|
||||
setFormError("Slug is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9-]+$/.test(trimmedSlug)) {
|
||||
setFormError("Slug must contain only lowercase letters, numbers, and hyphens.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload: CreateDomainDto = { name: trimmedName, slug: trimmedSlug };
|
||||
const trimmedDesc = description.trim();
|
||||
if (trimmedDesc) {
|
||||
payload.description = trimmedDesc;
|
||||
}
|
||||
await onSubmit(payload);
|
||||
resetForm();
|
||||
} catch (err: unknown) {
|
||||
setFormError(err instanceof Error ? err.message : "Failed to create domain.");
|
||||
}
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-domain-title"
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
zIndex: 50,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
style={{
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(0,0,0,0.5)",
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!isSubmitting) {
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div
|
||||
style={{
|
||||
position: "relative",
|
||||
background: "var(--surface, #fff)",
|
||||
borderRadius: "8px",
|
||||
border: "1px solid var(--border, #e5e7eb)",
|
||||
padding: 24,
|
||||
width: "100%",
|
||||
maxWidth: 480,
|
||||
zIndex: 1,
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
id="create-domain-title"
|
||||
style={{
|
||||
fontSize: "1.125rem",
|
||||
fontWeight: 600,
|
||||
color: "var(--text, #111)",
|
||||
margin: "0 0 8px",
|
||||
}}
|
||||
>
|
||||
New Domain
|
||||
</h2>
|
||||
<p style={{ color: "var(--muted, #6b7280)", fontSize: "0.875rem", margin: "0 0 16px" }}>
|
||||
Domains help you organize tasks, projects, and events by life area.
|
||||
</p>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(e);
|
||||
}}
|
||||
>
|
||||
{/* Name */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
htmlFor="domain-name"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-2, #374151)",
|
||||
}}
|
||||
>
|
||||
Name <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="domain-name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
handleNameChange(e.target.value);
|
||||
}}
|
||||
placeholder="e.g. Personal Finance"
|
||||
maxLength={255}
|
||||
autoFocus
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg, #f9fafb)",
|
||||
border: "1px solid var(--border, #d1d5db)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text, #111)",
|
||||
fontSize: "0.9rem",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Slug */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
htmlFor="domain-slug"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-2, #374151)",
|
||||
}}
|
||||
>
|
||||
Slug <span style={{ color: "var(--danger, #ef4444)" }}>*</span>
|
||||
</label>
|
||||
<input
|
||||
id="domain-slug"
|
||||
type="text"
|
||||
value={slug}
|
||||
onChange={(e) => {
|
||||
handleSlugChange(e.target.value);
|
||||
}}
|
||||
placeholder="e.g. personal-finance"
|
||||
maxLength={100}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg, #f9fafb)",
|
||||
border: "1px solid var(--border, #d1d5db)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text, #111)",
|
||||
fontSize: "0.9rem",
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
fontFamily: "var(--mono, monospace)",
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "var(--muted, #6b7280)",
|
||||
margin: "4px 0 0",
|
||||
}}
|
||||
>
|
||||
Lowercase letters, numbers, and hyphens only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<label
|
||||
htmlFor="domain-description"
|
||||
style={{
|
||||
display: "block",
|
||||
marginBottom: 6,
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
color: "var(--text-2, #374151)",
|
||||
}}
|
||||
>
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
id="domain-description"
|
||||
value={description}
|
||||
onChange={(e) => {
|
||||
setDescription(e.target.value);
|
||||
}}
|
||||
placeholder="A brief summary of this domain..."
|
||||
rows={3}
|
||||
maxLength={10000}
|
||||
style={{
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg, #f9fafb)",
|
||||
border: "1px solid var(--border, #d1d5db)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text, #111)",
|
||||
fontSize: "0.9rem",
|
||||
outline: "none",
|
||||
resize: "vertical",
|
||||
fontFamily: "inherit",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form error */}
|
||||
{formError !== null && (
|
||||
<p
|
||||
style={{
|
||||
color: "var(--danger, #ef4444)",
|
||||
fontSize: "0.85rem",
|
||||
margin: "0 0 12px",
|
||||
}}
|
||||
>
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "flex-end",
|
||||
gap: 8,
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "transparent",
|
||||
border: "1px solid var(--border, #d1d5db)",
|
||||
borderRadius: "6px",
|
||||
color: "var(--text-2, #374151)",
|
||||
fontSize: "0.85rem",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting || !name.trim() || !slug.trim()}
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
background: "var(--primary, #111827)",
|
||||
border: "none",
|
||||
borderRadius: "6px",
|
||||
color: "#fff",
|
||||
fontSize: "0.85rem",
|
||||
fontWeight: 500,
|
||||
cursor: isSubmitting || !name.trim() || !slug.trim() ? "not-allowed" : "pointer",
|
||||
opacity: isSubmitting || !name.trim() || !slug.trim() ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{isSubmitting ? "Creating..." : "Create Domain"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Domains Page
|
||||
--------------------------------------------------------------------------- */
|
||||
|
||||
export default function DomainsPage(): ReactElement {
|
||||
const workspaceId = useWorkspaceId();
|
||||
const [domains, setDomains] = useState<Domain[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Create dialog state
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceId) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
void loadDomains();
|
||||
}, []);
|
||||
}, [workspaceId]);
|
||||
|
||||
async function loadDomains(): Promise<void> {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await fetchDomains();
|
||||
const response = await fetchDomains(undefined, workspaceId ?? undefined);
|
||||
setDomains(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
@@ -27,9 +387,8 @@ export default function DomainsPage(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
function handleEdit(domain: Domain): void {
|
||||
function handleEdit(_domain: Domain): void {
|
||||
// TODO: Open edit modal/form
|
||||
console.log("Edit domain:", domain);
|
||||
}
|
||||
|
||||
async function handleDelete(domain: Domain): Promise<void> {
|
||||
@@ -38,13 +397,26 @@ export default function DomainsPage(): React.ReactElement {
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteDomain(domain.id);
|
||||
await deleteDomain(domain.id, workspaceId ?? undefined);
|
||||
await loadDomains();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to delete domain");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(data: CreateDomainDto): Promise<void> {
|
||||
setIsCreating(true);
|
||||
try {
|
||||
await createDomain(data, workspaceId ?? undefined);
|
||||
setCreateOpen(false);
|
||||
await loadDomains();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create domain.");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
<div className="mb-6">
|
||||
@@ -60,7 +432,7 @@ export default function DomainsPage(): React.ReactElement {
|
||||
<button
|
||||
className="px-4 py-2 bg-gray-900 text-white rounded hover:bg-gray-800"
|
||||
onClick={() => {
|
||||
console.log("TODO: Open create modal");
|
||||
setCreateOpen(true);
|
||||
}}
|
||||
>
|
||||
Create Domain
|
||||
@@ -73,6 +445,13 @@ export default function DomainsPage(): React.ReactElement {
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<CreateDomainDialog
|
||||
open={createOpen}
|
||||
onOpenChange={setCreateOpen}
|
||||
onSubmit={handleCreate}
|
||||
isSubmitting={isCreating}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
63
apps/web/src/app/(authenticated)/terminal/page.tsx
Normal file
63
apps/web/src/app/(authenticated)/terminal/page.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Terminal page — dedicated full-screen terminal route at /terminal.
|
||||
*
|
||||
* Renders the TerminalPanel component filling the available content area.
|
||||
* The panel is always open on this page; there is no close action since
|
||||
* the user navigates away using the sidebar instead.
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import type { ReactElement } from "react";
|
||||
import { TerminalPanel } from "@/components/terminal";
|
||||
import { getAccessToken } from "@/lib/auth-client";
|
||||
|
||||
export default function TerminalPage(): ReactElement {
|
||||
const [token, setToken] = useState<string>("");
|
||||
|
||||
// Resolve the access token once on mount. The WebSocket connection inside
|
||||
// TerminalPanel uses this token for authentication.
|
||||
useEffect((): void => {
|
||||
getAccessToken()
|
||||
.then((t) => {
|
||||
setToken(t ?? "");
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error("[TerminalPage] Failed to retrieve access token:", err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Override TerminalPanel inline height so it fills the page */}
|
||||
<style>{`
|
||||
.terminal-page-panel {
|
||||
height: 100% !important;
|
||||
border-top: none !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
aria-label="Terminal"
|
||||
>
|
||||
<TerminalPanel
|
||||
open={true}
|
||||
onClose={(): void => {
|
||||
/* No-op: on the dedicated terminal page the panel is always open.
|
||||
Users navigate away using the sidebar rather than closing the panel. */
|
||||
}}
|
||||
token={token}
|
||||
className="terminal-page-panel"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
BIN
apps/web/src/app/favicon.ico
Normal file
BIN
apps/web/src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 539 B |
@@ -11,6 +11,9 @@ export const dynamic = "force-dynamic";
|
||||
export const metadata: Metadata = {
|
||||
title: "Mosaic Stack",
|
||||
description: "Mosaic Stack Web Application",
|
||||
icons: {
|
||||
icon: "/favicon.ico",
|
||||
},
|
||||
};
|
||||
|
||||
const outfit = Outfit({
|
||||
|
||||
@@ -89,7 +89,11 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
|
||||
...(initialProjectId !== undefined && { projectId: initialProjectId }),
|
||||
});
|
||||
|
||||
const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
|
||||
// Use the actual workspace ID for the WebSocket room subscription.
|
||||
// Cookie-based auth (withCredentials) handles authentication, so no explicit
|
||||
// token is needed here — pass an empty string as the token placeholder.
|
||||
const workspaceId = user?.currentWorkspaceId ?? user?.workspaceId ?? "";
|
||||
const { isConnected: isWsConnected } = useWebSocket(workspaceId, "", {});
|
||||
|
||||
const { isCommand, executeCommand } = useOrchestratorCommands();
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
badge: { label: "live", pulse: true },
|
||||
},
|
||||
{
|
||||
href: "#terminal",
|
||||
href: "/terminal",
|
||||
label: "Terminal",
|
||||
icon: <IconTerminal />,
|
||||
},
|
||||
|
||||
@@ -91,7 +91,7 @@ export function SelectTrigger({
|
||||
onClick={() => {
|
||||
setIsOpen(!isOpen);
|
||||
}}
|
||||
className={`flex h-10 w-full items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ${className}`}
|
||||
className={`flex h-10 w-full items-center justify-between rounded-md border border-border bg-bg px-3 py-2 text-sm text-text ${className}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
@@ -110,7 +110,7 @@ export function SelectContent({ children }: SelectContentProps): React.JSX.Eleme
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-gray-300 bg-white shadow-lg">
|
||||
<div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border border-border bg-surface shadow-md">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -122,7 +122,7 @@ export function SelectItem({ value, children }: SelectItemProps): React.JSX.Elem
|
||||
return (
|
||||
<div
|
||||
onClick={() => onValueChange?.(value)}
|
||||
className="cursor-pointer px-3 py-2 text-sm hover:bg-gray-100"
|
||||
className="cursor-pointer px-3 py-2 text-sm text-text hover:bg-surface-2"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useState, useEffect } from "react";
|
||||
import { FolderOpen, Bot, Activity, Clock, AlertCircle, CheckCircle2 } from "lucide-react";
|
||||
import type { WidgetProps } from "@mosaic/shared";
|
||||
import { apiPost } from "@/lib/api/client";
|
||||
import { useWorkspaceId } from "@/lib/hooks";
|
||||
|
||||
interface ActiveProject {
|
||||
id: string;
|
||||
@@ -34,6 +35,7 @@ interface AgentSession {
|
||||
}
|
||||
|
||||
export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
|
||||
const workspaceId = useWorkspaceId();
|
||||
const [projects, setProjects] = useState<ActiveProject[]>([]);
|
||||
const [agentSessions, setAgentSessions] = useState<AgentSession[]>([]);
|
||||
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
|
||||
@@ -48,7 +50,11 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
try {
|
||||
setProjectsError(null);
|
||||
// Use API client to ensure CSRF token is included
|
||||
const data = await apiPost<ActiveProject[]>("/api/widgets/data/active-projects");
|
||||
const data = await apiPost<ActiveProject[]>(
|
||||
"/api/widgets/data/active-projects",
|
||||
undefined,
|
||||
workspaceId ?? undefined
|
||||
);
|
||||
setProjects(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch active projects:", error);
|
||||
@@ -67,7 +73,7 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}, [workspaceId]);
|
||||
|
||||
// Fetch agent chains
|
||||
useEffect(() => {
|
||||
@@ -75,7 +81,11 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
try {
|
||||
setAgentsError(null);
|
||||
// Use API client to ensure CSRF token is included
|
||||
const data = await apiPost<AgentSession[]>("/api/widgets/data/agent-chains");
|
||||
const data = await apiPost<AgentSession[]>(
|
||||
"/api/widgets/data/agent-chains",
|
||||
undefined,
|
||||
workspaceId ?? undefined
|
||||
);
|
||||
setAgentSessions(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch agent sessions:", error);
|
||||
@@ -94,7 +104,7 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
|
||||
return (): void => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
}, [workspaceId]);
|
||||
|
||||
const getStatusIcon = (status: string): React.JSX.Element => {
|
||||
const statusUpper = status.toUpperCase();
|
||||
|
||||
@@ -47,6 +47,7 @@ describe("useWebSocket", (): void => {
|
||||
expect(io).toHaveBeenCalledWith(expect.any(String), {
|
||||
auth: { token },
|
||||
query: { workspaceId },
|
||||
withCredentials: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -97,9 +97,12 @@ export function useWebSocket(
|
||||
setConnectionError(null);
|
||||
|
||||
// Create socket connection
|
||||
// withCredentials sends session cookies cross-origin so the gateway can
|
||||
// authenticate via cookie when no explicit token is provided.
|
||||
const newSocket = io(wsUrl, {
|
||||
auth: { token },
|
||||
query: { workspaceId },
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
setSocket(newSocket);
|
||||
|
||||
@@ -202,9 +202,13 @@ export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions
|
||||
...baseHeaders,
|
||||
};
|
||||
|
||||
// Add workspace ID header if provided (recommended over query string)
|
||||
if (workspaceId) {
|
||||
headers["X-Workspace-Id"] = workspaceId;
|
||||
// Add workspace ID header — use explicit value, or auto-detect from localStorage
|
||||
const resolvedWorkspaceId =
|
||||
workspaceId ??
|
||||
(typeof window !== "undefined" ? localStorage.getItem("mosaic-workspace-id") : null) ??
|
||||
undefined;
|
||||
if (resolvedWorkspaceId) {
|
||||
headers["X-Workspace-Id"] = resolvedWorkspaceId;
|
||||
}
|
||||
|
||||
// Add CSRF token for state-changing requests (POST, PUT, PATCH, DELETE)
|
||||
@@ -246,6 +250,11 @@ export async function apiRequest<T>(endpoint: string, options: ApiRequestOptions
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
// 204 No Content responses have no body — return undefined cast to T
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
return await (response.json() as Promise<T>);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof DOMException && err.name === "AbortError") {
|
||||
|
||||
@@ -44,7 +44,10 @@ export interface DomainFilters {
|
||||
/**
|
||||
* Fetch all domains
|
||||
*/
|
||||
export async function fetchDomains(filters?: DomainFilters): Promise<ApiResponse<Domain[]>> {
|
||||
export async function fetchDomains(
|
||||
filters?: DomainFilters,
|
||||
workspaceId?: string
|
||||
): Promise<ApiResponse<Domain[]>> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (filters?.search) {
|
||||
@@ -60,7 +63,7 @@ export async function fetchDomains(filters?: DomainFilters): Promise<ApiResponse
|
||||
const queryString = params.toString();
|
||||
const endpoint = queryString ? `/api/domains?${queryString}` : "/api/domains";
|
||||
|
||||
return apiGet<ApiResponse<Domain[]>>(endpoint);
|
||||
return apiGet<ApiResponse<Domain[]>>(endpoint, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,20 +76,27 @@ export async function fetchDomain(id: string): Promise<DomainWithCounts> {
|
||||
/**
|
||||
* Create a new domain
|
||||
*/
|
||||
export async function createDomain(data: CreateDomainDto): Promise<Domain> {
|
||||
return apiPost<Domain>("/api/domains", data);
|
||||
export async function createDomain(data: CreateDomainDto, workspaceId?: string): Promise<Domain> {
|
||||
return apiPost<Domain>("/api/domains", data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a domain
|
||||
*/
|
||||
export async function updateDomain(id: string, data: UpdateDomainDto): Promise<Domain> {
|
||||
return apiPatch<Domain>(`/api/domains/${id}`, data);
|
||||
export async function updateDomain(
|
||||
id: string,
|
||||
data: UpdateDomainDto,
|
||||
workspaceId?: string
|
||||
): Promise<Domain> {
|
||||
return apiPatch<Domain>(`/api/domains/${id}`, data, workspaceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a domain
|
||||
*/
|
||||
export async function deleteDomain(id: string): Promise<Record<string, never>> {
|
||||
return apiDelete<Record<string, never>>(`/api/domains/${id}`);
|
||||
export async function deleteDomain(
|
||||
id: string,
|
||||
workspaceId?: string
|
||||
): Promise<Record<string, never>> {
|
||||
return apiDelete<Record<string, never>>(`/api/domains/${id}`, workspaceId);
|
||||
}
|
||||
|
||||
@@ -73,7 +73,8 @@ export async function updatePersonality(
|
||||
|
||||
/**
|
||||
* Delete a personality
|
||||
* The DELETE endpoint returns 204 No Content on success.
|
||||
*/
|
||||
export async function deletePersonality(id: string): Promise<Record<string, never>> {
|
||||
return apiDelete<Record<string, never>>(`/api/personalities/${id}`);
|
||||
export async function deletePersonality(id: string): Promise<void> {
|
||||
await apiDelete<undefined>(`/api/personalities/${id}`);
|
||||
}
|
||||
|
||||
@@ -65,7 +65,8 @@ export interface UpdateProjectDto {
|
||||
* Fetch all projects for a workspace
|
||||
*/
|
||||
export async function fetchProjects(workspaceId?: string): Promise<Project[]> {
|
||||
return apiGet<Project[]>("/api/projects", workspaceId);
|
||||
const response = await apiGet<{ data: Project[]; meta?: unknown }>("/api/projects", workspaceId);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -691,4 +691,175 @@ describe("AuthContext", (): void => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("workspace ID persistence", (): void => {
|
||||
// ---------------------------------------------------------------------------
|
||||
// localStorage mock for workspace persistence tests
|
||||
// ---------------------------------------------------------------------------
|
||||
interface MockLocalStorage {
|
||||
getItem: ReturnType<typeof vi.fn>;
|
||||
setItem: ReturnType<typeof vi.fn>;
|
||||
removeItem: ReturnType<typeof vi.fn>;
|
||||
clear: ReturnType<typeof vi.fn>;
|
||||
readonly length: number;
|
||||
key: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
let localStorageMock: MockLocalStorage;
|
||||
|
||||
beforeEach((): void => {
|
||||
let store: Record<string, string> = {};
|
||||
localStorageMock = {
|
||||
getItem: vi.fn((key: string): string | null => store[key] ?? null),
|
||||
setItem: vi.fn((key: string, value: string): void => {
|
||||
store[key] = value;
|
||||
}),
|
||||
removeItem: vi.fn((key: string): void => {
|
||||
store = Object.fromEntries(Object.entries(store).filter(([k]) => k !== key));
|
||||
}),
|
||||
clear: vi.fn((): void => {
|
||||
store = {};
|
||||
}),
|
||||
get length(): number {
|
||||
return Object.keys(store).length;
|
||||
},
|
||||
key: vi.fn((_index: number): string | null => null),
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: localStorageMock,
|
||||
writable: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
afterEach((): void => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should persist currentWorkspaceId to localStorage after session check", async (): Promise<void> => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
currentWorkspaceId: "ws-current-123",
|
||||
workspaceId: "ws-default-456",
|
||||
};
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
});
|
||||
|
||||
// currentWorkspaceId takes priority over workspaceId
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"mosaic-workspace-id",
|
||||
"ws-current-123"
|
||||
);
|
||||
});
|
||||
|
||||
it("should fall back to workspaceId when currentWorkspaceId is absent", async (): Promise<void> => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
workspaceId: "ws-default-456",
|
||||
};
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
"mosaic-workspace-id",
|
||||
"ws-default-456"
|
||||
);
|
||||
});
|
||||
|
||||
it("should not write to localStorage when no workspace ID is present on user", async (): Promise<void> => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
// no workspaceId or currentWorkspaceId
|
||||
};
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
});
|
||||
|
||||
expect(localStorageMock.setItem).not.toHaveBeenCalledWith(
|
||||
"mosaic-workspace-id",
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove workspace ID from localStorage on sign-out", async (): Promise<void> => {
|
||||
const mockUser: AuthUser = {
|
||||
id: "user-1",
|
||||
email: "test@example.com",
|
||||
name: "Test User",
|
||||
currentWorkspaceId: "ws-current-123",
|
||||
};
|
||||
|
||||
vi.mocked(apiGet).mockResolvedValueOnce({
|
||||
user: mockUser,
|
||||
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
|
||||
});
|
||||
vi.mocked(apiPost).mockResolvedValueOnce({ success: true });
|
||||
|
||||
render(
|
||||
<AuthProvider>
|
||||
<TestComponent />
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
|
||||
});
|
||||
|
||||
const signOutButton = screen.getByRole("button", { name: "Sign Out" });
|
||||
signOutButton.click();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated");
|
||||
});
|
||||
|
||||
expect(localStorageMock.removeItem).toHaveBeenCalledWith("mosaic-workspace-id");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -24,6 +24,43 @@ const SESSION_EXPIRY_WARNING_MINUTES = 5;
|
||||
|
||||
/** Interval in milliseconds to check session expiry */
|
||||
const SESSION_CHECK_INTERVAL_MS = 60_000;
|
||||
|
||||
/**
|
||||
* localStorage key for the active workspace ID.
|
||||
* Must match the WORKSPACE_KEY constant in useLayout.ts and the key read
|
||||
* by apiRequest in client.ts.
|
||||
*/
|
||||
const WORKSPACE_STORAGE_KEY = "mosaic-workspace-id";
|
||||
|
||||
/**
|
||||
* Persist the workspace ID to localStorage so it is available to
|
||||
* useWorkspaceId and apiRequest on the next render / request cycle.
|
||||
* Silently ignores localStorage errors (private browsing, storage full).
|
||||
*/
|
||||
function persistWorkspaceId(workspaceId: string | undefined): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
if (workspaceId) {
|
||||
localStorage.setItem(WORKSPACE_STORAGE_KEY, workspaceId);
|
||||
}
|
||||
} catch {
|
||||
// localStorage unavailable — not fatal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the workspace ID from localStorage on sign-out so stale workspace
|
||||
* context is not sent on subsequent unauthenticated requests.
|
||||
*/
|
||||
function clearWorkspaceId(): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.removeItem(WORKSPACE_STORAGE_KEY);
|
||||
} catch {
|
||||
// localStorage unavailable — not fatal
|
||||
}
|
||||
}
|
||||
|
||||
const MOCK_AUTH_USER: AuthUser = {
|
||||
id: "dev-user-local",
|
||||
email: "dev@localhost",
|
||||
@@ -97,6 +134,11 @@ function RealAuthProvider({ children }: { children: ReactNode }): React.JSX.Elem
|
||||
setUser(session.user);
|
||||
setAuthError(null);
|
||||
|
||||
// Persist workspace ID to localStorage so useWorkspaceId and apiRequest
|
||||
// can pick it up without re-fetching the session.
|
||||
// Prefer currentWorkspaceId (the user's active workspace) over workspaceId.
|
||||
persistWorkspaceId(session.user.currentWorkspaceId ?? session.user.workspaceId);
|
||||
|
||||
// Track session expiry timestamp
|
||||
expiresAtRef.current = new Date(session.session.expiresAt);
|
||||
|
||||
@@ -128,6 +170,9 @@ function RealAuthProvider({ children }: { children: ReactNode }): React.JSX.Elem
|
||||
setUser(null);
|
||||
expiresAtRef.current = null;
|
||||
setSessionExpiring(false);
|
||||
// Clear persisted workspace ID so stale context is not sent on
|
||||
// subsequent unauthenticated API requests.
|
||||
clearWorkspaceId();
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -158,6 +158,8 @@ services:
|
||||
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
|
||||
- NEXT_PUBLIC_ORCHESTRATOR_URL=${NEXT_PUBLIC_ORCHESTRATOR_URL:-}
|
||||
- NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE:-real}
|
||||
# Server-side orchestrator proxy (API routes forward to orchestrator service over internal network)
|
||||
- ORCHESTRATOR_URL=http://orchestrator:3001
|
||||
- ORCHESTRATOR_API_KEY=${ORCHESTRATOR_API_KEY:-}
|
||||
depends_on:
|
||||
api:
|
||||
@@ -222,6 +224,8 @@ services:
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- ORCHESTRATOR_PORT=3001
|
||||
# Bind to all interfaces so the web container can reach it over Docker networking
|
||||
- HOST=0.0.0.0
|
||||
- AI_PROVIDER=${AI_PROVIDER:-ollama}
|
||||
- OLLAMA_ENDPOINT=${OLLAMA_ENDPOINT:-}
|
||||
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.2}
|
||||
|
||||
@@ -176,6 +176,9 @@ services:
|
||||
NODE_ENV: production
|
||||
PORT: ${WEB_PORT:-3000}
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
|
||||
# Server-side orchestrator proxy (API routes forward to orchestrator service)
|
||||
ORCHESTRATOR_URL: http://orchestrator:3001
|
||||
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -187,6 +190,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- internal
|
||||
- traefik-public
|
||||
deploy:
|
||||
restart_policy:
|
||||
@@ -248,6 +252,8 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
ORCHESTRATOR_PORT: 3001
|
||||
# Bind to all interfaces so the web container can reach it over Docker networking
|
||||
HOST: 0.0.0.0
|
||||
AI_PROVIDER: ${AI_PROVIDER:-ollama}
|
||||
VALKEY_URL: redis://valkey:6379
|
||||
VALKEY_HOST: valkey
|
||||
@@ -259,6 +265,8 @@ services:
|
||||
GIT_USER_EMAIL: "orchestrator@mosaicstack.dev"
|
||||
KILLSWITCH_ENABLED: "true"
|
||||
SANDBOX_ENABLED: "true"
|
||||
# API key for authenticating requests from the web proxy
|
||||
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||
- orchestrator_workspace:/workspace
|
||||
|
||||
@@ -433,6 +433,8 @@ services:
|
||||
NODE_ENV: production
|
||||
# Orchestrator Configuration
|
||||
ORCHESTRATOR_PORT: 3001
|
||||
# Bind to all interfaces so the web container can reach it over Docker networking
|
||||
HOST: 0.0.0.0
|
||||
AI_PROVIDER: ${AI_PROVIDER:-ollama}
|
||||
# Valkey
|
||||
VALKEY_URL: redis://valkey:6379
|
||||
@@ -448,6 +450,8 @@ services:
|
||||
# Security
|
||||
KILLSWITCH_ENABLED: true
|
||||
SANDBOX_ENABLED: true
|
||||
# API key for authenticating requests from the web proxy
|
||||
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
|
||||
ports:
|
||||
- "3002:3001"
|
||||
volumes:
|
||||
@@ -498,6 +502,8 @@ services:
|
||||
NODE_ENV: production
|
||||
PORT: ${WEB_PORT:-3000}
|
||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3001}
|
||||
# Server-side orchestrator proxy (API routes forward to orchestrator service)
|
||||
ORCHESTRATOR_URL: http://orchestrator:3001
|
||||
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
|
||||
ports:
|
||||
- "${WEB_PORT:-3000}:${WEB_PORT:-3000}"
|
||||
@@ -515,6 +521,7 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
networks:
|
||||
- mosaic-internal
|
||||
- mosaic-public
|
||||
labels:
|
||||
- "com.mosaic.service=web"
|
||||
|
||||
@@ -1,51 +1,53 @@
|
||||
# Mission Manifest — MS19 Chat & Terminal System
|
||||
# Mission Manifest — MS20 Site Stabilization
|
||||
|
||||
> Persistent document tracking full mission scope, status, and session history.
|
||||
> Updated by the orchestrator at each phase transition and milestone completion.
|
||||
|
||||
## Mission
|
||||
|
||||
**ID:** ms19-chat-terminal-20260225
|
||||
**Statement:** Implement MS19 (Chat & Terminal System) — real terminal with PTY backend, chat streaming, master chat polish, project-level orchestrator chat, and agent output integration
|
||||
**Phase:** Completion
|
||||
**Current Milestone:** MS19-ChatTerminal
|
||||
**ID:** ms20-site-stabilization-20260227
|
||||
**Statement:** Fix runtime bugs, missing API endpoints, orchestrator connectivity, and feature gaps discovered during live site testing at mosaic.woltje.com
|
||||
**Phase:** Complete
|
||||
**Current Milestone:** MS20-SiteStabilization
|
||||
**Progress:** 1 / 1 milestones
|
||||
**Status:** completed
|
||||
**Last Updated:** 2026-02-26T04:20Z
|
||||
**Last Updated:** 2026-02-27T12:15Z
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. Terminal panel has real xterm.js with PTY backend via WebSocket — **DONE** (PR #518)
|
||||
2. Terminal supports multiple named sessions (create/close/rename tabs) — **DONE** (PR #520)
|
||||
3. Terminal sessions persist in PostgreSQL and recover on reconnect — **DONE** (PR #517)
|
||||
4. Chat streaming renders tokens in real-time via SSE — **DONE** (PR #516)
|
||||
5. Master chat sidebar accessible from any page (Cmd+Shift+J / Cmd+K) — **DONE** (PR #519)
|
||||
6. Master chat supports model selection, temperature, conversation management — **DONE** (PR #519)
|
||||
7. Project-level chat can trigger orchestrator actions (/spawn, /status, /jobs) — **DONE** (PR #521)
|
||||
8. Agent output from orchestrator viewable in terminal tabs — **DONE** (PR #522)
|
||||
9. All features support all 5 themes (Dark, Light, Nord, Dracula, Solarized) — **DONE** (CSS variables)
|
||||
10. Lint, typecheck, and tests pass — **DONE** (1441 web + 3303 API = 4744 tests)
|
||||
11. Deployed and smoke-tested at mosaic.woltje.com — **DONE** (CI #635 green, web image sha:7165e7a deployed)
|
||||
1. Domains page: can create and list domains without workspace errors — **PASS** (PR #536)
|
||||
2. Projects page: can create new projects without workspace errors — **PASS** (already working)
|
||||
3. Personalities page: full CRUD works with proper dark mode theming — **PASS** (PR #537, #540)
|
||||
4. User preferences endpoint (`/users/me/preferences`) returns data — **PASS** (PR #539)
|
||||
5. Credentials page: can add, view credentials (not just disabled stub) — **PASS** (PR #545)
|
||||
6. Orchestrator proxy endpoints return real data (no 502) — **PASS** (PR #542; 502s remain because orchestrator service not active in prod, but proxy route works)
|
||||
7. Orchestrator WebSocket connects successfully — **PASS** (PR #547, #548, #549)
|
||||
8. Dashboard Agent Status, Task Progress, Orchestrator Events widgets work — **PARTIAL** (widgets render, but orchestrator service not active in prod so data endpoints return 502)
|
||||
9. Terminal has dedicated `/terminal` page route — **PASS** (PR #538)
|
||||
10. favicon.ico serves correctly (no 404) — **PASS** (PR #541, #544)
|
||||
11. `useWorkspaceId` warning resolved — workspace ID persists in localStorage — **PASS** (already in main via auth-context.tsx)
|
||||
12. All 5 themes render correctly on all affected pages — **PASS** (verified dark mode on personalities, credentials, domains, dashboard)
|
||||
13. Lint, typecheck, and tests pass — **PASS** (pipeline 680 green — 1445 web tests, 3316 API tests)
|
||||
14. Deployed and verified at mosaic.woltje.com — **PASS** (Portainer stack 121 redeployed, all pages verified)
|
||||
|
||||
## Existing Infrastructure
|
||||
|
||||
Key components already built that MS19 builds upon:
|
||||
Key components already built that MS20 builds upon:
|
||||
|
||||
| Component | Status | Location |
|
||||
| --------------------------------- | ------------------- | ------------------------------------ |
|
||||
| ChatOverlay + ConversationSidebar | ~95% complete | `apps/web/src/components/chat/` |
|
||||
| LLM Controller with SSE | Working | `apps/api/src/llm/` |
|
||||
| WebSocket Gateway | Production | `apps/api/src/websocket/` |
|
||||
| TerminalPanel UI (mock) | UI-only, no backend | `apps/web/src/components/terminal/` |
|
||||
| Orchestrator proxy routes | Working | `apps/web/src/app/api/orchestrator/` |
|
||||
| Speech Gateway (pattern ref) | Production | `apps/api/src/speech/` |
|
||||
| Ideas API (chat persistence) | Working | `apps/api/src/ideas/` |
|
||||
| Component | Status | Location |
|
||||
| ------------------------- | --------------- | ----------------------------------------------- |
|
||||
| WorkspaceGuard | Working | `apps/api/src/common/guards/workspace.guard.ts` |
|
||||
| Auto-detect workspace ID | Working (reads) | `apps/web/src/lib/api/client.ts` |
|
||||
| Credentials API backend | Built (M7) | `apps/api/src/credentials/` |
|
||||
| Orchestrator proxy routes | Fixed (MS20) | `apps/web/src/app/api/orchestrator/` |
|
||||
| Terminal components | Built (MS19) | `apps/web/src/components/terminal/` |
|
||||
| Theme system | Working (MS18) | `apps/web/src/lib/themes/` |
|
||||
|
||||
## Milestones
|
||||
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ---- | ---------------------- | --------- | ------------------------- | ------------------------ | ---------- | ---------- |
|
||||
| 1 | MS19 | Chat & Terminal System | completed | per-task feature branches | #508,#509,#510,#511,#512 | 2026-02-25 | 2026-02-25 |
|
||||
| # | ID | Name | Status | Branch | Issue | Started | Completed |
|
||||
| --- | ---- | ------------------ | --------- | ------------------------- | ----- | ---------- | ---------- |
|
||||
| 1 | MS20 | Site Stabilization | completed | per-task feature branches | #534 | 2026-02-27 | 2026-02-27 |
|
||||
|
||||
## Deployment
|
||||
|
||||
@@ -55,34 +57,25 @@ Key components already built that MS19 builds upon:
|
||||
|
||||
## Token Budget
|
||||
|
||||
| Metric | Value |
|
||||
| ------ | ----------------- |
|
||||
| Budget | ~300K (estimated) |
|
||||
| Used | ~220K |
|
||||
| Mode | normal |
|
||||
| Metric | Value |
|
||||
| ------ | -------------------- |
|
||||
| Budget | ~400K (estimated) |
|
||||
| Used | ~263K (across S1-S4) |
|
||||
| Mode | normal |
|
||||
|
||||
## Session History
|
||||
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | --------------- | ----------------- | -------- | ------------ | ------------------------------------------------- |
|
||||
| S1 | Claude Opus 4.6 | 2026-02-25T20:00Z | ~1h | context | Planning (PLAN-001) |
|
||||
| S2 | Claude Opus 4.6 | 2026-02-25T21:00Z | ~2h | context | Wave 1+2 (5 tasks, PRs #515-518) |
|
||||
| S3 | Claude Opus 4.6 | 2026-02-25T23:00Z | ~1.5h | context | Wave 3+4 (TERM-004, CHAT-002, ORCH-001, ORCH-002) |
|
||||
| S4 | Claude Opus 4.6 | 2026-02-26T04:00Z | ~30m | completed | VER-001, DOC-001, VER-002 — mission complete |
|
||||
| Session | Runtime | Started | Duration | Ended Reason | Last Task |
|
||||
| ------- | --------------- | ----------------- | -------- | ------------------ | -------------------- |
|
||||
| S1 | Claude Opus 4.6 | 2026-02-27T05:30Z | ~30m | Planning done | PLAN-001 |
|
||||
| S2 | Claude Opus 4.6 | 2026-02-27T06:00Z | ~2h | Context exhaustion | 5 workers dispatched |
|
||||
| S3 | Claude Opus 4.6 | 2026-02-27T08:00Z | ~1.5h | Context exhaustion | Recovery + 2 workers |
|
||||
| S4 | Claude Opus 4.6 | 2026-02-27T10:30Z | ~2h | Mission complete | VER-001 + DOC-001 |
|
||||
|
||||
## PRs Merged
|
||||
|
||||
| PR | Commit | Task | Description |
|
||||
| ---- | ------- | -------- | ---------------------------------------- |
|
||||
| #515 | 6290fc3 | TERM-001 | Terminal WebSocket gateway & PTY service |
|
||||
| #516 | 7de0e73 | CHAT-001 | SSE chat streaming |
|
||||
| #517 | 8128eb7 | TERM-002 | Terminal session persistence |
|
||||
| #518 | 417c6ab | TERM-003 | xterm.js integration |
|
||||
| #519 | 13aa52a | CHAT-002 | Master chat polish |
|
||||
| #520 | 859dcfc | TERM-004 | Terminal tab management |
|
||||
| #521 | b110c46 | ORCH-001 | Orchestrator command system |
|
||||
| #522 | 9b2520c | ORCH-002 | Agent output terminal tabs |
|
||||
13 code PRs + 1 docs PR = 14 total: #536, #537, #538, #539, #540, #541, #542, #543, #544, #545, #547, #548, #549
|
||||
|
||||
## Scratchpad
|
||||
|
||||
Path: `docs/scratchpads/ms19-chat-terminal-20260225.md`
|
||||
Path: `docs/scratchpads/ms20-site-stabilization-20260227.md`
|
||||
|
||||
115
docs/PRD.md
115
docs/PRD.md
@@ -40,7 +40,7 @@ Design system + app shell + dashboard page. PRs #451-454.
|
||||
- Dashboard page: metrics strip, orchestrator sessions, quick actions, activity feed, token budget
|
||||
- Grain overlay texture
|
||||
|
||||
### Go-Live MVP (v0.1.0) — Complete
|
||||
### Go-Live MVP (v0.0.16) — Complete
|
||||
|
||||
Dashboard polish, task ingestion pipeline, agent cycle visibility, deploy + smoke test. PRs #458, #460, #462, #464.
|
||||
|
||||
@@ -51,9 +51,9 @@ Dashboard polish, task ingestion pipeline, agent cycle visibility, deploy + smok
|
||||
- WebSocket emits for job status/progress/step events
|
||||
- Dashboard auto-refresh with polling + progress bars + step status indicators
|
||||
- Deployed to mosaic.woltje.com, auth working via Authentik
|
||||
- Release tag v0.1.0
|
||||
- Release tag v0.0.16
|
||||
|
||||
### MS16+MS17-PagesDataIntegration (v0.1.1) — Complete
|
||||
### MS16+MS17-PagesDataIntegration (v0.0.17) — Complete
|
||||
|
||||
All pages built + wired to real API data. PRs #470-484 (15 PRs). Issues #466-469.
|
||||
|
||||
@@ -69,7 +69,7 @@ All pages built + wired to real API data. PRs #470-484 (15 PRs). Issues #466-469
|
||||
- All 5125 tests passing, CI pipeline #585 green
|
||||
- Deployed and smoke-tested at mosaic.woltje.com
|
||||
|
||||
### MS18-ThemeWidgets (v0.1.2) — Complete
|
||||
### MS18-ThemeWidgets (v0.0.18) — Complete
|
||||
|
||||
Theme package system, widget registry, WYSIWYG editor, Kanban filtering. PRs #493-505. Issues #487-491.
|
||||
|
||||
@@ -86,7 +86,7 @@ Theme package system, widget registry, WYSIWYG editor, Kanban filtering. PRs #49
|
||||
- Kanban board filtering by project, assignee, priority, search with URL persistence
|
||||
- 1,195 web tests, 3,243 API tests passing
|
||||
|
||||
### MS19-ChatTerminal (v0.1.3) — In Progress
|
||||
### MS19-ChatTerminal (v0.0.19) — Complete
|
||||
|
||||
Real terminal with PTY backend, chat streaming, orchestrator integration. PRs #515-522. Issues #508-512.
|
||||
|
||||
@@ -100,7 +100,7 @@ Real terminal with PTY backend, chat streaming, orchestrator integration. PRs #5
|
||||
- Agent output terminal: SSE streaming from orchestrator, lifecycle indicators, read-only view
|
||||
- Command autocomplete with keyboard navigation in chat input
|
||||
- 328 MS19-specific tests (268 web + 60 API), 4744 total passing
|
||||
- Pending: deployment and smoke testing
|
||||
- Deployed and smoke-tested at mosaic.woltje.com (CI #635 green)
|
||||
|
||||
### Bugfix: API Global Prefix (post-MS18) — Complete
|
||||
|
||||
@@ -134,21 +134,28 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
|
||||
13. Global terminal: project/orchestrator level, smart (MS19)
|
||||
14. Project-level orchestrator chat (MS19)
|
||||
15. Master chat session: collapsible sidebar/slideout, always available (MS19)
|
||||
16. Settings page for ALL environment variables, dynamically configurable via webUI (MS20)
|
||||
17. Multi-tenant configuration with admin user management (MS20)
|
||||
18. Team management with shared data spaces and chat rooms (MS20)
|
||||
19. RBAC for file access, resources, models (MS20)
|
||||
20. Federation: master-master and master-slave with key exchange (MS21)
|
||||
21. Federation testing: 3 instances on Portainer (woltje.com domain) (MS21)
|
||||
22. Agent task mapping configuration: system-level defaults, user-level overrides (MS22)
|
||||
23. Telemetry: opt-out, customizable endpoint, sanitized data (MS22)
|
||||
24. File manager with WYSIWYG editing: system/user/project levels (MS18)
|
||||
25. User-level and project-level Kanban with filtering (MS18)
|
||||
26. Break-glass authentication user (MS20)
|
||||
27. Playwright E2E tests for all pages (MS23)
|
||||
28. API documentation via Swagger (MS23)
|
||||
29. Backend endpoints for all dashboard data (MS17 — already complete for existing modules)
|
||||
30. Profile page linked from user card (MS16)
|
||||
16. Site stabilization: workspace context propagation for mutations (MS20)
|
||||
17. Site stabilization: personalities API + UI (MS20)
|
||||
18. Site stabilization: user preferences API endpoint (MS20)
|
||||
19. Site stabilization: orchestrator 502 and WebSocket connectivity (MS20)
|
||||
20. Site stabilization: credential management UI (MS20)
|
||||
21. Site stabilization: terminal page route (MS20)
|
||||
22. Site stabilization: favicon, dark mode dropdown fix (MS20)
|
||||
23. Settings page for ALL environment variables, dynamically configurable via webUI (MS21)
|
||||
24. Multi-tenant configuration with admin user management (MS21)
|
||||
25. Team management with shared data spaces and chat rooms (MS21)
|
||||
26. RBAC for file access, resources, models (MS21)
|
||||
27. Federation: master-master and master-slave with key exchange (MS22)
|
||||
28. Federation testing: 3 instances on Portainer (woltje.com domain) (MS22)
|
||||
29. Agent task mapping configuration: system-level defaults, user-level overrides (MS23)
|
||||
30. Telemetry: opt-out, customizable endpoint, sanitized data (MS23)
|
||||
31. File manager with WYSIWYG editing: system/user/project levels (MS18)
|
||||
32. User-level and project-level Kanban with filtering (MS18)
|
||||
33. Break-glass authentication user (MS20)
|
||||
34. Playwright E2E tests for all pages (MS23)
|
||||
35. API documentation via Swagger (MS23)
|
||||
36. Backend endpoints for all dashboard data (MS17 — already complete for existing modules)
|
||||
37. Profile page linked from user card (MS16)
|
||||
|
||||
### Out of Scope
|
||||
|
||||
@@ -334,7 +341,46 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
|
||||
- ASSUMPTION: Orchestrator commands route through existing web proxy (/api/orchestrator/\*) to orchestrator service. Rationale: Proxy routes already exist and handle auth.
|
||||
- **Status: COMPLETE (MS19) — PRs #521 (commands), #522 (agent terminal). /status, /agents, /jobs, /pause, /resume, /help commands. Agent output streaming via SSE. 113 web tests.**
|
||||
|
||||
### FR-020: Settings Configuration (Future — MS20)
|
||||
### FR-020: Site Stabilization & Feature Gaps (MS20) — IN PROGRESS
|
||||
|
||||
Runtime bugs and feature gaps discovered during live testing of mosaic.woltje.com.
|
||||
|
||||
**Workspace Context Propagation:**
|
||||
|
||||
- Domains page: "Workspace ID is required" when creating domains
|
||||
- Projects page: "Workspace ID is required" when creating projects
|
||||
- Credentials page: unable to add credentials (button disabled, feature stub)
|
||||
- ASSUMPTION: The `useWorkspaceId()` hook + auto-detect in `apiRequest` from PR #532 handles reads, but mutation endpoints on some pages don't pass workspace ID correctly. Rationale: GET requests work after PR #532 but POST/mutation requests still fail on domains and projects pages.
|
||||
|
||||
**Missing API Endpoints:**
|
||||
|
||||
- `/api/personalities` — no controller/service exists; frontend expects GET/POST/PATCH/DELETE
|
||||
- `/users/me/preferences` — listed in PRD API table but returns 404; frontend profile page depends on it
|
||||
- ASSUMPTION: Personalities API follows existing NestJS module patterns (controller + service + DTO + Prisma model). Rationale: Consistent with all other API modules in the codebase.
|
||||
- ASSUMPTION: User preferences endpoint is part of the existing users module but route is not registered. Rationale: PRD lists it as an existing endpoint.
|
||||
|
||||
**Orchestrator Connectivity:**
|
||||
|
||||
- All orchestrator-proxied endpoints return HTTP 502
|
||||
- Orchestrator WebSocket connection fails ("Reconnecting to server...")
|
||||
- Dashboard widgets: Agent Status, Task Progress, Orchestrator Events all error
|
||||
- ASSUMPTION: The orchestrator service container runs but the Next.js API proxy cannot reach it. Root cause is likely environment variable or network configuration in Docker Swarm. Rationale: The orchestrator container exists in the compose file and has Traefik labels.
|
||||
|
||||
**UI/UX Issues:**
|
||||
|
||||
- Dark mode theming on Formality Level dropdown in Personalities page incorrect
|
||||
- favicon.ico missing (404)
|
||||
- Terminal sidebar link uses `#terminal` anchor instead of page route
|
||||
- `useWorkspaceId` warning in console: no workspace ID in localStorage on fresh sessions
|
||||
- ASSUMPTION: Terminal should have a dedicated page route `/terminal` that renders the terminal panel full-screen. Rationale: The sidebar has a Terminal link in the Operations section alongside Logs, implying it should be a navigable page.
|
||||
|
||||
**Credential Management:**
|
||||
|
||||
- "Add Credential" button is `disabled` in code — feature was stubbed as "coming soon"
|
||||
- Need to implement credential creation UI and wire to existing `/api/credentials` CRUD endpoints
|
||||
- ASSUMPTION: Credential CRUD frontend can use the existing `/api/credentials` API which was built during M7-CredentialSecurity. Rationale: Backend endpoints exist per audit.
|
||||
|
||||
### FR-021: Settings Configuration (Future — MS21)
|
||||
|
||||
- All environment variables configurable via UI
|
||||
- Minimal launch env vars, rest configurable dynamically
|
||||
@@ -363,7 +409,7 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
|
||||
9. ~~Lint, typecheck, and existing tests pass~~ DONE
|
||||
10. ~~Grain overlay texture from reference is applied~~ DONE
|
||||
|
||||
### Go-Live MVP (v0.1.0) — COMPLETE
|
||||
### Go-Live MVP (v0.0.16) — COMPLETE
|
||||
|
||||
11. ~~Dashboard widgets wired to real API data~~ DONE
|
||||
12. ~~WebSocket emits for agent job lifecycle~~ DONE
|
||||
@@ -492,14 +538,15 @@ These 19 NestJS modules are already implemented with Prisma and available for fr
|
||||
| Milestone | Version | Focus | Status |
|
||||
| ------------------------------ | ------- | ----------------------------------------------------------------- | ----------- |
|
||||
| MS15-DashboardShell | 0.0.15 | Design system + app shell + dashboard page | COMPLETE |
|
||||
| Go-Live MVP | 0.1.0 | Dashboard polish, ingestion, agent visibility, deploy | COMPLETE |
|
||||
| MS16+MS17-PagesDataIntegration | 0.1.1 | All pages built + wired to real API data | COMPLETE |
|
||||
| MS18-ThemeWidgets | 0.1.2 | Theme package system, widget registry, WYSIWYG, Kanban filtering | COMPLETE |
|
||||
| MS19-ChatTerminal | 0.1.3 | Global terminal, project chat, master chat session | COMPLETE |
|
||||
| MS20-MultiTenant | 0.2.0 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth | NOT STARTED |
|
||||
| MS21-Federation | 0.2.x | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
|
||||
| MS22-AgentTelemetry | 0.2.x | Agent task mapping, telemetry, wide-event logging | NOT STARTED |
|
||||
| MS23-Testing | 0.2.x | Playwright E2E, federation tests, documentation finalization | NOT STARTED |
|
||||
| Go-Live MVP | 0.0.16 | Dashboard polish, ingestion, agent visibility, deploy | COMPLETE |
|
||||
| MS16+MS17-PagesDataIntegration | 0.0.17 | All pages built + wired to real API data | COMPLETE |
|
||||
| MS18-ThemeWidgets | 0.0.18 | Theme package system, widget registry, WYSIWYG, Kanban filtering | COMPLETE |
|
||||
| MS19-ChatTerminal | 0.0.19 | Global terminal, project chat, master chat session | COMPLETE |
|
||||
| MS20-SiteStabilization | 0.0.20 | Runtime bug fixes, missing endpoints, orchestrator connectivity | IN PROGRESS |
|
||||
| MS21-MultiTenant | 0.0.21 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth | NOT STARTED |
|
||||
| MS22-Federation | 0.0.22 | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
|
||||
| MS23-AgentTelemetry | 0.0.23 | Agent task mapping, telemetry, wide-event logging | NOT STARTED |
|
||||
| MS24-Testing | 0.0.24 | Playwright E2E, federation tests, documentation finalization | NOT STARTED |
|
||||
|
||||
## Assumptions
|
||||
|
||||
@@ -511,3 +558,9 @@ These 19 NestJS modules are already implemented with Prisma and available for fr
|
||||
6. ASSUMPTION: Theme packages are code-level TypeScript files (not runtime-installable npm packages). Each theme exports CSS variable overrides. Rationale: Keeps the system simple for MS18; runtime package loading can be added in a future milestone.
|
||||
7. ASSUMPTION: WYSIWYG editor uses Tiptap (ProseMirror-based, headless). Rationale: Headless approach integrates naturally with the CSS variable design system, excellent markdown import/export, TypeScript-first, battle-tested.
|
||||
8. ASSUMPTION: MS18 includes WYSIWYG editing for knowledge entries and Kanban filtering enhancements in addition to themes and widgets. These were originally listed separately but are grouped into MS18 per PRD scope items 24-25. Rationale: All are frontend-focused enhancements that build on the existing page infrastructure.
|
||||
9. ASSUMPTION: The `useWorkspaceId()` hook + auto-detect in `apiRequest` from PR #532 handles reads, but mutation endpoints on some pages don't pass workspace ID correctly. Rationale: GET requests work after PR #532 but POST/mutation requests still fail on domains and projects pages.
|
||||
10. ASSUMPTION: Personalities API follows existing NestJS module patterns (controller + service + DTO + Prisma model). Rationale: Consistent with all other API modules in the codebase.
|
||||
11. ASSUMPTION: User preferences endpoint is part of the existing users module but route is not registered. Rationale: PRD lists it as an existing endpoint.
|
||||
12. ASSUMPTION: The orchestrator service container runs but the Next.js API proxy cannot reach it. Root cause is likely environment variable or network configuration in Docker Swarm. Rationale: The orchestrator container exists in the compose file and has Traefik labels.
|
||||
13. ASSUMPTION: Terminal should have a dedicated page route `/terminal` that renders the terminal panel full-screen. Rationale: The sidebar has a Terminal link in the Operations section alongside Logs, implying it should be a navigable page.
|
||||
14. ASSUMPTION: Credential CRUD frontend can use the existing `/api/credentials` API which was built during M7-CredentialSecurity. Rationale: Backend endpoints exist per audit.
|
||||
|
||||
@@ -1,54 +1,70 @@
|
||||
# Tasks — MS19 Chat & Terminal System
|
||||
# Tasks — MS20 Site Stabilization
|
||||
|
||||
> Single-writer: orchestrator only. Workers read but never modify.
|
||||
|
||||
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||
| ----------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------- | ------------------------------ | ----------------------------------------------- | ----------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ----------------------------------------------------------------- |
|
||||
| CT-PLAN-001 | done | Plan MS19 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | CT-TERM-001,CT-TERM-002,CT-CHAT-001,CT-CHAT-002 | orchestrator | 2026-02-25 | 2026-02-25 | 15K | ~15K | Planning complete |
|
||||
| CT-TERM-001 | done | Terminal WebSocket gateway & PTY session service — NestJS gateway (namespace: /terminal), node-pty spawn/kill/resize, workspace-scoped rooms, auth via token | #508 | api | feat/ms19-terminal-gateway | CT-PLAN-001 | CT-TERM-003,CT-TERM-004,CT-ORCH-002 | sonnet | 2026-02-25 | 2026-02-25 | 30K | ~30K | PR #515 merged (6290fc3), 48 tests |
|
||||
| CT-TERM-002 | done | Terminal session persistence — Prisma model (TerminalSession: id, workspaceId, name, status, createdAt, closedAt), migration, CRUD service | #508 | api | feat/ms19-terminal-persistence | CT-PLAN-001 | CT-TERM-004 | sonnet | 2026-02-25 | 2026-02-25 | 15K | ~15K | PR #517 merged (8128eb7), 12 tests, #508 closed |
|
||||
| CT-TERM-003 | done | xterm.js integration — Replace mock TerminalPanel with real xterm.js, WebSocket connection to /terminal namespace, resize handling, copy/paste, theme support | #509 | web | feat/ms19-xterm-integration | CT-TERM-001 | CT-TERM-004 | sonnet | 2026-02-25 | 2026-02-25 | 30K | ~30K | PR #518 merged (417c6ab), 40 tests |
|
||||
| CT-TERM-004 | done | Terminal tab management — Multiple named sessions, create/close/rename tabs, tab switching, session list from API, reconnect on page reload | #509 | web | feat/ms19-terminal-tabs | CT-TERM-001,CT-TERM-002,CT-TERM-003 | CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 20K | ~20K | PR #520 merged (859dcfc), 76 tests, #509 closed |
|
||||
| CT-CHAT-001 | done | Complete SSE chat streaming — Wire streamChatMessage() in frontend, token-by-token rendering in MessageList, streaming state indicators, abort/cancel support | #510 | web | feat/ms19-chat-streaming-v2 | CT-PLAN-001 | CT-CHAT-002,CT-ORCH-001 | sonnet | 2026-02-25 | 2026-02-25 | 25K | ~25K | PR #516 merged (7de0e73), streaming+fallback+abort |
|
||||
| CT-CHAT-002 | done | Master chat polish — Model selector dropdown, temperature/params config, conversation search in sidebar, keyboard shortcut improvements, empty state design | #510 | web | feat/ms19-chat-polish | CT-CHAT-001 | CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 15K | ~15K | PR #519 merged (13aa52a), 46 tests, #510 closed |
|
||||
| CT-ORCH-001 | done | Project-level orchestrator chat — Chat context scoped to project, command prefix parsing (/spawn, /status, /jobs, /kill), route commands through orchestrator proxy, display structured responses | #511 | web | feat/ms19-orchestrator-chat | CT-CHAT-001 | CT-ORCH-002,CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 30K | ~25K | PR #521 merged (b110c46), 34 tests |
|
||||
| CT-ORCH-002 | done | Agent output in terminal — View orchestrator agent sessions as terminal tabs, stream agent stdout/stderr via SSE (/agents/events), agent lifecycle indicators (spawning/running/done) | #511 | web | feat/ms19-agent-terminal | CT-TERM-001,CT-ORCH-001 | CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 25K | ~25K | PR #522 merged (9b2520c), 79 tests, #511 closed |
|
||||
| CT-VER-001 | done | Unit tests — Tests for terminal gateway, xterm component, chat streaming, orchestrator chat, agent terminal integration | #512 | web,api | — | CT-TERM-004,CT-CHAT-002,CT-ORCH-001,CT-ORCH-002 | CT-DOC-001 | orchestrator | 2026-02-25 | 2026-02-25 | 20K | ~5K | 328 MS19 tests (268 web + 60 API), all inline with tasks |
|
||||
| CT-DOC-001 | done | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #512 | — | — | CT-VER-001 | CT-VER-002 | orchestrator | 2026-02-25 | 2026-02-25 | 10K | ~5K | Updated PRD, manifest, scratchpad, TASKS.md |
|
||||
| CT-VER-002 | done | Deploy + smoke test — Deploy to Portainer, verify terminal, chat streaming, orchestrator chat, agent output all functional | #512 | — | — | CT-DOC-001 | | orchestrator | 2026-02-25 | 2026-02-25 | 15K | ~5K | CI #635 green, web deployed (sha:7165e7a), API crash pre-existing |
|
||||
| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
|
||||
| ----------- | ----------- | ---------------------------------------------------------------------------------------- | ----- | ------- | ----------------------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------ | ------------ | ---------- | ------------ | -------- | ---- | ------------------------------------------------------------------------------------------- |
|
||||
| SS-PLAN-001 | done | Plan MS20 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | SS-WS-001,SS-ORCH-001,SS-API-001,SS-UI-001 | orchestrator | 2026-02-27 | 2026-02-27 | 15K | ~15K | Planning complete |
|
||||
| SS-WS-001 | done | Fix workspace context for domain creation — domains page POST sends workspace ID | #534 | web | fix/workspace-domain-project-create | SS-PLAN-001 | SS-WS-002 | worker-1 | 2026-02-27 | 2026-02-27 | 15K | ~37K | PR #536 merged. CreateDomainDialog + wsId threading. QA remediated |
|
||||
| SS-WS-002 | done | Fix workspace context for project creation — projects page POST sends workspace ID | #534 | web | fix/workspace-domain-project-create | SS-WS-001 | SS-VER-001 | worker-1 | 2026-02-27 | 2026-02-27 | 10K | 0K | Already working — projects/page.tsx uses useWorkspaceId correctly |
|
||||
| SS-WS-003 | done | Fix useWorkspaceId localStorage initialization — ensure workspace ID persists from login | #534 | web | — | SS-PLAN-001 | SS-VER-001 | — | 2026-02-27 | 2026-02-27 | 15K | 0K | Already in main — auth-context.tsx has WORKSPACE_STORAGE_KEY persistence. PR #546 closed. |
|
||||
| SS-ORCH-001 | done | Fix orchestrator 502 — diagnose and fix proxy connectivity to orchestrator service | #534 | web,api | fix/orchestrator-connectivity | SS-PLAN-001 | SS-ORCH-002 | worker-6 | 2026-02-27 | 2026-02-27 | 25K | ~30K | PR #542 merged. Proxy config + CORS + health endpoint. |
|
||||
| SS-ORCH-002 | done | Fix WebSocket "Reconnecting to server..." — cookie auth + CORS + withCredentials | #534 | web,api | fix/websocket-reconnect | SS-ORCH-001 | SS-VER-001 | worker-8 | 2026-02-27 | 2026-02-27 | 15K | ~25K | PR #547 merged (auth fix), PR #548 (test), PR #549 (CORS origins). All green. |
|
||||
| SS-API-001 | done | Implement personalities API — controller, service, DTOs, Prisma model for CRUD | #534 | api | feat/personalities-api | SS-PLAN-001 | SS-UI-002 | worker-2 | 2026-02-27 | 2026-02-27 | 30K | ~45K | PR #537 merged. Full CRUD, migration, field mapping. Review: 3 should-fix logged |
|
||||
| SS-API-002 | done | Implement /users/me/preferences endpoint — wire to UserPreference model | #534 | api | feat/user-preferences-endpoint | SS-PLAN-001 | SS-VER-001 | worker-4 | 2026-02-27 | 2026-02-27 | 15K | ~18K | PR #539 merged. Added PATCH endpoint + fixed /api prefix in profile/appearance pages |
|
||||
| SS-UI-001 | done | Credential management UI — enable Add Credential button, create/view forms, wire to API | #534 | web | feat/credential-management-ui | SS-PLAN-001 | SS-VER-001 | worker-9 | 2026-02-27 | 2026-02-27 | 25K | ~25K | PR #545 merged. Full CRUD forms, credential type switching, API wiring. |
|
||||
| SS-UI-002 | done | Fix personalities page — dark mode Formality dropdown, save functionality, wire to API | #534 | web | fix/personalities-page | SS-API-001 | SS-VER-001 | worker-5 | 2026-02-27 | 2026-02-27 | 15K | ~10K | PR #540 merged. Select dark mode, 204 handler, deletePersonality type. Review: 3 should-fix |
|
||||
| SS-UI-003 | done | Terminal page route — create /terminal page with full-screen terminal panel | #534 | web | feat/terminal-page-route | SS-PLAN-001 | SS-VER-001 | worker-3 | 2026-02-27 | 2026-02-27 | 10K | ~15K | PR #538 merged. /terminal page + sidebar link. Review: 2 should-fix logged |
|
||||
| SS-UI-004 | done | Add favicon.ico and fix dark mode polish | #534 | web | fix/favicon-polish | SS-PLAN-001 | SS-VER-001 | worker-7 | 2026-02-27 | 2026-02-27 | 5K | ~8K | PR #541 merged. favicon.ico added + layout metadata |
|
||||
| SS-VER-001 | done | Verification — full site test, deploy, smoke test | #534 | web,api | fix/websocket-cors-origins | SS-WS-002,SS-WS-003,SS-ORCH-002,SS-API-002,SS-UI-001,SS-UI-002,SS-UI-003,SS-UI-004 | SS-DOC-001 | orchestrator | 2026-02-27 | 2026-02-27 | 15K | ~20K | All pages verified. PR #548 test fix, PR #549 CORS fix. Deployed pipeline 680. |
|
||||
| SS-DOC-001 | in-progress | Documentation — update PRD status, manifest, scratchpad, close mission | #534 | — | — | SS-VER-001 | | orchestrator | 2026-02-27 | | 5K | | |
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Value |
|
||||
| --------------- | ----------------- |
|
||||
| Total tasks | 12 |
|
||||
| Completed | 12 |
|
||||
| In Progress | 0 |
|
||||
| Remaining | 0 |
|
||||
| Estimated total | ~250K tokens |
|
||||
| Used | ~215K tokens |
|
||||
| Milestone | MS19-ChatTerminal |
|
||||
| Metric | Value |
|
||||
| --------------- | ---------------------- |
|
||||
| Total tasks | 14 |
|
||||
| Completed | 13 |
|
||||
| In Progress | 1 (SS-DOC-001) |
|
||||
| Remaining | 0 |
|
||||
| Estimated total | ~215K tokens |
|
||||
| Used | ~263K tokens |
|
||||
| Milestone | MS20-SiteStabilization |
|
||||
|
||||
## Dependency Graph
|
||||
|
||||
```
|
||||
PLAN-001 ──┬──→ TERM-001 ──┬──→ TERM-003 ──→ TERM-004 ──→ VER-001 ──→ DOC-001 ──→ VER-002
|
||||
│ │ ↑
|
||||
│ └──→ ORCH-002 ───────┘
|
||||
│ ↑
|
||||
├──→ TERM-002 ────────→ TERM-004
|
||||
│
|
||||
├──→ CHAT-001 ──┬──→ CHAT-002 ──→ VER-001
|
||||
│ │
|
||||
│ └──→ ORCH-001 ──→ ORCH-002
|
||||
│
|
||||
└──→ CHAT-002 (also depends on CHAT-001)
|
||||
PLAN-001 ✓ ──┬──→ WS-001 ✓ ──→ WS-002 ✓ ──→ VER-001 ✓ ──→ DOC-001 (in-progress)
|
||||
│
|
||||
├──→ WS-003 ✓ ──→ VER-001 ✓
|
||||
│
|
||||
├──→ ORCH-001 ✓ ──→ ORCH-002 ✓ ──→ VER-001 ✓
|
||||
│
|
||||
├──→ API-001 ✓ ──→ UI-002 ✓ ──→ VER-001 ✓
|
||||
│
|
||||
├──→ API-002 ✓ ──→ VER-001 ✓
|
||||
│
|
||||
├──→ UI-001 ✓ ──→ VER-001 ✓
|
||||
│
|
||||
├──→ UI-003 ✓ ──→ VER-001 ✓
|
||||
│
|
||||
└──→ UI-004 ✓ ──→ VER-001 ✓
|
||||
```
|
||||
|
||||
## Parallel Execution Opportunities
|
||||
## PRs Merged (14 total)
|
||||
|
||||
- **Wave 1** (after PLAN-001): TERM-001 + TERM-002 + CHAT-001 can run in parallel (3 independent tracks)
|
||||
- **Wave 2**: TERM-003 (after TERM-001) + CHAT-002 (after CHAT-001) + ORCH-001 (after CHAT-001) can overlap
|
||||
- **Wave 3**: TERM-004 (after TERM-001+002+003) + ORCH-002 (after TERM-001+ORCH-001)
|
||||
- **Wave 4**: VER-001 (after all implementation)
|
||||
- **Wave 5**: DOC-001 → VER-002 (sequential)
|
||||
| PR | Title | Branch |
|
||||
| ---- | ------------------------------------------------------------------ | ----------------------------------- |
|
||||
| #536 | fix(web): add workspace context to domain creation | fix/workspace-domain-project-create |
|
||||
| #537 | feat(api): implement personalities CRUD API | feat/personalities-api |
|
||||
| #538 | feat(web): add dedicated /terminal page route | feat/terminal-page-route |
|
||||
| #539 | feat(api): implement /users/me/preferences endpoint | feat/user-preferences-endpoint |
|
||||
| #540 | fix(web): fix personalities page dark mode theming and wire to API | fix/personalities-page |
|
||||
| #541 | fix(web): add favicon.ico | fix/favicon-polish |
|
||||
| #542 | fix(web,api): fix orchestrator proxy 502 connectivity | fix/orchestrator-connectivity |
|
||||
| #543 | chore(orchestrator): update MS20 task tracking for S3 | — |
|
||||
| #544 | fix(web): convert favicon.ico to RGBA format for Turbopack | fix/favicon-rgba |
|
||||
| #545 | feat(web): implement credential management UI | feat/credential-management-ui |
|
||||
| #547 | fix(web,api): fix WebSocket authentication for chat real-time | fix/websocket-reconnect |
|
||||
| #548 | fix(web): update useWebSocket test for withCredentials | fix/websocket-test-assertion |
|
||||
| #549 | fix(api): use getTrustedOrigins() for WebSocket CORS | fix/websocket-cors-origins |
|
||||
|
||||
103
docs/scratchpads/ms20-site-stabilization-20260227.md
Normal file
103
docs/scratchpads/ms20-site-stabilization-20260227.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Mission Scratchpad — MS20 Site Stabilization
|
||||
|
||||
> Append-only log. NEVER delete entries. NEVER overwrite sections.
|
||||
> This is the orchestrator's working memory across sessions.
|
||||
|
||||
## Original Mission Prompt
|
||||
|
||||
```
|
||||
User tested every aspect of mosaic.woltje.com and found:
|
||||
|
||||
settings/personalities:
|
||||
- Unable to save new personality
|
||||
- Dark mode theming on Formality Level dropdown not correct
|
||||
- Error: Cannot GET /api/personalities?isActive=true
|
||||
|
||||
settings/credentials:
|
||||
- "Loading credentials" displayed, none populated, unable to add
|
||||
- favicon.ico 404
|
||||
- useWorkspaceId warning in console
|
||||
|
||||
settings/domains:
|
||||
- Workspace ID is required error
|
||||
|
||||
projects:
|
||||
- Unable to create new project
|
||||
- Workspace ID is required error
|
||||
|
||||
Additional:
|
||||
- Fix Orchestrator 502
|
||||
- Fix Orchestrator WebSocket connection
|
||||
- /users/me/preferences endpoint needs implemented
|
||||
- #terminal anchor panel toggle needs page route
|
||||
```
|
||||
|
||||
## Planning Decisions
|
||||
|
||||
### S1 — 2026-02-27
|
||||
|
||||
1. **Mission scope**: Stabilization mission covering runtime bugs and feature gaps from live testing. NOT the originally planned MS20-MultiTenant. Bumped MultiTenant to MS21.
|
||||
|
||||
2. **Task categorization**:
|
||||
- P1 (Critical — blocking core functionality): Workspace context for mutations, orchestrator 502
|
||||
- P2 (High — important features): Personalities API, preferences endpoint, credentials UI, terminal route
|
||||
- P3 (Medium — polish): Dark mode dropdown, favicon, workspace ID warning
|
||||
|
||||
3. **PRD updated**: Added FR-020 (Site Stabilization) with 6 new assumptions. Shifted MS20-MultiTenant to MS21, renumbered subsequent milestones.
|
||||
|
||||
4. **Prior fixes already merged**:
|
||||
- PR #531: RLS context SQL, workspace guard crash, projects response unwrapping
|
||||
- PR #532: Widget endpoints workspace context + auto-detect workspace ID + credentials pages
|
||||
- PR #533: Knowledge entry query DTO — sortBy, sortOrder, search, visibility
|
||||
|
||||
## Session Log
|
||||
|
||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||
| ------- | ---------- | --------- | ---------- | ----------- |
|
||||
| S1 | 2026-02-27 | MS20 | Planning | In progress |
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Orchestrator 502: Is the orchestrator container actually running? Need to check Docker service status.
|
||||
- Workspace ID lifecycle: When does the workspace ID first get set in localStorage? Is it during login/auth callback?
|
||||
- Credentials backend: Do the M7 credential CRUD endpoints still work, or has something changed since?
|
||||
|
||||
### S2 — 2026-02-27
|
||||
|
||||
1. **Completed tasks**: WS-001 (PR #536), WS-002 (already working), API-001 (PR #537), API-002 (PR #539), UI-003 (PR #538)
|
||||
2. **Session ended**: Context exhaustion after dispatching 5 workers across 2 waves
|
||||
3. **Dirty state at exit**: SS-UI-002 worker left uncommitted changes (Select dark mode fix, 204 handler, personalities API client fix). SS-API-002 worker completed autonomously (PR #539 merged).
|
||||
4. **Variance**: SS-WS-001 estimated 15K used ~37K (146% over). SS-API-001 estimated 30K used ~45K (50% over). Both due to QA remediation cycles.
|
||||
|
||||
### S3 — 2026-02-27
|
||||
|
||||
1. **Dirty state recovery**: Recovered uncommitted S2 worker changes. Committed SS-API-002 to feat/user-preferences-endpoint (PR #539 already merged by old worker). Committed SS-UI-002 partial to fix/personalities-page (PR #540 open).
|
||||
2. **Dispatched workers**: SS-ORCH-001 (orchestrator 502 fix), SS-UI-004 (favicon)
|
||||
3. **Remaining**: WS-003, ORCH-001 (dispatched), ORCH-002 (blocked), UI-001, UI-004 (dispatched), VER-001, DOC-001
|
||||
|
||||
## Corrections
|
||||
|
||||
### S3 — TASKS.md revert
|
||||
|
||||
TASKS.md had reverted to S1 state (only PLAN-001 done) despite S2 completing 5 tasks. Root cause: S2 doc commits were on main but TASKS.md edits were local and lost when worktree workers caused git state issues. Rewrote TASKS.md from scratch in S3.
|
||||
|
||||
### S4 — 2026-02-27
|
||||
|
||||
1. **Completed tasks**: SS-WS-003 (already in main), SS-UI-001 (PR #545 merged by S3 worker), SS-ORCH-002 (PR #547 merged by worker + PR #548 test fix + PR #549 CORS fix by orchestrator), SS-VER-001 (full site verification + deploy)
|
||||
2. **Key findings during verification**:
|
||||
- WebSocket test failure: PR #547 added `withCredentials: true` but test expected old options. Fixed in PR #548.
|
||||
- WebSocket CORS: Gateway used `process.env.WEB_URL ?? "http://localhost:3000"` for CORS origin. WEB_URL not set in prod, causing localhost CORS rejection. Fixed in PR #549 to use `getTrustedOrigins()` matching main API.
|
||||
- SS-WS-003 was already in main from S2 worker that co-committed with favicon fix. PR #546 closed as redundant.
|
||||
3. **Deployment**: Portainer stack 121 (mosaic-stack) redeployed twice — first for PR #548 merge, second for PR #549 CORS fix.
|
||||
4. **Smoke test results**: All 8 key pages return 200. Chat WebSocket connected (no more "Reconnecting"). Favicon valid RGBA ICO. No CORS errors in console. Only remaining errors are orchestrator 502s (expected — service not active in prod).
|
||||
5. **Variance**: SS-ORCH-002 estimated 15K, used ~25K (67% over) due to CORS follow-up fix discovered during verification.
|
||||
6. **Total mission PRs**: 13 code PRs + 1 doc PR = 14 merged.
|
||||
|
||||
## Session Log (Updated)
|
||||
|
||||
| Session | Date | Milestone | Tasks Done | Outcome |
|
||||
| ------- | ---------- | --------- | ------------------------------------------ | --------- |
|
||||
| S1 | 2026-02-27 | MS20 | Planning | Completed |
|
||||
| S2 | 2026-02-27 | MS20 | WS-001, WS-002, API-001, API-002, UI-003 | Completed |
|
||||
| S3 | 2026-02-27 | MS20 | UI-002, UI-004, ORCH-001 dispatched | Completed |
|
||||
| S4 | 2026-02-27 | MS20 | WS-003, UI-001, ORCH-002, VER-001, DOC-001 | Completed |
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mosaic-stack",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.19.0",
|
||||
@@ -63,7 +63,7 @@
|
||||
],
|
||||
"overrides": {
|
||||
"@isaacs/brace-expansion": ">=5.0.1",
|
||||
"minimatch": ">=10.2.1",
|
||||
"minimatch": ">=10.2.3",
|
||||
"tar": ">=7.5.8",
|
||||
"form-data": ">=2.5.4",
|
||||
"lodash": ">=4.17.23",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/cli-tools",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.20",
|
||||
"description": "CLI tools for Mosaic Stack orchestration - git operations for Gitea/GitHub",
|
||||
"private": true,
|
||||
"bin": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/config",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/shared",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@mosaic/ui",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
47
pnpm-lock.yaml
generated
47
pnpm-lock.yaml
generated
@@ -6,7 +6,7 @@ settings:
|
||||
|
||||
overrides:
|
||||
'@isaacs/brace-expansion': '>=5.0.1'
|
||||
minimatch: '>=10.2.1'
|
||||
minimatch: '>=10.2.3'
|
||||
tar: '>=7.5.8'
|
||||
form-data: '>=2.5.4'
|
||||
lodash: '>=4.17.23'
|
||||
@@ -1596,6 +1596,7 @@ packages:
|
||||
|
||||
'@mosaicstack/telemetry-client@0.1.1':
|
||||
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@mrleebo/prisma-ast@0.13.1':
|
||||
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
|
||||
@@ -5776,9 +5777,9 @@ packages:
|
||||
minimalistic-assert@1.0.1:
|
||||
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||
|
||||
minimatch@10.2.1:
|
||||
resolution: {integrity: sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==}
|
||||
engines: {node: 20 || >=22}
|
||||
minimatch@10.2.4:
|
||||
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
@@ -7965,7 +7966,7 @@ snapshots:
|
||||
chalk: 5.6.2
|
||||
commander: 12.1.0
|
||||
dotenv: 17.2.4
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
open: 10.2.0
|
||||
pg: 8.17.2
|
||||
prettier: 3.8.1
|
||||
@@ -8303,7 +8304,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@eslint/object-schema': 2.1.7
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.1
|
||||
minimatch: 10.2.4
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -8324,7 +8325,7 @@ snapshots:
|
||||
ignore: 5.3.2
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.1.1
|
||||
minimatch: 10.2.1
|
||||
minimatch: 10.2.4
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -10780,7 +10781,7 @@ snapshots:
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/visitor-keys': 8.54.0
|
||||
debug: 4.4.3
|
||||
minimatch: 10.2.1
|
||||
minimatch: 10.2.4
|
||||
semver: 7.7.3
|
||||
tinyglobby: 0.2.15
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
@@ -11291,7 +11292,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
better-sqlite3: 12.6.2
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
pg: 8.17.2
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
@@ -11316,7 +11317,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
|
||||
better-sqlite3: 12.6.2
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
pg: 8.17.2
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
@@ -12135,17 +12136,6 @@ snapshots:
|
||||
|
||||
dotenv@17.2.4: {}
|
||||
|
||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
|
||||
'@types/pg': 8.16.0
|
||||
better-sqlite3: 12.6.2
|
||||
kysely: 0.28.10
|
||||
pg: 8.17.2
|
||||
postgres: 3.4.8
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
|
||||
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
|
||||
optionalDependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -12156,7 +12146,6 @@ snapshots:
|
||||
pg: 8.17.2
|
||||
postgres: 3.4.8
|
||||
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
|
||||
optional: true
|
||||
|
||||
dunder-proto@1.0.1:
|
||||
dependencies:
|
||||
@@ -12362,7 +12351,7 @@ snapshots:
|
||||
is-glob: 4.0.3
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 10.2.1
|
||||
minimatch: 10.2.4
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
optionalDependencies:
|
||||
@@ -12605,7 +12594,7 @@ snapshots:
|
||||
deepmerge: 4.3.1
|
||||
fs-extra: 10.1.0
|
||||
memfs: 3.5.3
|
||||
minimatch: 10.2.1
|
||||
minimatch: 10.2.4
|
||||
node-abort-controller: 3.1.1
|
||||
schema-utils: 3.3.0
|
||||
semver: 7.7.3
|
||||
@@ -12731,14 +12720,14 @@ snapshots:
|
||||
dependencies:
|
||||
foreground-child: 3.3.1
|
||||
jackspeak: 3.4.3
|
||||
minimatch: 10.2.1
|
||||
minimatch: 10.2.4
|
||||
minipass: 7.1.2
|
||||
package-json-from-dist: 1.0.1
|
||||
path-scurry: 1.11.1
|
||||
|
||||
glob@13.0.0:
|
||||
dependencies:
|
||||
minimatch: 10.2.1
|
||||
minimatch: 10.2.4
|
||||
minipass: 7.1.2
|
||||
path-scurry: 2.0.1
|
||||
|
||||
@@ -13374,7 +13363,7 @@ snapshots:
|
||||
|
||||
minimalistic-assert@1.0.1: {}
|
||||
|
||||
minimatch@10.2.1:
|
||||
minimatch@10.2.4:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.2
|
||||
|
||||
@@ -14110,7 +14099,7 @@ snapshots:
|
||||
|
||||
readdir-glob@1.1.3:
|
||||
dependencies:
|
||||
minimatch: 10.2.1
|
||||
minimatch: 10.2.4
|
||||
|
||||
readdirp@3.6.0:
|
||||
dependencies:
|
||||
@@ -14797,7 +14786,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@istanbuljs/schema': 0.1.3
|
||||
glob: 10.5.0
|
||||
minimatch: 10.2.1
|
||||
minimatch: 10.2.4
|
||||
|
||||
text-decoder@1.2.3:
|
||||
dependencies:
|
||||
|
||||
69
scripts/version-bump.sh
Executable file
69
scripts/version-bump.sh
Executable file
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env bash
|
||||
# version-bump.sh — Bump version across all workspace packages
|
||||
#
|
||||
# Usage:
|
||||
# scripts/version-bump.sh <new-version>
|
||||
# scripts/version-bump.sh 0.0.21
|
||||
#
|
||||
# Enforces:
|
||||
# - Version MUST be 0.0.x (alpha constraint)
|
||||
# - All package.json files are updated atomically
|
||||
# - Rejects 0.1.0+ until explicitly unlocked
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
|
||||
if [[ $# -ne 1 ]]; then
|
||||
echo "Usage: $0 <version>"
|
||||
echo "Example: $0 0.0.21"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEW_VERSION="$1"
|
||||
|
||||
# Hard gate: alpha constraint (0.0.x only)
|
||||
if [[ ! "$NEW_VERSION" =~ ^0\.0\.[0-9]+$ ]]; then
|
||||
echo "ERROR: Version must be 0.0.x (alpha). Got: $NEW_VERSION"
|
||||
echo ""
|
||||
echo "This project is in ALPHA. Versions >= 0.1.0 are not allowed."
|
||||
echo "If this constraint needs to change, update AGENTS.md and this script."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Patch number must be > 0
|
||||
PATCH="${NEW_VERSION##0.0.}"
|
||||
if [[ "$PATCH" -lt 1 ]]; then
|
||||
echo "ERROR: Patch version must be >= 1. Got: $NEW_VERSION"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Find all package.json files in the monorepo
|
||||
PACKAGE_FILES=(
|
||||
"$REPO_ROOT/package.json"
|
||||
)
|
||||
|
||||
for dir in "$REPO_ROOT"/apps/*/package.json "$REPO_ROOT"/packages/*/package.json; do
|
||||
[[ -f "$dir" ]] && PACKAGE_FILES+=("$dir")
|
||||
done
|
||||
|
||||
echo "Bumping all packages to $NEW_VERSION"
|
||||
echo ""
|
||||
|
||||
for pkg in "${PACKAGE_FILES[@]}"; do
|
||||
REL_PATH="${pkg#"$REPO_ROOT"/}"
|
||||
OLD_VERSION=$(grep -o '"version": "[^"]*"' "$pkg" | head -1 | cut -d'"' -f4)
|
||||
# Use node for reliable JSON editing (preserves formatting better than sed)
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const path = '$pkg';
|
||||
const raw = fs.readFileSync(path, 'utf8');
|
||||
const updated = raw.replace(/\"version\": \"[^\"]*\"/, '\"version\": \"$NEW_VERSION\"');
|
||||
fs.writeFileSync(path, updated);
|
||||
"
|
||||
echo " $REL_PATH: $OLD_VERSION -> $NEW_VERSION"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "Done. $NEW_VERSION applied to ${#PACKAGE_FILES[@]} packages."
|
||||
echo "Next steps: commit, tag (v$NEW_VERSION), push."
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"remoteCache": {},
|
||||
"tasks": {
|
||||
"prisma:generate": {
|
||||
"cache": false
|
||||
|
||||
Reference in New Issue
Block a user