diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..a66b2261 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,342 @@ +# AGENTS.md + +This file provides guidance to AI coding agents when working with code in this repository. + +## Project Overview + +Team9 is a full-stack instant messaging and team collaboration platform built as a monorepo. The backend uses NestJS with PostgreSQL (Drizzle ORM), while the frontend is a Tauri-based cross-platform desktop app (React + TypeScript) with real-time WebSocket communication via Socket.io. + +## Common Commands + +### Development + +```bash +pnpm dev # Run server (gateway + im-worker) and client concurrently +pnpm dev:client # Web frontend only (Vite dev server) +pnpm dev:desktop # Tauri desktop app (hot reload) +pnpm dev:server # Gateway service only +pnpm dev:im-worker # Background IM worker service only +pnpm dev:server:all # Both gateway and im-worker services +``` + +### Database Operations + +```bash +pnpm db:generate # Generate Drizzle schemas from TypeScript +pnpm db:migrate # Run pending migrations +pnpm db:push # Push schema changes to database (dev only) +pnpm db:studio # Open Drizzle Studio UI for database inspection +``` + +### Building + +```bash +pnpm build # Build both server and client +pnpm build:server # Build NestJS backend +pnpm build:client # Build web client +pnpm build:client:mac # Build macOS Tauri app +pnpm build:client:windows # Build Windows Tauri app +``` + +### Production + +```bash +pnpm start:prod # Start server in production mode +``` + +## Architecture + +### Monorepo Structure + +``` +apps/ +├── client/ # Tauri + React frontend +├── server/ +│ ├── apps/ +│ │ ├── gateway/ # Main API gateway (port 3000) +│ │ ├── im-worker/ # Background IM worker service (port 3001) +│ │ └── task-worker/ # Task execution worker service +│ └── libs/ # Shared libraries +│ ├── database/ # Drizzle schemas and DB module +│ ├── auth/ # Shared authentication +│ ├── ai-client/ +│ ├── agent-framework/ +│ ├── redis/ +│ ├── rabbitmq/ +│ └── shared/ # Common types, constants +└── debugger/ # Debug tool +``` + +### Backend Architecture (NestJS) + +Entry point: `apps/server/apps/gateway/src/main.ts` + +The backend follows a modular NestJS architecture with two main applications: + +- **Gateway:** Main API service with REST endpoints and WebSocket gateway +- **IM Worker:** Background service for async processing (message persistence, routing, offline delivery) + +Key Modules: + +- **Auth Module** (`apps/server/apps/gateway/src/auth`): JWT-based authentication with Passport strategy, 7-day token expiry +- **IM Module** (`apps/server/apps/gateway/src/im`): Instant messaging functionality + - Channels: direct, public, private types + - Messages: text, file, image, system types with threading support (parentId) + - Users: profile management, status tracking + - WebSocket: Socket.io gateway for real-time events +- **Workspace Module** (`apps/server/apps/gateway/src/workspace`): Multi-tenant workspace management +- **Edition Module** (`apps/server/apps/gateway/src/edition`): Dynamic feature loading for Community vs Enterprise editions + +Edition System: +The codebase supports Community and Enterprise editions via environment variable `EDITION=community|enterprise`. The Edition module conditionally loads features (e.g., TenantModule only in enterprise). Enterprise code lives in a separate git submodule at `enterprise/`. + +Database Layer: + +- Uses Drizzle ORM with PostgreSQL +- Schemas organized by domain in `apps/server/libs/database/schemas`: + - **im/**: users, channels, messages, channel_members, message_attachments, message_reactions, message_acks, mentions, user_channel_read_status + - **tenant/**: tenants, tenant_members, workspace_invitations +- All migrations managed via `pnpm db:migrate` +- Schema changes pushed via `pnpm db:push` (dev) or `pnpm db:generate` + `pnpm db:migrate` (prod) + +### Frontend Architecture (Tauri + React) + +Entry point: `apps/client/src/main.tsx` + +State Management: + +- **Zustand** for UI state: theme, user profile, loading states + - App store: `apps/client/src/stores/app.ts` + - Workspace store: `apps/client/src/stores/workspace.ts` + - Home store: `apps/client/src/stores/home.ts` +- **TanStack React Query** for server state: messages, channels, users (caching, invalidation) +- Local component state for UI-only interactions + +Routing: + +- **TanStack Router** with file-based routing in `apps/client/src/routes` +- Protected routes via `_authenticated` layout +- Automatic route generation from directory structure + +HTTP Client: + +- Custom HttpClient class at `apps/client/src/services/http.ts` +- Request/response interceptors for auth tokens and error handling +- Centralized API client at `apps/client/src/services/api.ts` + +WebSocket Service: + +- Singleton pattern at `apps/client/src/services/websocket.ts` +- Auto-reconnection with exponential backoff +- Event queuing for offline operations +- Type-safe event emitters +- Channel join/leave lifecycle management + +### Real-Time Communication + +WebSocket Events (Socket.io): + +Message Operations: + +- `new_message`: Server broadcasts new messages to channel members +- `mark_as_read` → `read_status_updated`: Read receipt tracking +- `add_reaction` → `reaction_added`, `reaction_removed`: Message reactions + +User Presence: + +- `user_online`, `user_offline`: Connection status +- `user_status_changed`: Status updates (online/offline/away/busy) +- `typing_start`, `typing_stop` → `user_typing`: Typing indicators + +Channel Management: + +- `join_channel`, `leave_channel`: Channel subscription lifecycle + +Message Features: + +- Threading via `parentId` field +- Mentions: @user, @channel, @everyone (parsed server-side) +- Attachments: file, image types +- Reactions: emoji-based reactions per message +- Read status: per-user, per-channel tracking via `user_channel_read_status` table + +### Key Development Patterns + +Adding a New Database Table: + +1. Define schema in `apps/server/libs/database/schemas` using Drizzle syntax +2. Export from the appropriate index file (im/index.ts or tenant/index.ts) +3. Run `pnpm db:generate` to generate migration +4. Run `pnpm db:migrate` to apply migration +5. Update database module to inject the new table + +Adding a New API Endpoint: + +1. Create controller method in appropriate module (auth, im, workspace) +2. Implement business logic in service layer +3. Use Drizzle to query database via injected DatabaseService +4. Add DTO classes for request/response validation +5. Apply appropriate guards (JwtAuthGuard for protected routes) + +Adding a New WebSocket Event: + +1. Define event handler in `apps/server/apps/gateway/src/im/websocket/websocket.gateway.ts` +2. Emit response events via `this.server.to(channelId).emit(event, data)` +3. Add client-side listener in `apps/client/src/services/websocket.ts` +4. Update React Query cache or Zustand store based on event data + +Adding a New Frontend Route: + +1. Create file in `apps/client/src/routes` following TanStack Router conventions +2. Use `_authenticated` layout for protected routes +3. Define loader functions for data fetching +4. Implement component with hooks for state/query management + +## External Dependencies + +### OpenClaw Hive + +Team9 acts as a **client** of the OpenClaw Hive Control Plane API. The integration module lives at `apps/server/apps/gateway/src/openclaw/`: + +- **OpenclawService** (`openclaw.service.ts`): HTTP client that calls the Control Plane API to manage instances +- **OpenclawModule** (`openclaw.module.ts`): Global NestJS module exporting the service + +Data flow: + +1. When a bot is created in Team9, `OpenclawService` calls the Control Plane API (`POST /api/instances`) to provision an OpenClaw instance +2. Team9 passes `TEAM9_TOKEN` and `TEAM9_BASE_URL` as env vars so the OpenClaw instance can call back to Team9's IM APIs +3. The OpenClaw instance connects to Team9 via REST API and WebSocket (Socket.io) to send/receive messages + +Environment variables (Team9 side): + +- `OPENCLAW_API_URL`: Control Plane base URL (e.g., `https://plane.claw.team9.ai`) +- `OPENCLAW_AUTH_TOKEN`: Bearer token for authenticating with the Control Plane API + +Control Plane API endpoints (called by Team9's OpenclawService): + +| Method | Endpoint | Description | +| ------ | -------------------------- | ---------------------------------------------- | +| GET | `/api/instances` | List all instances | +| GET | `/api/instances/:id` | Get instance by ID | +| POST | `/api/instances` | Create new instance (`{id, subdomain?, env?}`) | +| DELETE | `/api/instances/:id` | Delete instance | +| POST | `/api/instances/:id/start` | Start instance | +| POST | `/api/instances/:id/stop` | Stop instance | + +Authentication model: + +- Team9 → Control Plane: `Authorization: Bearer ` +- OpenClaw instance → Team9: `Authorization: Bearer ` (JWT generated per bot) + +Team9 REST API endpoints called by the OpenClaw plugin: + +| Method | Endpoint | Description | +| ------ | ------------------------------------------ | ---------------------------------------------- | +| GET | `/api/v1/users/me` | Get bot's own user profile | +| GET | `/api/v1/users/:userId` | Get user by ID | +| GET | `/api/v1/im/channels` | List channels | +| GET | `/api/v1/im/channels/:id` | Get channel by ID | +| POST | `/api/v1/im/channels/dm/:targetUserId` | Get or create DM channel | +| GET | `/api/v1/im/channels/:id/messages` | Get channel messages | +| POST | `/api/v1/im/channels/:id/messages` | Send message (supports `parentId` for threads) | +| PATCH | `/api/v1/im/messages/:id` | Update message | +| DELETE | `/api/v1/im/messages/:id` | Delete message | +| POST | `/api/v1/im/messages/:id/reactions` | Add reaction | +| DELETE | `/api/v1/im/messages/:id/reactions/:emoji` | Remove reaction | +| POST | `/api/v1/im/channels/:id/read` | Mark channel as read | + +> **Important:** When modifying Team9's IM APIs or WebSocket events, changes must stay compatible with the OpenClaw plugin. + +### TaskCast + +Team9 acts as a **client** of a TaskCast server instance, using `@taskcast/server-sdk` to create tasks, transition statuses, and publish events. The frontend subscribes to task progress via SSE (Server-Sent Events) proxied through the Team9 gateway. + +Integration points in Team9: + +| Component | Path | Role | +| ----------------------- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------ | +| `TaskCastService` | `apps/server/apps/gateway/src/tasks/taskcast.service.ts` | Creates TaskCast tasks, transitions status, publishes events | +| `TasksStreamController` | `apps/server/apps/gateway/src/tasks/tasks-stream.controller.ts` | SSE proxy — authenticates user, verifies access, proxies upstream TaskCast SSE | +| `TaskCastClient` | `apps/server/apps/task-worker/src/taskcast/taskcast.client.ts` | Creates TaskCast tasks from the worker service | +| `WebhookController` | `apps/server/apps/task-worker/src/webhook/webhook.controller.ts` | Receives TaskCast timeout webhooks, updates execution/task status | +| `useExecutionStream` | `apps/client/src/hooks/useExecutionStream.ts` | React hook — opens SSE to stream execution events | + +Data flow: + +1. When a task execution starts, `TaskCastService` creates a TaskCast task with deterministic ID `agent_task_exec_${executionId}` +2. During execution, events are published to TaskCast via `publishEvent()` +3. The frontend opens an SSE connection through the gateway's proxy endpoint (`GET /api/v1/tasks/:taskId/executions/:execId/stream`) +4. The gateway authenticates the user, verifies workspace membership, then proxies the SSE stream from TaskCast +5. On task timeout, TaskCast calls the webhook endpoint (`POST /webhooks/taskcast/timeout`), which updates the DB status + +Environment variables (Team9 side): + +- `TASKCAST_URL`: TaskCast server base URL (default: `http://localhost:3721`) +- `TASKCAST_WEBHOOK_SECRET`: Shared secret for validating incoming TaskCast webhooks + +Key concepts: + +- **Task lifecycle:** `pending → running → completed|failed|timeout|cancelled` (no backward transitions) +- **Deterministic IDs:** Team9 uses `agent_task_exec_${executionId}` pattern — no DB lookup needed for TaskCast task ID + +### aHand + +Team9 integrates with **aHand** — a local execution gateway for cloud AI that lets cloud-side orchestrators run tools on local machines behind NAT/firewalls via WebSocket. + +Architecture: + +``` +Cloud (WS server) ←── WebSocket (protobuf) ──→ Local daemon (WS client) + │ │ + @ahand/sdk ahandd + (control plane) (job executor) + ├─ shell / tools + ├─ browser automation + └─ policy enforcement +``` + +- **SDK** (`@ahand/sdk`): TypeScript cloud control plane SDK +- **Daemon** (`ahandd`): Rust binary enforcing local security policy before executing any job +- **Protocol:** Protocol Buffers over WebSocket + +Session modes enforced by the daemon: + +| Mode | Behavior | +| --------------- | ----------------------------------------------------- | +| **Inactive** | Default — rejects all jobs until activated | +| **Strict** | Every command requires manual approval | +| **Trust** | Auto-approve with inactivity timeout (default 60 min) | +| **Auto-Accept** | Auto-approve, no timeout | + +## Technology Stack + +Frontend: + +- React 19, TypeScript 5.8+, Tauri 2 +- TanStack Router 1.141, TanStack React Query 5.90 +- Zustand 5.0, Socket.io-client +- Radix UI, Tailwind CSS 4.1, Lucide icons +- Vite 7 + +Backend: + +- NestJS 11, TypeScript 5.8+ +- PostgreSQL + Drizzle ORM +- Socket.io, JWT + Passport +- Redis, RabbitMQ +- Anthropic AI SDK, Google Generative AI + +Tooling: + +- pnpm workspaces, ESLint, Prettier +- Jest (testing), SWC (compilation) +- Husky + lint-staged (pre-commit hooks) + +## Prerequisites + +- Node.js >= 18.0.0 +- pnpm >= 8.0.0 +- Rust toolchain (for Tauri builds) +- PostgreSQL (local or remote) +- Redis (for caching/sessions) +- RabbitMQ (for message queuing) diff --git a/apps/client/package.json b/apps/client/package.json index 6fd49b09..265a3788 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -56,6 +56,7 @@ "@tanstack/react-router": "^1.141.6", "@tanstack/router-devtools": "^1.141.6", "@tanstack/router-plugin": "^1.141.7", + "@taskcast/client": "^1.1.0", "@tauri-apps/api": "^2.10.1", "@tauri-apps/plugin-deep-link": "^2.4.7", "@tauri-apps/plugin-opener": "^2", diff --git a/apps/client/src/components/channel/ChannelView.tsx b/apps/client/src/components/channel/ChannelView.tsx index 0f8c243c..01a13f26 100644 --- a/apps/client/src/components/channel/ChannelView.tsx +++ b/apps/client/src/components/channel/ChannelView.tsx @@ -48,6 +48,10 @@ interface ChannelViewProps { initialDraft?: string; // Preview channel data for non-members (public channel preview mode) previewChannel?: PublicChannelPreview; + // Hide the built-in ChannelHeader (e.g. when a parent component provides its own header) + hideHeader?: boolean; + // Show a read-only bar instead of the message input + readOnly?: boolean; } /** @@ -60,6 +64,8 @@ export function ChannelView({ initialMessageId, initialDraft, previewChannel, + hideHeader, + readOnly, }: ChannelViewProps) { const isPreviewMode = !!previewChannel; const { data: memberChannel, isLoading: channelLoading } = useChannel( @@ -329,7 +335,9 @@ export function ChannelView({
- + {!hideHeader && ( + + )} {showOverlay ? ( )} - {isPreviewMode ? ( + {readOnly ? ( +
+ Read-only +
+ ) : isPreviewMode ? ( = { - in_progress: "default", - upcoming: "secondary", - paused: "outline", - pending_action: "default", - completed: "secondary", - failed: "destructive", - stopped: "outline", - timeout: "destructive", -}; - -const RETRIABLE_STATUSES: AgentTaskStatus[] = ["failed", "timeout", "stopped"]; - -interface RunDetailViewProps { - taskId: string; - executionId: string; - onBack: () => void; - onChannelChange?: (channelId: string | null) => void; - userMessages?: TimelineUserMessage[]; -} - -const ACTIVE_STATUSES: AgentTaskStatus[] = [ - "in_progress", - "pending_action", - "paused", -]; - -export function RunDetailView({ - taskId, - executionId, - onBack, - onChannelChange, - userMessages, -}: RunDetailViewProps) { - const { t } = useTranslation("tasks"); - const queryClient = useQueryClient(); - const [showRetryForm, setShowRetryForm] = useState(false); - const [retryNotes, setRetryNotes] = useState(""); - - const { data: execution, isLoading: execLoading } = useQuery({ - queryKey: ["task-execution", taskId, executionId], - queryFn: () => tasksApi.getExecution(taskId, executionId), - refetchInterval: 5000, - }); - - // Notify parent of this run's channelId for the message input - const channelId = execution?.channelId ?? null; - const isActive = execution - ? ACTIVE_STATUSES.includes(execution.status) - : false; - useEffect(() => { - onChannelChange?.(isActive ? channelId : null); - return () => onChannelChange?.(null); - }, [channelId, isActive, onChannelChange]); - - const { data: entries = [] } = useQuery({ - queryKey: ["task-execution-entries", taskId, executionId], - queryFn: () => tasksApi.getExecutionEntries(taskId, executionId), - refetchInterval: 5000, - }); - - const retryMutation = useMutation({ - mutationFn: () => - tasksApi.retry(taskId, { - executionId, - notes: retryNotes.trim() || undefined, - }), - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["task-executions", taskId], - }); - queryClient.invalidateQueries({ queryKey: ["task", taskId] }); - setShowRetryForm(false); - setRetryNotes(""); - onBack(); - }, - }); - - if (execLoading || !execution) { - return ( -
- -
- ); - } - - const triggerCtx = execution.triggerContext as TriggerContext | null; - const canRetry = RETRIABLE_STATUSES.includes(execution.status); - - return ( -
- {/* Header */} -
- - - {t("runs.version", { version: execution.version })} - - {execution.triggerType && ( - - {t(`runs.triggerType.${execution.triggerType}`)} - - )} - - {t(`status.${execution.status}`)} - -
- - {/* Trigger context */} - {triggerCtx && ( -
- {"notes" in triggerCtx && triggerCtx.notes && ( -

- {t("runs.notes")}:{" "} - {triggerCtx.notes} -

- )} - {"scheduledAt" in triggerCtx && ( -

- {t("runs.scheduledAt", { - time: new Date(triggerCtx.scheduledAt).toLocaleString(), - })} -

- )} - {"messageContent" in triggerCtx && triggerCtx.messageContent && ( -

{triggerCtx.messageContent}

- )} - {"senderId" in triggerCtx && ( -

- {t("runs.messageFrom", { user: triggerCtx.senderId })} -

- )} - {"originalExecutionId" in triggerCtx && ( -

- {t("runs.retryOf", { version: "?" })} - {"originalFailReason" in triggerCtx && - triggerCtx.originalFailReason && ( - - {" "} - — {triggerCtx.originalFailReason} - - )} -

- )} -

- {t("runs.triggeredAt", { - time: new Date(triggerCtx.triggeredAt).toLocaleString(), - })} -

-
- )} - - {/* Execution info */} - {execution.startedAt && ( -

- {new Date(execution.startedAt).toLocaleString()} - {execution.duration != null && - execution.duration > 0 && - ` · ${execution.duration}s`} - {execution.tokenUsage > 0 && ` · ${execution.tokenUsage} tokens`} -

- )} - - - - {/* Unified timeline */} - - - {/* Retry */} - {canRetry && ( - <> - - {showRetryForm ? ( -
-