From 1f37fe8361f93abe6a3add6cedef833654897312 Mon Sep 17 00:00:00 2001 From: Winrey Date: Fri, 13 Mar 2026 18:12:11 +0800 Subject: [PATCH 01/68] docs: add TaskCast integration design spec Co-Authored-By: Claude Opus 4.6 --- .../2026-03-13-taskcast-integration-design.md | 629 ++++++++++++++++++ 1 file changed, 629 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-13-taskcast-integration-design.md diff --git a/docs/superpowers/specs/2026-03-13-taskcast-integration-design.md b/docs/superpowers/specs/2026-03-13-taskcast-integration-design.md new file mode 100644 index 00000000..a657cf4d --- /dev/null +++ b/docs/superpowers/specs/2026-03-13-taskcast-integration-design.md @@ -0,0 +1,629 @@ +# TaskCast Integration Design Spec + +> Date: 2026-03-13 +> Status: Draft + +## Background + +Team9's Task Module lets users assign tasks to AI Bots. Bots execute autonomously and report progress in real-time. Previously, a standalone `task-tracker` microservice (port 3002) existed for generic task tracking but was never integrated. It has been removed. + +TaskCast is our own open-source SSE + task management library. A Rust instance (`mwr1998/taskcast-rs`) is now deployed on Railway dev: + +- **Internal:** `http://taskcast.railway.internal:3721` +- **Public (test):** `https://team9-taskcast-development.up.railway.app` +- **Storage:** Shared Redis (broadcast + short-term) + Dedicated Postgres (long-term) +- **Auth:** `none` (internal network only) + +## Goals + +1. Replace TODO stubs in `TaskCastService` with real `@taskcast/server-sdk` calls +2. Create a real TaskCast task per execution (not UUID placeholder) +3. Publish step/intervention/deliverable events to TaskCast during execution +4. Add Gateway SSE proxy endpoint for frontend consumption +5. Replace 5s polling in frontend with TaskCast SSE via custom hook + `@taskcast/client` + +## Non-Goals + +- TaskCast auth (internal network, no auth needed for now) +- Custom state machine configuration (TaskCast already supports `paused`/`blocked` natively) +- Removing WebSocket events (keep `task:status_changed` and `task:execution_created` for task list notifications) +- Implementing `task:execution_created` WebSocket emission (tracked separately) + +--- + +## Design Principles + +- **DB is source of truth.** TaskCast is a best-effort real-time view. If TaskCast events are lost, the 5s polling fallback ensures frontend eventually converges. +- **Fire-and-forget publishing.** All TaskCast calls are wrapped with internal error handling in `TaskCastService`. Callers never need try/catch. TaskCast failures never block execution flow. +- **Graceful degradation.** If `taskcastTaskId` is null (legacy executions or TaskCast creation failure), frontend falls back to 5s polling. + +--- + +## Architecture + +``` +Frontend (React) +├── REST API ──────────────── Gateway (CRUD, control) +├── Socket.io ─────────────── Gateway (task list notifications) +└── SSE ───────────────────── Gateway /api/v1/tasks/:taskId/executions/:execId/stream + │ (proxy) + ▼ + TaskCast (taskcast.railway.internal:3721) + │ + Redis (broadcast + short-term) + Postgres (long-term archive) + +task-worker +├── Creates TaskCast task via @taskcast/server-sdk +└── Bot reports progress → TaskBotService → publishes events to TaskCast +``` + +### Responsibility Separation + +| Channel | Purpose | Scope | +| ---------------- | -------------------------------------------------------------------------------- | ------------------------ | +| **REST** | Task CRUD, execution control, intervention resolution | Request-response | +| **Socket.io** | Task list status notifications (`task:status_changed`, `task:execution_created`) | Workspace-wide broadcast | +| **TaskCast SSE** | Real-time execution progress (steps, interventions, deliverables) | Per-execution stream | + +--- + +## Backend Design + +### 1. Install `@taskcast/server-sdk` + +`@taskcast/server-sdk` is published on npm. Add to both gateway and task-worker: + +```bash +pnpm --filter @team9/gateway add @taskcast/server-sdk +pnpm --filter @team9/task-worker add @taskcast/server-sdk +``` + +### 2. TaskCastService (Gateway) + +**File:** `apps/server/apps/gateway/src/tasks/taskcast.service.ts` + +Replace TODO stubs with `TaskcastServerClient`. All methods catch errors internally so callers never need try/catch: + +```typescript +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { TaskcastServerClient } from "@taskcast/server-sdk"; +import type { TaskStatus } from "@taskcast/core"; + +const STATUS_MAP: Record = { + in_progress: "running", + paused: "paused", + pending_action: "blocked", + completed: "completed", + failed: "failed", + timeout: "timeout", + stopped: "cancelled", +}; + +@Injectable() +export class TaskCastService { + private readonly logger = new Logger(TaskCastService.name); + private readonly client: TaskcastServerClient; + + constructor(config: ConfigService) { + this.client = new TaskcastServerClient({ + baseUrl: config.get("TASKCAST_URL", "http://localhost:3721"), + }); + } + + async createTask(params: { + taskId: string; + executionId: string; + botId: string; + tenantId: string; + ttl?: number; + }): Promise { + try { + const task = await this.client.createTask({ + type: `agent_task.${params.taskId}`, + ttl: params.ttl ?? 86400, + metadata: { + taskId: params.taskId, + executionId: params.executionId, + botId: params.botId, + tenantId: params.tenantId, + }, + }); + return task.id; + } catch (error) { + this.logger.error(`Failed to create TaskCast task: ${error}`); + return null; + } + } + + async transitionStatus( + taskcastTaskId: string, + status: string, + ): Promise { + const mapped = STATUS_MAP[status]; + if (!mapped) { + this.logger.warn(`No TaskCast mapping for status: ${status}`); + return; + } + try { + await this.client.transitionTask(taskcastTaskId, mapped); + } catch (error) { + this.logger.error(`Failed to transition TaskCast status: ${error}`); + } + } + + async publishEvent( + taskcastTaskId: string, + event: { + type: string; + data: Record; + seriesId?: string; + seriesMode?: "accumulate" | "latest" | "keep-all"; + }, + ): Promise { + try { + await this.client.publishEvent(taskcastTaskId, { + type: event.type, + level: "info", + data: event.data, + seriesId: event.seriesId, + seriesMode: event.seriesMode, + }); + } catch (error) { + this.logger.error(`Failed to publish TaskCast event: ${error}`); + } + } + + /** No-op — TaskCast cleanup rules handle expiration via TTL. */ + async deleteTask(_taskcastTaskId: string): Promise {} +} +``` + +**Note:** Method is renamed from `updateStatus` → `transitionStatus` to better reflect the TaskCast API. All callsites must be updated (currently none exist since all were TODOs). + +### 3. Status Mapping + +| Team9 Status | TaskCast Status | Notes | +| ---------------- | --------------- | ----------------------- | +| `in_progress` | `running` | | +| `paused` | `paused` | User-initiated pause | +| `pending_action` | `blocked` | Awaiting intervention | +| `completed` | `completed` | Terminal | +| `failed` | `failed` | Terminal | +| `timeout` | `timeout` | Terminal | +| `stopped` | `cancelled` | Terminal | +| `upcoming` | — | No execution exists yet | + +Non-terminal status transitions (`paused`, `pending_action`, `in_progress`) are driven by `TasksService` control actions (pause/resume/stop), not by `TaskBotService`. The bot API (`TaskBotService.updateStatus()`) only allows terminal statuses (`completed`/`failed`/`timeout`). + +### 4. TaskCast Client in task-worker + +Since `TaskCastService` lives in gateway and task-worker is a separate process, create a standalone client in task-worker: + +**New file:** `apps/server/apps/task-worker/src/taskcast/taskcast.client.ts` + +```typescript +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { TaskcastServerClient } from "@taskcast/server-sdk"; + +@Injectable() +export class TaskCastClient { + private readonly logger = new Logger(TaskCastClient.name); + private readonly client: TaskcastServerClient; + + constructor(config: ConfigService) { + this.client = new TaskcastServerClient({ + baseUrl: config.get("TASKCAST_URL", "http://localhost:3721"), + }); + } + + async createTask(params: { + taskId: string; + executionId: string; + botId: string; + tenantId: string; + ttl?: number; + }): Promise { + try { + const task = await this.client.createTask({ + type: `agent_task.${params.taskId}`, + ttl: params.ttl ?? 86400, + metadata: { + taskId: params.taskId, + executionId: params.executionId, + botId: params.botId, + tenantId: params.tenantId, + }, + }); + return task.id; + } catch (error) { + this.logger.error(`Failed to create TaskCast task: ${error}`); + return null; + } + } +} +``` + +**New file:** `apps/server/apps/task-worker/src/taskcast/taskcast.module.ts` + +```typescript +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { TaskCastClient } from "./taskcast.client.js"; + +@Module({ + imports: [ConfigModule], + providers: [TaskCastClient], + exports: [TaskCastClient], +}) +export class TaskCastModule {} +``` + +**Update:** `apps/server/apps/task-worker/src/executor/executor.module.ts` — add `TaskCastModule` to imports. + +**Update:** `apps/server/apps/task-worker/src/executor/executor.service.ts` — inject `TaskCastClient`: + +```diff + constructor( + @Inject(DATABASE_CONNECTION) + private readonly db: PostgresJsDatabase, ++ private readonly taskCastClient: TaskCastClient, + ) {} +``` + +In `triggerExecution()`, replace the UUID placeholder: + +```diff +- const taskcastTaskId = uuidv7(); // Placeholder ++ const taskcastTaskId = await this.taskCastClient.createTask({ ++ taskId, ++ executionId, ++ botId: task.botId, ++ tenantId: task.tenantId, ++ ttl: 86400, ++ }); +``` + +If `createTask` returns `null` (TaskCast failure), the execution proceeds without TaskCast tracking. `taskcastTaskId` will be null in the DB and frontend falls back to polling. + +### 5. TaskBotService Integration (Gateway) + +**File:** `apps/server/apps/gateway/src/tasks/task-bot.service.ts` + +**Constructor change:** + +```diff + constructor( + @Inject(DATABASE_CONNECTION) private readonly db: ..., + @Inject(WEBSOCKET_GATEWAY) private readonly wsGateway: ..., ++ private readonly taskCastService: TaskCastService, + ) {} +``` + +`TaskCastService` is already registered as a provider in `TasksModule` (`tasks.module.ts`), so DI will resolve it. + +Publish events after each DB write: + +**`reportSteps()`** — after returning `steps`: + +```typescript +if (execution.taskcastTaskId) { + await this.taskCastService.publishEvent(execution.taskcastTaskId, { + type: "step", + data: { steps }, + seriesId: "steps", + seriesMode: "latest", + }); +} +``` + +**`updateStatus()`** — after DB write (terminal statuses only): + +```typescript +if (execution.taskcastTaskId) { + await this.taskCastService.transitionStatus(execution.taskcastTaskId, status); +} +``` + +**`createIntervention()`** — after DB write: + +```typescript +if (execution.taskcastTaskId) { + await this.taskCastService.transitionStatus( + execution.taskcastTaskId, + "pending_action", + ); + await this.taskCastService.publishEvent(execution.taskcastTaskId, { + type: "intervention", + data: { intervention }, + seriesId: `intervention:${intervention.id}`, + seriesMode: "latest", + }); +} +``` + +**`addDeliverable()`** — after DB write: + +```typescript +if (execution.taskcastTaskId) { + await this.taskCastService.publishEvent(execution.taskcastTaskId, { + type: "deliverable", + data: { deliverable }, + }); +} +``` + +### 6. TasksService Integration (Gateway) + +**File:** `apps/server/apps/gateway/src/tasks/tasks.service.ts` + +**Constructor change:** Add `TaskCastService` injection. + +**Control action handlers** (pause/resume/stop) — sync status to TaskCast: + +```typescript +// In pause handler: +if (execution.taskcastTaskId) { + await this.taskCastService.transitionStatus( + execution.taskcastTaskId, + "paused", + ); +} + +// In resume handler: +if (execution.taskcastTaskId) { + await this.taskCastService.transitionStatus( + execution.taskcastTaskId, + "in_progress", + ); +} + +// In stop handler: +if (execution.taskcastTaskId) { + await this.taskCastService.transitionStatus( + execution.taskcastTaskId, + "stopped", + ); +} + +// In resolveIntervention: +if (execution.taskcastTaskId) { + await this.taskCastService.transitionStatus( + execution.taskcastTaskId, + "in_progress", + ); +} +``` + +### 7. Gateway SSE Proxy Endpoint + +**New file:** `apps/server/apps/gateway/src/tasks/tasks-stream.controller.ts` + +``` +GET /api/v1/tasks/:taskId/executions/:execId/stream +``` + +- Authenticated via `JwtAuthGuard` +- Looks up execution to get `taskcastTaskId` +- Verifies user is a member of the task's workspace +- Forwards `Last-Event-ID` request header for SSE resumability +- Uses raw `Response` object with `Content-Type: text/event-stream` to pipe upstream TaskCast SSE +- On client disconnect, aborts the upstream fetch to clean up + +**Implementation approach:** Use `fetch()` to open SSE connection to `${TASKCAST_URL}/tasks/${taskcastTaskId}/events/stream`, then pipe the response body through to the client response. NestJS `@Sse()` decorator is not suitable here since we need raw stream proxying, not RxJS Observable-based SSE. + +**Register** in `TasksModule` as a controller. + +### 8. WebhookController Update (task-worker) + +**File:** `apps/server/apps/task-worker/src/webhook/webhook.controller.ts` + +TaskCast sends its own task ID in the webhook payload, not Team9's task ID. Update lookup: + +```diff +- const [task] = await this.db.select().from(schema.agentTasks) +- .where(eq(schema.agentTasks.id, taskId)); ++ const { taskId: taskcastId } = payload; ++ const [execution] = await this.db.select().from(schema.agentTaskExecutions) ++ .where(eq(schema.agentTaskExecutions.taskcastTaskId, taskcastId)); ++ if (!execution) { ++ this.logger.error(`Execution not found for TaskCast task: ${taskcastId}`); ++ return; ++ } ++ const [task] = await this.db.select().from(schema.agentTasks) ++ .where(eq(schema.agentTasks.id, execution.taskId)); +``` + +### 9. Legacy `taskcastTaskId` Cleanup + +Existing executions may have UUID placeholder values (format `tc_${executionId}` or raw UUIDs) as `taskcastTaskId`. These are not real TaskCast task IDs and would cause API errors. + +**Solution:** The `if (execution.taskcastTaskId)` guard already protects against `null`. For non-null placeholder values, the TaskCast API call will fail, but since all calls are wrapped with internal error handling, this is safe — it will log a warning and the frontend falls back to polling. No migration needed. New executions will have real TaskCast IDs (ULID format `01KK...`), which are easily distinguishable from UUIDs. + +--- + +## Frontend Design + +### 1. Install Package + +```bash +pnpm --filter @team9/client add @taskcast/client +``` + +We use `@taskcast/client` directly (not `@taskcast/react`) because our SSE goes through a gateway proxy with a custom URL structure that doesn't match `useTaskEvents`'s expected format. + +### 2. Custom SSE Hook + +**New file:** `apps/client/src/hooks/useExecutionStream.ts` + +```typescript +import { useEffect, useCallback } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +function useExecutionStream( + taskId: string, + execId: string, + taskcastTaskId: string | null, + enabled: boolean, +) { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!enabled || !taskcastTaskId) return; + + const url = `${API_BASE_URL}/api/v1/tasks/${taskId}/executions/${execId}/stream`; + const eventSource = new EventSource(url, { withCredentials: true }); + // Or pass JWT via URL param if EventSource doesn't support headers + + eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + // Invalidate relevant query cache based on event type + if ( + data.type === "step" || + data.type === "intervention" || + data.type === "deliverable" + ) { + queryClient.invalidateQueries({ + queryKey: ["task-execution-entries", taskId, execId], + }); + } + }; + + eventSource.onerror = () => { + // EventSource auto-reconnects; no action needed + }; + + return () => eventSource.close(); + }, [taskId, execId, taskcastTaskId, enabled, queryClient]); +} +``` + +**Auth consideration:** `EventSource` does not support custom headers. Options: + +- Pass JWT as query param: `?token=${jwt}` (proxy endpoint accepts both header and query) +- Use cookie-based auth if available +- Use `fetch()` with `ReadableStream` instead of `EventSource` for header support + +### 3. React Query Cache Integration + +On receiving SSE events, invalidate relevant queries rather than manually updating cache (simpler, avoids data transformation): + +| SSE Event Type | Cache Invalidation | +| -------------------- | --------------------------------------------------------------------------------------- | +| `step` | `invalidateQueries(['task-execution-entries', taskId, execId])` | +| `intervention` | `invalidateQueries(['task-execution-entries', taskId, execId])` | +| `deliverable` | `invalidateQueries(['task-execution-entries', taskId, execId])` | +| SSE close (terminal) | `invalidateQueries(['task', taskId])`, `invalidateQueries(['task-executions', taskId])` | + +This triggers a refetch of the full entries list from the REST API, which returns properly formatted `ExecutionEntry[]` objects. No SSE-to-ExecutionEntry transformation needed. + +### 4. Remove Polling + +| Component | Current | After Integration | +| ---------------------- | ---------------------------------------------- | --------------------------------------------------------------------------------- | +| `TaskDetailPanel.tsx` | `refetchInterval: 5000` on `["task", taskId]` | Keep interval only when no active SSE | +| `TaskBasicInfoTab.tsx` | `refetchInterval: 5000` on entries | SSE invalidation replaces polling; disable interval when `taskcastTaskId` present | +| `TaskRunsTab.tsx` | `refetchInterval: 5000` on executions | WebSocket `task:execution_created` + SSE terminal event invalidates | +| `RunDetailView.tsx` | `refetchInterval: 5000` on execution + entries | SSE invalidation replaces polling | + +**Fallback:** If `taskcastTaskId` is null (legacy executions or TaskCast failure), keep 5s polling. + +--- + +## Event Schema + +Events published to TaskCast: + +### `step` (series: `steps`, mode: `latest`) + +```json +{ + "type": "step", + "level": "info", + "seriesId": "steps", + "seriesMode": "latest", + "data": { + "steps": [ + { + "orderIndex": 0, + "title": "Analyzing requirements", + "status": "completed", + "duration": 12 + }, + { "orderIndex": 1, "title": "Writing code", "status": "in_progress" } + ] + } +} +``` + +### `intervention` (series: `intervention:{id}`, mode: `latest`) + +```json +{ + "type": "intervention", + "level": "warn", + "seriesId": "intervention:abc123", + "seriesMode": "latest", + "data": { + "intervention": { + "id": "abc123", + "prompt": "Which database should I use?", + "actions": ["PostgreSQL", "MySQL", "SQLite"], + "status": "pending" + } + } +} +``` + +### `deliverable` + +```json +{ + "type": "deliverable", + "level": "info", + "data": { + "deliverable": { + "id": "def456", + "fileName": "report.pdf", + "fileSize": 102400, + "mimeType": "application/pdf", + "fileUrl": "https://..." + } + } +} +``` + +--- + +## TTL & Task Lifecycle + +- Default TTL: 86400 seconds (24 hours) +- TaskCast automatically transitions to `timeout` when TTL expires +- TaskCast sends timeout webhook → `WebhookController` marks execution + task as `timeout` +- For tasks expected to run >24h, the TTL can be configured per-task at creation time +- When a Team9 task is deleted while an execution is running, `TasksService` should transition the TaskCast task to `cancelled` before deleting + +--- + +## Deployment & Configuration + +### Environment Variables + +Already configured on Railway dev: + +| Service | Variable | Value | +| ----------- | ----------------------- | ------------------------------------------------------------------ | +| TaskCast | `TASKCAST_PORT` | `3721` | +| TaskCast | `TASKCAST_STORAGE` | `redis` | +| TaskCast | `TASKCAST_REDIS_URL` | `redis://:***@redis.railway.internal:6379` | +| TaskCast | `TASKCAST_POSTGRES_URL` | `postgresql://***@postgres-taskcast.railway.internal:5432/railway` | +| TaskCast | `TASKCAST_AUTH_MODE` | `none` | +| API-Gateway | `TASKCAST_URL` | `http://taskcast.railway.internal:3721` | +| Task-worker | `TASKCAST_URL` | `http://taskcast.railway.internal:3721` | + +### Cleanup + +- Remove old `TaskcastPostgres` empty service from Railway +- Remove old `Task-tracker` service from Railway From b2bd6964edd9b731165c9c70b3dafb2f6e75debf Mon Sep 17 00:00:00 2001 From: Winrey Date: Fri, 13 Mar 2026 18:14:04 +0800 Subject: [PATCH 02/68] chore: remove remaining task-tracker references from Dockerfiles Co-Authored-By: Claude Opus 4.6 --- docker/client.Dockerfile | 1 - docker/gateway.Dockerfile | 1 - docker/im-worker.Dockerfile | 1 - 3 files changed, 3 deletions(-) diff --git a/docker/client.Dockerfile b/docker/client.Dockerfile index 04235e20..a16d46bd 100644 --- a/docker/client.Dockerfile +++ b/docker/client.Dockerfile @@ -14,7 +14,6 @@ COPY apps/client/package.json ./apps/client/ COPY apps/server/package.json ./apps/server/ COPY apps/server/apps/gateway/package.json ./apps/server/apps/gateway/ COPY apps/server/apps/im-worker/package.json ./apps/server/apps/im-worker/ -COPY apps/server/apps/task-tracker/package.json ./apps/server/apps/task-tracker/ COPY apps/server/libs/auth/package.json ./apps/server/libs/auth/ COPY apps/server/libs/database/package.json ./apps/server/libs/database/ COPY apps/server/libs/shared/package.json ./apps/server/libs/shared/ diff --git a/docker/gateway.Dockerfile b/docker/gateway.Dockerfile index 3ef7a000..5627ccf3 100644 --- a/docker/gateway.Dockerfile +++ b/docker/gateway.Dockerfile @@ -32,7 +32,6 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./ COPY apps/server/package.json ./apps/server/ COPY apps/server/apps/gateway/package.json ./apps/server/apps/gateway/ COPY apps/server/apps/im-worker/package.json ./apps/server/apps/im-worker/ -COPY apps/server/apps/task-tracker/package.json ./apps/server/apps/task-tracker/ COPY apps/server/libs/auth/package.json ./apps/server/libs/auth/ COPY apps/server/libs/database/package.json ./apps/server/libs/database/ COPY apps/server/libs/shared/package.json ./apps/server/libs/shared/ diff --git a/docker/im-worker.Dockerfile b/docker/im-worker.Dockerfile index 1448c0be..b6471997 100644 --- a/docker/im-worker.Dockerfile +++ b/docker/im-worker.Dockerfile @@ -21,7 +21,6 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./ COPY apps/server/package.json ./apps/server/ COPY apps/server/apps/gateway/package.json ./apps/server/apps/gateway/ COPY apps/server/apps/im-worker/package.json ./apps/server/apps/im-worker/ -COPY apps/server/apps/task-tracker/package.json ./apps/server/apps/task-tracker/ COPY apps/server/libs/auth/package.json ./apps/server/libs/auth/ COPY apps/server/libs/database/package.json ./apps/server/libs/database/ COPY apps/server/libs/shared/package.json ./apps/server/libs/shared/ From b82af74b0d992836317987eeda836cf48e728792 Mon Sep 17 00:00:00 2001 From: Winrey Date: Fri, 13 Mar 2026 18:46:08 +0800 Subject: [PATCH 03/68] chore: remove task-tracker microservice Remove the standalone task-tracker service (port 3002) which was never integrated into the gateway or frontend. TaskCast replaces it as the real-time task tracking infrastructure. - Delete apps/server/apps/task-tracker/ entirely - Delete database schema (tracker/tasks.ts) - Remove tracker export from schemas/index.ts - Remove TASK_TRACKER_PORT from env.ts - Remove task-tracker COPY from all Dockerfiles - Update pnpm-lock.yaml Co-Authored-By: Claude Opus 4.6 --- apps/server/apps/task-tracker/CLAUDE.md | 190 ------ apps/server/apps/task-tracker/nest-cli.json | 8 - apps/server/apps/task-tracker/package.json | 32 - .../apps/task-tracker/src/app.module.ts | 28 - apps/server/apps/task-tracker/src/load-env.ts | 9 - apps/server/apps/task-tracker/src/main.ts | 25 - .../src/shared/constants/index.ts | 1 - .../src/shared/constants/redis-keys.ts | 27 - .../task-tracker/src/shared/types/index.ts | 1 - .../task-tracker/src/shared/types/progress.ts | 31 - .../apps/task-tracker/src/sse/sse.service.ts | 137 ---- .../src/task/dto/claim-task.dto.ts | 21 - .../apps/task-tracker/src/task/dto/index.ts | 6 - .../src/task/dto/register-task.dto.ts | 39 -- .../src/task/dto/release-task.dto.ts | 18 - .../src/task/dto/retry-task.dto.ts | 9 - .../src/task/dto/update-progress.dto.ts | 18 - .../src/task/dto/update-status.dto.ts | 60 -- .../task-tracker/src/task/task.controller.ts | 261 -------- .../apps/task-tracker/src/task/task.module.ts | 11 - .../task-tracker/src/task/task.service.ts | 587 ------------------ apps/server/apps/task-tracker/tsconfig.json | 14 - .../server/libs/database/src/schemas/index.ts | 1 - .../database/src/schemas/tracker/index.ts | 1 - .../database/src/schemas/tracker/tasks.ts | 100 --- apps/server/libs/shared/src/env.ts | 5 - docker/task-tracker.Dockerfile | 79 --- docker/task-worker.Dockerfile | 1 - pnpm-lock.yaml | 40 -- 29 files changed, 1760 deletions(-) delete mode 100644 apps/server/apps/task-tracker/CLAUDE.md delete mode 100644 apps/server/apps/task-tracker/nest-cli.json delete mode 100644 apps/server/apps/task-tracker/package.json delete mode 100644 apps/server/apps/task-tracker/src/app.module.ts delete mode 100644 apps/server/apps/task-tracker/src/load-env.ts delete mode 100644 apps/server/apps/task-tracker/src/main.ts delete mode 100644 apps/server/apps/task-tracker/src/shared/constants/index.ts delete mode 100644 apps/server/apps/task-tracker/src/shared/constants/redis-keys.ts delete mode 100644 apps/server/apps/task-tracker/src/shared/types/index.ts delete mode 100644 apps/server/apps/task-tracker/src/shared/types/progress.ts delete mode 100644 apps/server/apps/task-tracker/src/sse/sse.service.ts delete mode 100644 apps/server/apps/task-tracker/src/task/dto/claim-task.dto.ts delete mode 100644 apps/server/apps/task-tracker/src/task/dto/index.ts delete mode 100644 apps/server/apps/task-tracker/src/task/dto/register-task.dto.ts delete mode 100644 apps/server/apps/task-tracker/src/task/dto/release-task.dto.ts delete mode 100644 apps/server/apps/task-tracker/src/task/dto/retry-task.dto.ts delete mode 100644 apps/server/apps/task-tracker/src/task/dto/update-progress.dto.ts delete mode 100644 apps/server/apps/task-tracker/src/task/dto/update-status.dto.ts delete mode 100644 apps/server/apps/task-tracker/src/task/task.controller.ts delete mode 100644 apps/server/apps/task-tracker/src/task/task.module.ts delete mode 100644 apps/server/apps/task-tracker/src/task/task.service.ts delete mode 100644 apps/server/apps/task-tracker/tsconfig.json delete mode 100644 apps/server/libs/database/src/schemas/tracker/index.ts delete mode 100644 apps/server/libs/database/src/schemas/tracker/tasks.ts delete mode 100644 docker/task-tracker.Dockerfile diff --git a/apps/server/apps/task-tracker/CLAUDE.md b/apps/server/apps/task-tracker/CLAUDE.md deleted file mode 100644 index ea240fa9..00000000 --- a/apps/server/apps/task-tracker/CLAUDE.md +++ /dev/null @@ -1,190 +0,0 @@ -# Task Tracker Microservice - -A REST microservice for tracking long-running task execution, progress updates, and worker assignment. - -## Overview - -Task Tracker provides: - -- Task registration and lifecycle management -- Real-time progress tracking via SSE (Server-Sent Events) -- Worker-based task claiming and release -- Automatic timeout detection -- Task retry with history preservation - -## Architecture - -``` -task-tracker/ -├── src/ -│ ├── main.ts # Application bootstrap (port 3002) -│ ├── app.module.ts # Root module with DatabaseModule, RedisModule -│ ├── load-env.ts # Environment variable loading -│ ├── task/ # Core task management -│ │ ├── task.module.ts -│ │ ├── task.service.ts # Business logic for all 9 APIs -│ │ ├── task.controller.ts -│ │ └── dto/ # Request/Response DTOs -│ ├── sse/ # SSE broadcasting service -│ │ └── sse.service.ts -│ └── shared/ -│ ├── constants/ # Redis key generators -│ └── types/ # Progress and SSE types -``` - -## Database Schema - -Table: `tracker_tasks` (defined in `libs/database/src/schemas/tracker/tasks.ts`) - -Key fields: - -- `id`: Task identifier (CUID or custom) -- `taskType`: Worker filter key -- `status`: pending | in_progress | completed | failed | timeout -- `metadata`: Descriptive info about the task (JSONB) -- `params`: Execution parameters passed to worker (JSONB) -- `result`/`error`: Final output (JSONB) -- `progressHistory`: Persisted progress array (JSONB) -- `workerId`: Currently assigned worker -- `timeoutAt`: Automatic timeout timestamp -- `originalTaskId`: For retry tracking - -## Redis Keys - -All keys use namespace prefix: `team9:tracker:` - -- `team9:tracker:progress:{taskId}` - Progress history array (list) -- `team9:tracker:seq:{taskId}` - Sequence counter for progress updates - -## API Endpoints - -All endpoints are prefixed with `/api/v1/tasks` - -### 1. Register Task - -``` -POST /api/v1/tasks -Body: { taskId?, taskType, metadata?, params?, timeoutSeconds? } -Response: { taskId, status, createdAt, timeoutAt } -``` - -### 2. Update Task Status - -``` -POST /api/v1/tasks/:taskId/start # Set to in_progress -POST /api/v1/tasks/:taskId/complete # Finish with result -POST /api/v1/tasks/:taskId/fail # Finish with error -POST /api/v1/tasks/:taskId/timeout # Manual timeout -``` - -### 3. Get Task Status - -``` -GET /api/v1/tasks/:taskId -Response: Task object with all fields -``` - -### 4. Update Progress - -``` -POST /api/v1/tasks/:taskId/progress -Body: { progress: { ...data } } -Response: { taskId, seqId, timestamp } -``` - -### 5. Track Progress (SSE) - -``` -GET /api/v1/tasks/:taskId/track?afterSeqId=5&ignoreHistory=true -Query params: - - afterSeqId: Only send progress entries with seqId > this value - - ignoreHistory: If 'true', skip all history (only new updates for active tasks) - -Response: SSE stream with events: - - progress: { event: 'progress', data: { seqId, ...progressData }, taskId, timestamp } - - status_change: { event: 'status_change', data: { status, result?, error? }, taskId, timestamp } - -Behavior: - - Streams progress entries individually (not batched as history) - - For completed/failed/timeout: streams all progress then status_change then closes - - For pending/in_progress: streams history (filtered) then live updates -``` - -### 6. Process Timeouts - -``` -POST /api/v1/tasks/timeouts/process -Response: { processedCount } -``` - -### 7. Claim Task - -``` -POST /api/v1/tasks/claim -Body: { taskTypes: string[], workerId } -Response: Task | null -``` - -### 8. Release Task - -``` -POST /api/v1/tasks/:taskId/release -Body: { workerId } -Response: { taskId, status, message } -``` - -### 9. Retry Task - -``` -POST /api/v1/tasks/:taskId/retry -Response: { newTaskId, originalTaskId, status, retryCount } -``` - -## Task Lifecycle - -``` -┌─────────┐ claim/start ┌─────────────┐ -│ pending │ ──────────────────► │ in_progress │ -└─────────┘ └─────────────┘ - ▲ │ - │ release │ - │ ┌───────────┼───────────┐ - │ ▼ ▼ ▼ - │ ┌──────────┐ ┌────────┐ ┌─────────┐ - └───────────── │ completed│ │ failed │ │ timeout │ - └──────────┘ └────────┘ └─────────┘ - │ - │ retry - ▼ - ┌─────────┐ - │ pending │ (new task) - └─────────┘ -``` - -## Progress Tracking Flow - -1. Worker updates progress: `POST /tasks/:id/progress` -2. Service increments seqId in Redis -3. Progress entry stored in Redis list -4. SSE subscribers receive real-time update -5. On task completion: Redis data persisted to PostgreSQL, Redis cleaned up - -## Running the Service - -```bash -# Development -pnpm --filter @team9/task-tracker start:dev - -# Production -pnpm --filter @team9/task-tracker build -pnpm --filter @team9/task-tracker start:prod -``` - -Default port: `3002` (configurable via `TASK_TRACKER_PORT` env var) - -## Dependencies - -- `@team9/database`: PostgreSQL via Drizzle ORM -- `@team9/redis`: Redis for progress caching -- `@team9/shared`: Shared env configuration -- `@paralleldrive/cuid2`: ID generation diff --git a/apps/server/apps/task-tracker/nest-cli.json b/apps/server/apps/task-tracker/nest-cli.json deleted file mode 100644 index f9aa683b..00000000 --- a/apps/server/apps/task-tracker/nest-cli.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "https://json.schemastore.org/nest-cli", - "collection": "@nestjs/schematics", - "sourceRoot": "src", - "compilerOptions": { - "deleteOutDir": true - } -} diff --git a/apps/server/apps/task-tracker/package.json b/apps/server/apps/task-tracker/package.json deleted file mode 100644 index 035abfe9..00000000 --- a/apps/server/apps/task-tracker/package.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "@team9/task-tracker", - "version": "0.0.1", - "type": "module", - "private": true, - "scripts": { - "build": "tsc", - "start": "node dist/main.js", - "start:dev": "node --loader @swc-node/register/esm --watch src/main.ts", - "start:debug": "node --inspect --loader @swc-node/register/esm src/main.ts", - "start:prod": "node dist/main.js", - "test": "jest", - "test:watch": "jest --watch", - "test:cov": "jest --coverage" - }, - "dependencies": { - "@nestjs/common": "^11.0.1", - "@nestjs/config": "^4.0.2", - "@nestjs/core": "^11.0.1", - "@nestjs/platform-express": "^11.0.1", - "@team9/database": "workspace:*", - "@team9/redis": "workspace:*", - "@team9/shared": "workspace:*", - "@paralleldrive/cuid2": "^2.2.2", - "dotenv": "^17.2.3", - "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.1" - }, - "devDependencies": { - "typescript": "^5.7.3" - } -} diff --git a/apps/server/apps/task-tracker/src/app.module.ts b/apps/server/apps/task-tracker/src/app.module.ts deleted file mode 100644 index 0ae8fbf5..00000000 --- a/apps/server/apps/task-tracker/src/app.module.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { Module, Logger, OnModuleInit } from '@nestjs/common'; -import { ConfigModule } from '@nestjs/config'; -import { join } from 'path'; -import { DatabaseModule } from '@team9/database'; -import { RedisModule } from '@team9/redis'; -import { TaskModule } from './task/task.module.js'; - -@Module({ - imports: [ - ConfigModule.forRoot({ - isGlobal: true, - envFilePath: [ - join(process.cwd(), '.env.local'), - join(process.cwd(), '.env'), - ], - }), - DatabaseModule, - RedisModule, - TaskModule, - ], -}) -export class AppModule implements OnModuleInit { - private readonly logger = new Logger(AppModule.name); - - onModuleInit() { - this.logger.log('Task Tracker service initialized'); - } -} diff --git a/apps/server/apps/task-tracker/src/load-env.ts b/apps/server/apps/task-tracker/src/load-env.ts deleted file mode 100644 index 5c2a9672..00000000 --- a/apps/server/apps/task-tracker/src/load-env.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { config } from 'dotenv'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// Load .env file from apps/server directory -config({ path: join(__dirname, '..', '..', '..', '.env') }); diff --git a/apps/server/apps/task-tracker/src/main.ts b/apps/server/apps/task-tracker/src/main.ts deleted file mode 100644 index 6723f921..00000000 --- a/apps/server/apps/task-tracker/src/main.ts +++ /dev/null @@ -1,25 +0,0 @@ -import './load-env.js'; -import { NestFactory } from '@nestjs/core'; -import { VersioningType, Logger } from '@nestjs/common'; -import { env } from '@team9/shared'; -import { AppModule } from './app.module.js'; - -async function bootstrap() { - const logger = new Logger('TaskTracker'); - const app = await NestFactory.create(AppModule); - - app.enableCors(); - app.setGlobalPrefix('api'); - app.enableVersioning({ - type: VersioningType.URI, - defaultVersion: '1', - }); - - const port = - env.TASK_TRACKER_PORT || parseInt(process.env.PORT ?? '3000', 10); - await app.listen(port); - - logger.log(`Task Tracker service running on port ${port}`); -} - -void bootstrap(); diff --git a/apps/server/apps/task-tracker/src/shared/constants/index.ts b/apps/server/apps/task-tracker/src/shared/constants/index.ts deleted file mode 100644 index 19be44ae..00000000 --- a/apps/server/apps/task-tracker/src/shared/constants/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './redis-keys.js'; diff --git a/apps/server/apps/task-tracker/src/shared/constants/redis-keys.ts b/apps/server/apps/task-tracker/src/shared/constants/redis-keys.ts deleted file mode 100644 index 14fe4ecc..00000000 --- a/apps/server/apps/task-tracker/src/shared/constants/redis-keys.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Redis key prefix for task tracker namespace isolation - */ -export const TRACKER_PREFIX = 'team9:tracker:'; - -/** - * Redis key generators for task tracker - */ -export const RedisKeys = { - /** - * Progress history array for a task (stored as JSON array in Redis) - * @param taskId - Task identifier - */ - taskProgress: (taskId: string) => `${TRACKER_PREFIX}progress:${taskId}`, - - /** - * Sequence counter for progress updates within a task - * @param taskId - Task identifier - */ - taskSeqId: (taskId: string) => `${TRACKER_PREFIX}seq:${taskId}`, - - /** - * Set of active SSE subscribers for a task - * @param taskId - Task identifier - */ - taskSubscribers: (taskId: string) => `${TRACKER_PREFIX}subs:${taskId}`, -} as const; diff --git a/apps/server/apps/task-tracker/src/shared/types/index.ts b/apps/server/apps/task-tracker/src/shared/types/index.ts deleted file mode 100644 index 0cbb3989..00000000 --- a/apps/server/apps/task-tracker/src/shared/types/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './progress.js'; diff --git a/apps/server/apps/task-tracker/src/shared/types/progress.ts b/apps/server/apps/task-tracker/src/shared/types/progress.ts deleted file mode 100644 index 74e9c6e2..00000000 --- a/apps/server/apps/task-tracker/src/shared/types/progress.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Progress update entry with sequence ID for ordering - */ -export interface ProgressEntry { - seqId: number; - [key: string]: unknown; -} - -/** - * SSE event types for task tracking - */ -export enum SseEventType { - /** Individual progress update */ - PROGRESS = 'progress', - /** Task status changed (completed/failed/timeout) */ - STATUS_CHANGE = 'status_change', - /** Task completed successfully */ - COMPLETE = 'complete', - /** Error occurred */ - ERROR = 'error', -} - -/** - * SSE message payload structure - */ -export interface SseMessage { - event: SseEventType; - data: unknown; - taskId: string; - timestamp: string; -} diff --git a/apps/server/apps/task-tracker/src/sse/sse.service.ts b/apps/server/apps/task-tracker/src/sse/sse.service.ts deleted file mode 100644 index a1c04c5a..00000000 --- a/apps/server/apps/task-tracker/src/sse/sse.service.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { Subject } from 'rxjs'; -import type { MessageEvent } from '@nestjs/common'; -import { SseEventType, type SseMessage } from '../shared/types/index.js'; - -/** - * SSE (Server-Sent Events) service for managing task progress subscriptions. - * Maintains in-memory subjects for broadcasting progress updates to connected clients. - */ -@Injectable() -export class SseService { - private readonly logger = new Logger(SseService.name); - - /** - * Map of taskId -> Subject for broadcasting events to subscribers - */ - private readonly taskSubjects = new Map>(); - - /** - * Map of taskId -> subscriber count for cleanup tracking - */ - private readonly subscriberCounts = new Map(); - - /** - * Get or create a subject for a task - */ - getTaskSubject(taskId: string): Subject { - let subject = this.taskSubjects.get(taskId); - if (!subject) { - subject = new Subject(); - this.taskSubjects.set(taskId, subject); - this.subscriberCounts.set(taskId, 0); - this.logger.debug(`Created subject for task ${taskId}`); - } - return subject; - } - - /** - * Register a new subscriber for a task - */ - addSubscriber(taskId: string): void { - const count = this.subscriberCounts.get(taskId) ?? 0; - this.subscriberCounts.set(taskId, count + 1); - this.logger.debug( - `Added subscriber for task ${taskId}, total: ${count + 1}`, - ); - } - - /** - * Unregister a subscriber for a task - */ - removeSubscriber(taskId: string): void { - const count = this.subscriberCounts.get(taskId) ?? 0; - if (count > 0) { - this.subscriberCounts.set(taskId, count - 1); - this.logger.debug( - `Removed subscriber for task ${taskId}, remaining: ${count - 1}`, - ); - - // Cleanup subject if no more subscribers - if (count - 1 === 0) { - this.cleanupTask(taskId); - } - } - } - - /** - * Broadcast a progress update to all subscribers of a task - */ - broadcastProgress(taskId: string, progress: Record): void { - const subject = this.taskSubjects.get(taskId); - if (subject) { - const message: SseMessage = { - event: SseEventType.PROGRESS, - data: progress, - taskId, - timestamp: new Date().toISOString(), - }; - subject.next({ data: message } as MessageEvent); - this.logger.debug(`Broadcast progress for task ${taskId}`); - } - } - - /** - * Broadcast a status change to all subscribers and complete the stream - */ - broadcastStatusChange( - taskId: string, - status: string, - result?: Record, - error?: Record, - ): void { - const subject = this.taskSubjects.get(taskId); - if (subject) { - const message: SseMessage = { - event: SseEventType.STATUS_CHANGE, - data: { status, result, error }, - taskId, - timestamp: new Date().toISOString(), - }; - subject.next({ data: message } as MessageEvent); - this.logger.debug( - `Broadcast status change for task ${taskId}: ${status}`, - ); - - // Complete the stream for terminal states - if ( - status === 'completed' || - status === 'failed' || - status === 'timeout' - ) { - subject.complete(); - this.cleanupTask(taskId); - } - } - } - - /** - * Check if a task has active subscribers - */ - hasSubscribers(taskId: string): boolean { - return (this.subscriberCounts.get(taskId) ?? 0) > 0; - } - - /** - * Cleanup task subject and subscriber count - */ - private cleanupTask(taskId: string): void { - const subject = this.taskSubjects.get(taskId); - if (subject && !subject.closed) { - subject.complete(); - } - this.taskSubjects.delete(taskId); - this.subscriberCounts.delete(taskId); - this.logger.debug(`Cleaned up subject for task ${taskId}`); - } -} diff --git a/apps/server/apps/task-tracker/src/task/dto/claim-task.dto.ts b/apps/server/apps/task-tracker/src/task/dto/claim-task.dto.ts deleted file mode 100644 index d9d1c9de..00000000 --- a/apps/server/apps/task-tracker/src/task/dto/claim-task.dto.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Task } from '@team9/database'; - -/** - * DTO for claiming a task (API 7: Claim Task) - */ -export class ClaimTaskDto { - /** - * Task types this worker can handle - */ - taskTypes!: string[]; - - /** - * Worker ID claiming the task - */ - workerId!: string; -} - -/** - * Response for task claim - returns the claimed task or null - */ -export type ClaimTaskResponse = Task | null; diff --git a/apps/server/apps/task-tracker/src/task/dto/index.ts b/apps/server/apps/task-tracker/src/task/dto/index.ts deleted file mode 100644 index 4a52f723..00000000 --- a/apps/server/apps/task-tracker/src/task/dto/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -export * from './register-task.dto.js'; -export * from './update-status.dto.js'; -export * from './update-progress.dto.js'; -export * from './claim-task.dto.js'; -export * from './release-task.dto.js'; -export * from './retry-task.dto.js'; diff --git a/apps/server/apps/task-tracker/src/task/dto/register-task.dto.ts b/apps/server/apps/task-tracker/src/task/dto/register-task.dto.ts deleted file mode 100644 index 131ce0b9..00000000 --- a/apps/server/apps/task-tracker/src/task/dto/register-task.dto.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * DTO for task registration (API 1: Register Task) - */ -export class RegisterTaskDto { - /** - * Optional task ID. If not provided, auto-generates a CUID. - */ - taskId?: string; - - /** - * Task type for worker filtering (e.g., 'ai_inference', 'video_encode') - */ - taskType!: string; - - /** - * Optional initial metadata for the task (descriptive info) - */ - metadata?: Record; - - /** - * Optional execution parameters passed to worker - */ - params?: Record; - - /** - * Timeout in seconds. Defaults to 86400 (24 hours) - */ - timeoutSeconds?: number; -} - -/** - * Response for task registration - */ -export interface RegisterTaskResponse { - taskId: string; - status: string; - createdAt: string; - timeoutAt: string; -} diff --git a/apps/server/apps/task-tracker/src/task/dto/release-task.dto.ts b/apps/server/apps/task-tracker/src/task/dto/release-task.dto.ts deleted file mode 100644 index a6a54455..00000000 --- a/apps/server/apps/task-tracker/src/task/dto/release-task.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * DTO for releasing a task (API 8: Release Task) - */ -export class ReleaseTaskDto { - /** - * Worker ID releasing the task - must match the current worker - */ - workerId!: string; -} - -/** - * Response for task release - */ -export interface ReleaseTaskResponse { - taskId: string; - status: string; - message: string; -} diff --git a/apps/server/apps/task-tracker/src/task/dto/retry-task.dto.ts b/apps/server/apps/task-tracker/src/task/dto/retry-task.dto.ts deleted file mode 100644 index 99f278df..00000000 --- a/apps/server/apps/task-tracker/src/task/dto/retry-task.dto.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * Response for task retry (API 9: Retry Task) - */ -export interface RetryTaskResponse { - newTaskId: string; - originalTaskId: string; - status: string; - retryCount: number; -} diff --git a/apps/server/apps/task-tracker/src/task/dto/update-progress.dto.ts b/apps/server/apps/task-tracker/src/task/dto/update-progress.dto.ts deleted file mode 100644 index 274cb812..00000000 --- a/apps/server/apps/task-tracker/src/task/dto/update-progress.dto.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * DTO for updating task progress (API 4: Update Progress) - */ -export class UpdateProgressDto { - /** - * Progress update data. seqId will be auto-added if not provided. - */ - progress!: Record; -} - -/** - * Response for progress update - */ -export interface UpdateProgressResponse { - taskId: string; - seqId: number; - timestamp: string; -} diff --git a/apps/server/apps/task-tracker/src/task/dto/update-status.dto.ts b/apps/server/apps/task-tracker/src/task/dto/update-status.dto.ts deleted file mode 100644 index 1131aa33..00000000 --- a/apps/server/apps/task-tracker/src/task/dto/update-status.dto.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { TaskStatus } from '@team9/database'; - -/** - * DTO for updating task status to in_progress (API 2: Claim task manually) - */ -export class StartTaskDto { - /** - * Worker ID claiming this task - */ - workerId!: string; -} - -/** - * DTO for completing a task (API 2: Complete task) - */ -export class CompleteTaskDto { - /** - * Worker ID that completed this task - */ - workerId!: string; - - /** - * Result data from task execution - */ - result!: Record; -} - -/** - * DTO for failing a task (API 2: Fail task) - */ -export class FailTaskDto { - /** - * Worker ID that failed this task - */ - workerId!: string; - - /** - * Error information - */ - error!: Record; -} - -/** - * DTO for manually timing out a task (API 2: Manual timeout) - */ -export class TimeoutTaskDto { - /** - * Worker ID triggering the timeout - */ - workerId?: string; -} - -/** - * Response for status update operations - */ -export interface UpdateStatusResponse { - taskId: string; - status: TaskStatus; - updatedAt: string; -} diff --git a/apps/server/apps/task-tracker/src/task/task.controller.ts b/apps/server/apps/task-tracker/src/task/task.controller.ts deleted file mode 100644 index 8f61e7c9..00000000 --- a/apps/server/apps/task-tracker/src/task/task.controller.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { - Controller, - Get, - Post, - Param, - Body, - Query, - Sse, - Logger, - MessageEvent, -} from '@nestjs/common'; -import { Observable, Subject, from, switchMap, finalize } from 'rxjs'; -import { TaskService } from './task.service.js'; -import { SseService } from '../sse/sse.service.js'; -import { SseEventType } from '../shared/types/index.js'; -import type { Task } from '@team9/database'; -import type { - RegisterTaskDto, - RegisterTaskResponse, - StartTaskDto, - CompleteTaskDto, - FailTaskDto, - TimeoutTaskDto, - UpdateStatusResponse, - UpdateProgressDto, - UpdateProgressResponse, - ClaimTaskDto, - ReleaseTaskDto, - ReleaseTaskResponse, - RetryTaskResponse, -} from './dto/index.js'; - -@Controller({ path: 'tasks', version: '1' }) -export class TaskController { - private readonly logger = new Logger(TaskController.name); - - constructor( - private readonly taskService: TaskService, - private readonly sseService: SseService, - ) {} - - /** - * API 1: Register a new task - * POST /api/v1/tasks - */ - @Post() - async registerTask( - @Body() dto: RegisterTaskDto, - ): Promise { - return this.taskService.registerTask(dto); - } - - /** - * API 2a: Start task (set status to in_progress) - * POST /api/v1/tasks/:taskId/start - */ - @Post(':taskId/start') - async startTask( - @Param('taskId') taskId: string, - @Body() dto: StartTaskDto, - ): Promise { - return this.taskService.startTask(taskId, dto); - } - - /** - * API 2b: Complete task - * POST /api/v1/tasks/:taskId/complete - */ - @Post(':taskId/complete') - async completeTask( - @Param('taskId') taskId: string, - @Body() dto: CompleteTaskDto, - ): Promise { - return this.taskService.completeTask(taskId, dto); - } - - /** - * API 2c: Fail task - * POST /api/v1/tasks/:taskId/fail - */ - @Post(':taskId/fail') - async failTask( - @Param('taskId') taskId: string, - @Body() dto: FailTaskDto, - ): Promise { - return this.taskService.failTask(taskId, dto); - } - - /** - * API 2d: Manual timeout - * POST /api/v1/tasks/:taskId/timeout - */ - @Post(':taskId/timeout') - async timeoutTask( - @Param('taskId') taskId: string, - @Body() dto: TimeoutTaskDto, - ): Promise { - return this.taskService.timeoutTask(taskId, dto); - } - - /** - * API 3: Get task status - * GET /api/v1/tasks/:taskId - */ - @Get(':taskId') - async getTask(@Param('taskId') taskId: string): Promise { - return this.taskService.getTask(taskId); - } - - /** - * API 4: Update task progress - * POST /api/v1/tasks/:taskId/progress - */ - @Post(':taskId/progress') - async updateProgress( - @Param('taskId') taskId: string, - @Body() dto: UpdateProgressDto, - ): Promise { - return this.taskService.updateProgress(taskId, dto); - } - - /** - * API 5: Track task progress via SSE - * GET /api/v1/tasks/:taskId/track - * - * Query params: - * - afterSeqId: Only send progress entries after this seqId (skip earlier history) - * - ignoreHistory: If true, skip all history and only receive new updates (for active tasks) - * - * Behavior: - * - Streams progress entries as individual PROGRESS events - * - For completed/failed/timeout tasks: streams history then STATUS_CHANGE then closes - * - For pending/in_progress tasks: streams history (if any) then live updates - */ - @Sse(':taskId/track') - trackTask( - @Param('taskId') taskId: string, - @Query('afterSeqId') afterSeqIdParam?: string, - @Query('ignoreHistory') ignoreHistoryParam?: string, - ): Observable { - const afterSeqId = afterSeqIdParam - ? parseInt(afterSeqIdParam, 10) - : undefined; - const ignoreHistory = ignoreHistoryParam === 'true'; - - return from(this.taskService.getTaskForTracking(taskId)).pipe( - switchMap(({ task, progressHistory }) => { - const responseSubject = new Subject(); - const isTerminal = - task.status === 'completed' || - task.status === 'failed' || - task.status === 'timeout'; - - // Filter history based on afterSeqId - let filteredHistory = progressHistory; - if (afterSeqId !== undefined) { - filteredHistory = progressHistory.filter((p) => p.seqId > afterSeqId); - } else if (ignoreHistory) { - filteredHistory = []; - } - - // Stream history as individual PROGRESS events - if (filteredHistory.length > 0) { - this.logger.debug( - `Sending ${filteredHistory.length} history entries for task ${taskId}`, - ); - for (const entry of filteredHistory) { - responseSubject.next({ - data: { - event: SseEventType.PROGRESS, - data: entry, - taskId, - timestamp: new Date().toISOString(), - }, - } as MessageEvent); - } - } - - // For terminal states, send final status and complete - if (isTerminal) { - this.logger.debug( - `Task ${taskId} already finished with status ${task.status}`, - ); - responseSubject.next({ - data: { - event: SseEventType.STATUS_CHANGE, - data: { - status: task.status, - result: task.result, - error: task.error, - }, - taskId, - timestamp: new Date().toISOString(), - }, - } as MessageEvent); - responseSubject.complete(); - return responseSubject.asObservable(); - } - - // For active tasks, subscribe to live updates - this.logger.debug(`Task ${taskId} is active, subscribing to updates`); - const subject = this.sseService.getTaskSubject(taskId); - this.sseService.addSubscriber(taskId); - - // Forward all future updates - const subscription = subject.subscribe({ - next: (event) => responseSubject.next(event), - error: (err) => responseSubject.error(err), - complete: () => responseSubject.complete(), - }); - - return responseSubject.pipe( - finalize(() => { - subscription.unsubscribe(); - this.sseService.removeSubscriber(taskId); - this.logger.debug(`SSE connection closed for task ${taskId}`); - }), - ); - }), - ); - } - - /** - * API 6: Process timeout detection - * POST /api/v1/tasks/timeouts/process - */ - @Post('timeouts/process') - async processTimeouts(): Promise<{ processedCount: number }> { - return this.taskService.processTimeouts(); - } - - /** - * API 7: Claim a task - * POST /api/v1/tasks/claim - */ - @Post('claim') - async claimTask(@Body() dto: ClaimTaskDto): Promise { - return this.taskService.claimTask(dto); - } - - /** - * API 8: Release a task - * POST /api/v1/tasks/:taskId/release - */ - @Post(':taskId/release') - async releaseTask( - @Param('taskId') taskId: string, - @Body() dto: ReleaseTaskDto, - ): Promise { - return this.taskService.releaseTask(taskId, dto.workerId); - } - - /** - * API 9: Retry a task - * POST /api/v1/tasks/:taskId/retry - */ - @Post(':taskId/retry') - async retryTask(@Param('taskId') taskId: string): Promise { - return this.taskService.retryTask(taskId); - } -} diff --git a/apps/server/apps/task-tracker/src/task/task.module.ts b/apps/server/apps/task-tracker/src/task/task.module.ts deleted file mode 100644 index f62b6267..00000000 --- a/apps/server/apps/task-tracker/src/task/task.module.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Module } from '@nestjs/common'; -import { TaskController } from './task.controller.js'; -import { TaskService } from './task.service.js'; -import { SseService } from '../sse/sse.service.js'; - -@Module({ - controllers: [TaskController], - providers: [TaskService, SseService], - exports: [TaskService, SseService], -}) -export class TaskModule {} diff --git a/apps/server/apps/task-tracker/src/task/task.service.ts b/apps/server/apps/task-tracker/src/task/task.service.ts deleted file mode 100644 index 2acaacd3..00000000 --- a/apps/server/apps/task-tracker/src/task/task.service.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { - Injectable, - Inject, - Logger, - NotFoundException, - BadRequestException, - ForbiddenException, -} from '@nestjs/common'; -import { createId } from '@paralleldrive/cuid2'; -import { - DATABASE_CONNECTION, - tasks, - eq, - and, - inArray, - lte, - type Task, - type PostgresJsDatabase, -} from '@team9/database'; -import * as schema from '@team9/database'; -import { RedisService } from '@team9/redis'; -import { SseService } from '../sse/sse.service.js'; -import { RedisKeys } from '../shared/constants/index.js'; -import type { ProgressEntry } from '../shared/types/index.js'; -import type { - RegisterTaskDto, - RegisterTaskResponse, - CompleteTaskDto, - FailTaskDto, - StartTaskDto, - TimeoutTaskDto, - UpdateStatusResponse, - UpdateProgressDto, - UpdateProgressResponse, - ClaimTaskDto, - ReleaseTaskResponse, - RetryTaskResponse, -} from './dto/index.js'; - -@Injectable() -export class TaskService { - private readonly logger = new Logger(TaskService.name); - - constructor( - @Inject(DATABASE_CONNECTION) - private readonly db: PostgresJsDatabase, - private readonly redisService: RedisService, - private readonly sseService: SseService, - ) {} - - /** - * API 1: Register a new task - * Creates a task with pending status and stores in PostgreSQL - */ - async registerTask(dto: RegisterTaskDto): Promise { - const taskId = dto.taskId ?? createId(); - const timeoutSeconds = dto.timeoutSeconds ?? 86400; // Default 24 hours - const now = new Date(); - const timeoutAt = new Date(now.getTime() + timeoutSeconds * 1000); - - const [task] = await this.db - .insert(tasks) - .values({ - id: taskId, - taskType: dto.taskType, - status: 'pending', - metadata: dto.metadata, - params: dto.params, - timeoutSeconds, - timeoutAt, - createdAt: now, - updatedAt: now, - }) - .returning(); - - this.logger.log( - `Registered task ${taskId} of type ${dto.taskType}, timeout at ${timeoutAt.toISOString()}`, - ); - - return { - taskId: task.id, - status: task.status, - createdAt: task.createdAt.toISOString(), - timeoutAt: timeoutAt.toISOString(), - }; - } - - /** - * API 2a: Start task (set to in_progress) - */ - async startTask( - taskId: string, - dto: StartTaskDto, - ): Promise { - const task = await this.getTaskOrThrow(taskId); - - if (task.status !== 'pending') { - throw new BadRequestException( - `Task ${taskId} cannot be started, current status: ${task.status}`, - ); - } - - const now = new Date(); - const [updated] = await this.db - .update(tasks) - .set({ - status: 'in_progress', - workerId: dto.workerId, - startedAt: now, - updatedAt: now, - }) - .where(eq(tasks.id, taskId)) - .returning(); - - this.logger.log(`Task ${taskId} started by worker ${dto.workerId}`); - - return { - taskId: updated.id, - status: updated.status, - updatedAt: updated.updatedAt.toISOString(), - }; - } - - /** - * API 2b: Complete task - * Persists progress history from Redis, stores result, notifies SSE subscribers - */ - async completeTask( - taskId: string, - dto: CompleteTaskDto, - ): Promise { - const task = await this.getTaskOrThrow(taskId); - - if (task.status !== 'in_progress') { - throw new BadRequestException( - `Task ${taskId} cannot be completed, current status: ${task.status}`, - ); - } - - if (task.workerId !== dto.workerId) { - throw new ForbiddenException( - `Worker ${dto.workerId} is not authorized to complete task ${taskId}`, - ); - } - - // Get progress history from Redis - const progressHistory = await this.getProgressFromRedis(taskId); - - const now = new Date(); - const [updated] = await this.db - .update(tasks) - .set({ - status: 'completed', - result: dto.result, - progressHistory, - completedAt: now, - updatedAt: now, - }) - .where(eq(tasks.id, taskId)) - .returning(); - - // Cleanup Redis - await this.cleanupRedis(taskId); - - // Notify SSE subscribers - this.sseService.broadcastStatusChange( - taskId, - 'completed', - dto.result, - undefined, - ); - - this.logger.log(`Task ${taskId} completed by worker ${dto.workerId}`); - - return { - taskId: updated.id, - status: updated.status, - updatedAt: updated.updatedAt.toISOString(), - }; - } - - /** - * API 2c: Fail task - * Persists progress history from Redis, stores error, notifies SSE subscribers - */ - async failTask( - taskId: string, - dto: FailTaskDto, - ): Promise { - const task = await this.getTaskOrThrow(taskId); - - if (task.status !== 'in_progress') { - throw new BadRequestException( - `Task ${taskId} cannot be failed, current status: ${task.status}`, - ); - } - - if (task.workerId !== dto.workerId) { - throw new ForbiddenException( - `Worker ${dto.workerId} is not authorized to fail task ${taskId}`, - ); - } - - // Get progress history from Redis - const progressHistory = await this.getProgressFromRedis(taskId); - - const now = new Date(); - const [updated] = await this.db - .update(tasks) - .set({ - status: 'failed', - error: dto.error, - progressHistory, - completedAt: now, - updatedAt: now, - }) - .where(eq(tasks.id, taskId)) - .returning(); - - // Cleanup Redis - await this.cleanupRedis(taskId); - - // Notify SSE subscribers - this.sseService.broadcastStatusChange( - taskId, - 'failed', - undefined, - dto.error, - ); - - this.logger.log(`Task ${taskId} failed by worker ${dto.workerId}`); - - return { - taskId: updated.id, - status: updated.status, - updatedAt: updated.updatedAt.toISOString(), - }; - } - - /** - * API 2d: Manual timeout - */ - async timeoutTask( - taskId: string, - dto: TimeoutTaskDto, - ): Promise { - const task = await this.getTaskOrThrow(taskId); - - if (task.status === 'completed' || task.status === 'failed') { - throw new BadRequestException( - `Task ${taskId} already finished with status: ${task.status}`, - ); - } - - // Get progress history from Redis - const progressHistory = await this.getProgressFromRedis(taskId); - - const now = new Date(); - const [updated] = await this.db - .update(tasks) - .set({ - status: 'timeout', - progressHistory, - completedAt: now, - updatedAt: now, - }) - .where(eq(tasks.id, taskId)) - .returning(); - - // Cleanup Redis - await this.cleanupRedis(taskId); - - // Notify SSE subscribers - this.sseService.broadcastStatusChange(taskId, 'timeout'); - - this.logger.log(`Task ${taskId} manually timed out`); - - return { - taskId: updated.id, - status: updated.status, - updatedAt: updated.updatedAt.toISOString(), - }; - } - - /** - * API 3: Get task status - */ - async getTask(taskId: string): Promise { - return this.getTaskOrThrow(taskId); - } - - /** - * API 4: Update task progress - * Stores progress in Redis and broadcasts to SSE subscribers - */ - async updateProgress( - taskId: string, - dto: UpdateProgressDto, - ): Promise { - const task = await this.getTaskOrThrow(taskId); - - if (task.status !== 'in_progress') { - throw new BadRequestException( - `Cannot update progress for task ${taskId}, status: ${task.status}`, - ); - } - - // Get next seqId - const seqIdKey = RedisKeys.taskSeqId(taskId); - const seqId = await this.redisService.incr(seqIdKey); - - // Create progress entry - const progressEntry: ProgressEntry = { - seqId, - ...dto.progress, - }; - - // Store in Redis - const progressKey = RedisKeys.taskProgress(taskId); - await this.redisService.rpush(progressKey, JSON.stringify(progressEntry)); - - // Broadcast to SSE subscribers - this.sseService.broadcastProgress(taskId, progressEntry); - - const timestamp = new Date().toISOString(); - this.logger.debug(`Task ${taskId} progress updated, seqId: ${seqId}`); - - return { - taskId, - seqId, - timestamp, - }; - } - - /** - * API 5: Get task for SSE tracking - * Returns task data for SSE controller to use - */ - async getTaskForTracking(taskId: string): Promise<{ - task: Task; - progressHistory: ProgressEntry[]; - }> { - const task = await this.getTaskOrThrow(taskId); - - // For completed/failed/timeout tasks, return persisted history - if ( - task.status === 'completed' || - task.status === 'failed' || - task.status === 'timeout' - ) { - return { - task, - progressHistory: (task.progressHistory ?? []) as ProgressEntry[], - }; - } - - // For pending/in_progress, get current progress from Redis - const progressHistory = await this.getProgressFromRedis(taskId); - - return { - task, - progressHistory, - }; - } - - /** - * API 6: Process timeout detection - * Updates all timed-out tasks and returns count - */ - async processTimeouts(): Promise<{ processedCount: number }> { - const now = new Date(); - - // Find all tasks that have exceeded their timeout - const timedOutTasks = await this.db - .select() - .from(tasks) - .where( - and( - inArray(tasks.status, ['pending', 'in_progress']), - lte(tasks.timeoutAt, now), - ), - ); - - let processedCount = 0; - - for (const task of timedOutTasks) { - try { - // Get progress history from Redis - const progressHistory = await this.getProgressFromRedis(task.id); - - await this.db - .update(tasks) - .set({ - status: 'timeout', - progressHistory, - completedAt: now, - updatedAt: now, - }) - .where(eq(tasks.id, task.id)); - - // Cleanup Redis - await this.cleanupRedis(task.id); - - // Notify SSE subscribers - this.sseService.broadcastStatusChange(task.id, 'timeout'); - - processedCount++; - this.logger.log(`Task ${task.id} auto-timed out`); - } catch (error) { - this.logger.error( - `Failed to process timeout for task ${task.id}:`, - error, - ); - } - } - - return { processedCount }; - } - - /** - * API 7: Claim task - * Atomically claims a pending task for a worker - */ - async claimTask(dto: ClaimTaskDto): Promise { - const now = new Date(); - - // Find and update the first pending task matching the worker's types - // Using a transaction to ensure atomicity - const [claimed] = await this.db - .update(tasks) - .set({ - status: 'in_progress', - workerId: dto.workerId, - startedAt: now, - updatedAt: now, - }) - .where( - and( - inArray(tasks.taskType, dto.taskTypes), - eq(tasks.status, 'pending'), - ), - ) - .returning(); - - if (claimed) { - this.logger.log(`Task ${claimed.id} claimed by worker ${dto.workerId}`); - } - - return claimed ?? null; - } - - /** - * API 8: Release task - * Returns task to pending state if worker matches - */ - async releaseTask( - taskId: string, - workerId: string, - ): Promise { - const task = await this.getTaskOrThrow(taskId); - - if (task.status !== 'in_progress') { - throw new BadRequestException( - `Task ${taskId} cannot be released, current status: ${task.status}`, - ); - } - - if (task.workerId !== workerId) { - throw new ForbiddenException( - `Worker ${workerId} is not authorized to release task ${taskId}`, - ); - } - - // Get and preserve current progress - const progressHistory = await this.getProgressFromRedis(taskId); - - const now = new Date(); - await this.db - .update(tasks) - .set({ - status: 'pending', - workerId: null, - startedAt: null, - updatedAt: now, - // Keep progress history in Redis for next worker - }) - .where(eq(tasks.id, taskId)); - - this.logger.log(`Task ${taskId} released by worker ${workerId}`); - - return { - taskId, - status: 'pending', - message: `Task released, ${progressHistory.length} progress entries preserved`, - }; - } - - /** - * API 9: Retry task - * Creates a new task with same content, tracks original - */ - async retryTask(taskId: string): Promise { - const originalTask = await this.getTaskOrThrow(taskId); - - if (originalTask.status !== 'failed' && originalTask.status !== 'timeout') { - throw new BadRequestException( - `Only failed or timed-out tasks can be retried, current status: ${originalTask.status}`, - ); - } - - // Calculate retry count - const retryCount = originalTask.retryCount + 1; - const newTaskId = createId(); - const now = new Date(); - const timeoutAt = new Date( - now.getTime() + originalTask.timeoutSeconds * 1000, - ); - - const [newTask] = await this.db - .insert(tasks) - .values({ - id: newTaskId, - taskType: originalTask.taskType, - status: 'pending', - metadata: originalTask.metadata, - params: originalTask.params, - timeoutSeconds: originalTask.timeoutSeconds, - timeoutAt, - originalTaskId: originalTask.originalTaskId ?? taskId, - retryCount, - createdAt: now, - updatedAt: now, - }) - .returning(); - - this.logger.log( - `Created retry task ${newTaskId} for original ${taskId}, retry #${retryCount}`, - ); - - return { - newTaskId: newTask.id, - originalTaskId: originalTask.originalTaskId ?? taskId, - status: newTask.status, - retryCount, - }; - } - - /** - * Helper: Get task or throw NotFoundException - */ - private async getTaskOrThrow(taskId: string): Promise { - const [task] = await this.db - .select() - .from(tasks) - .where(eq(tasks.id, taskId)) - .limit(1); - - if (!task) { - throw new NotFoundException(`Task ${taskId} not found`); - } - - return task; - } - - /** - * Helper: Get progress history from Redis - */ - private async getProgressFromRedis(taskId: string): Promise { - const progressKey = RedisKeys.taskProgress(taskId); - const entries = await this.redisService.lrange(progressKey, 0, -1); - - return entries.map((entry) => JSON.parse(entry) as ProgressEntry); - } - - /** - * Helper: Cleanup Redis keys for a task - */ - private async cleanupRedis(taskId: string): Promise { - const progressKey = RedisKeys.taskProgress(taskId); - const seqIdKey = RedisKeys.taskSeqId(taskId); - - await Promise.all([ - this.redisService.del(progressKey), - this.redisService.del(seqIdKey), - ]); - } -} diff --git a/apps/server/apps/task-tracker/tsconfig.json b/apps/server/apps/task-tracker/tsconfig.json deleted file mode 100644 index 378bb0c8..00000000 --- a/apps/server/apps/task-tracker/tsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "outDir": "./dist", - "baseUrl": ".", - "paths": { - "@team9/database": ["../../libs/database/src"], - "@team9/shared": ["../../libs/shared/src"], - "@team9/redis": ["../../libs/redis/src"] - } - }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] -} diff --git a/apps/server/libs/database/src/schemas/index.ts b/apps/server/libs/database/src/schemas/index.ts index ad814090..648274c3 100644 --- a/apps/server/libs/database/src/schemas/index.ts +++ b/apps/server/libs/database/src/schemas/index.ts @@ -1,7 +1,6 @@ export * from './config.js'; export * from './im/index.js'; export * from './tenant/index.js'; -export * from './tracker/index.js'; export * from './document/index.js'; export * from './task/index.js'; export * from './resource/index.js'; diff --git a/apps/server/libs/database/src/schemas/tracker/index.ts b/apps/server/libs/database/src/schemas/tracker/index.ts deleted file mode 100644 index b1b3640d..00000000 --- a/apps/server/libs/database/src/schemas/tracker/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './tasks.js'; diff --git a/apps/server/libs/database/src/schemas/tracker/tasks.ts b/apps/server/libs/database/src/schemas/tracker/tasks.ts deleted file mode 100644 index ad408bcc..00000000 --- a/apps/server/libs/database/src/schemas/tracker/tasks.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - pgTable, - uuid, - varchar, - text, - timestamp, - jsonb, - index, - integer, - pgEnum, -} from 'drizzle-orm/pg-core'; - -/** - * Task status enum for task lifecycle management - * - pending: Task created, waiting for worker to claim - * - in_progress: Task claimed by a worker, being processed - * - completed: Task finished successfully - * - failed: Task finished with error - * - timeout: Task exceeded its timeout limit - */ -export const taskStatusEnum = pgEnum('task_status', [ - 'pending', - 'in_progress', - 'completed', - 'failed', - 'timeout', -]); - -/** - * Task tracker table for storing long-running task information - * Supports task registration, progress tracking, worker assignment, and retry - */ -export const tasks = pgTable( - 'tracker_tasks', - { - // Primary key - can be auto-generated or provided by client - id: varchar('id', { length: 64 }).primaryKey(), - - // Task type for worker filtering (e.g., 'ai_inference', 'video_encode') - taskType: varchar('task_type', { length: 128 }).notNull(), - - // Current task status - status: taskStatusEnum('status').default('pending').notNull(), - - // Initial parameters/metadata provided when task is registered - metadata: jsonb('metadata').$type>(), - - // Task execution parameters (passed to worker) - params: jsonb('params').$type>(), - - // Result data when task completes successfully - result: jsonb('result').$type>(), - - // Error details when task fails - error: jsonb('error').$type>(), - - // Complete progress history (persisted from Redis when task ends) - progressHistory: - jsonb('progress_history').$type< - Array<{ seqId: number; [key: string]: unknown }> - >(), - - // Timeout in seconds (default 24 hours = 86400 seconds) - timeoutSeconds: integer('timeout_seconds').default(86400).notNull(), - - // Worker currently processing this task - workerId: varchar('worker_id', { length: 128 }), - - // Original task ID for retry tracking - originalTaskId: varchar('original_task_id', { length: 64 }), - - // Retry count - retryCount: integer('retry_count').default(0).notNull(), - - // Timestamps - createdAt: timestamp('created_at').defaultNow().notNull(), - updatedAt: timestamp('updated_at').defaultNow().notNull(), - startedAt: timestamp('started_at'), - completedAt: timestamp('completed_at'), - timeoutAt: timestamp('timeout_at'), - }, - (table) => [ - // Index for filtering tasks by type (for worker claiming) - index('idx_tasks_task_type').on(table.taskType), - // Index for filtering by status (pending tasks for claiming) - index('idx_tasks_status').on(table.status), - // Composite index for worker claiming (type + status) - index('idx_tasks_type_status').on(table.taskType, table.status), - // Index for worker lookup - index('idx_tasks_worker_id').on(table.workerId), - // Index for timeout detection - index('idx_tasks_timeout_at').on(table.timeoutAt), - // Index for retry tracking - index('idx_tasks_original_task_id').on(table.originalTaskId), - ], -); - -export type Task = typeof tasks.$inferSelect; -export type NewTask = typeof tasks.$inferInsert; -export type TaskStatus = (typeof taskStatusEnum.enumValues)[number]; diff --git a/apps/server/libs/shared/src/env.ts b/apps/server/libs/shared/src/env.ts index d8ae3b58..9496e278 100644 --- a/apps/server/libs/shared/src/env.ts +++ b/apps/server/libs/shared/src/env.ts @@ -139,11 +139,6 @@ export const env = { return process.env.APP_ENV || process.env.NODE_ENV || 'development'; }, - // Task Tracker Service - get TASK_TRACKER_PORT() { - return parseInt(process.env.TASK_TRACKER_PORT || '3002', 10); - }, - // System Bot Configuration (optional) // If configured, this bot account will be automatically added to all new workspaces get SYSTEM_BOT_EMAIL() { diff --git a/docker/task-tracker.Dockerfile b/docker/task-tracker.Dockerfile deleted file mode 100644 index d0e34116..00000000 --- a/docker/task-tracker.Dockerfile +++ /dev/null @@ -1,79 +0,0 @@ -# ============================================ -# Team9 Task Tracker Service -# ============================================ -# A background service for tracking long-running tasks -# with progress updates and SSE support. -# -# Usage: -# docker build -f docker/task-tracker.Dockerfile -t team9-task-tracker . -# ============================================ - -FROM node:20-alpine AS base -RUN corepack enable && corepack prepare pnpm@10.13.1 --activate -RUN apk add --no-cache libc6-compat - -# ============================================ -# Stage: Dependencies -# ============================================ -FROM base AS deps -WORKDIR /app - -ENV NODE_ENV=development - -# Copy workspace config -COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./ - -# Copy all package.json files for workspace resolution -COPY apps/server/package.json ./apps/server/ -COPY apps/server/apps/gateway/package.json ./apps/server/apps/gateway/ -COPY apps/server/apps/im-worker/package.json ./apps/server/apps/im-worker/ -COPY apps/server/apps/task-tracker/package.json ./apps/server/apps/task-tracker/ -COPY apps/server/libs/auth/package.json ./apps/server/libs/auth/ -COPY apps/server/libs/database/package.json ./apps/server/libs/database/ -COPY apps/server/libs/shared/package.json ./apps/server/libs/shared/ -COPY apps/server/libs/redis/package.json ./apps/server/libs/redis/ -COPY apps/server/libs/rabbitmq/package.json ./apps/server/libs/rabbitmq/ -COPY apps/server/libs/ai-client/package.json ./apps/server/libs/ai-client/ -COPY apps/server/libs/storage/package.json ./apps/server/libs/storage/ -COPY apps/server/libs/email/package.json ./apps/server/libs/email/ - -RUN pnpm install --frozen-lockfile --ignore-scripts - -# ============================================ -# Stage: Builder -# ============================================ -FROM deps AS builder -WORKDIR /app - -# Copy source code -COPY apps/server ./apps/server - -# Reinstall to create symlinks, ignore scripts to avoid husky -RUN pnpm install --frozen-lockfile --ignore-scripts - -# Clean tsbuildinfo and build -RUN find apps/server -name "*.tsbuildinfo" -delete && \ - pnpm --filter '@team9/*' --filter '!@team9/server' build - -# Use pnpm deploy to create a standalone deployment -RUN pnpm --filter @team9/task-tracker deploy --prod /app/deploy - -# ============================================ -# Stage: Runner -# ============================================ -FROM node:20-alpine AS runner -WORKDIR /app - -ENV NODE_ENV=production - -RUN addgroup --system --gid 1001 nodejs && \ - adduser --system --uid 1001 nestjs - -# Copy deployed app (pnpm deploy now includes workspace packages with dist) -COPY --from=builder --chown=nestjs:nodejs /app/deploy ./ - -USER nestjs - -EXPOSE 3002 - -CMD ["node", "dist/main.js"] diff --git a/docker/task-worker.Dockerfile b/docker/task-worker.Dockerfile index 7313d0f2..abbb8303 100644 --- a/docker/task-worker.Dockerfile +++ b/docker/task-worker.Dockerfile @@ -27,7 +27,6 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json .npmrc ./ COPY apps/server/package.json ./apps/server/ COPY apps/server/apps/gateway/package.json ./apps/server/apps/gateway/ COPY apps/server/apps/im-worker/package.json ./apps/server/apps/im-worker/ -COPY apps/server/apps/task-tracker/package.json ./apps/server/apps/task-tracker/ COPY apps/server/apps/task-worker/package.json ./apps/server/apps/task-worker/ COPY apps/server/libs/auth/package.json ./apps/server/libs/auth/ COPY apps/server/libs/database/package.json ./apps/server/libs/database/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df863d6d..77a4c16e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -644,46 +644,6 @@ importers: specifier: ^5.7.3 version: 5.8.3 - apps/server/apps/task-tracker: - dependencies: - "@nestjs/common": - specifier: ^11.0.1 - version: 11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) - "@nestjs/config": - specifier: ^4.0.2 - version: 4.0.2(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) - "@nestjs/core": - specifier: ^11.0.1 - version: 11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/microservices@11.1.11)(@nestjs/platform-express@11.1.10)(@nestjs/websockets@11.1.10)(reflect-metadata@0.2.2)(rxjs@7.8.2) - "@nestjs/platform-express": - specifier: ^11.0.1 - version: 11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10) - "@paralleldrive/cuid2": - specifier: ^2.2.2 - version: 2.3.1 - "@team9/database": - specifier: workspace:* - version: link:../../libs/database - "@team9/redis": - specifier: workspace:* - version: link:../../libs/redis - "@team9/shared": - specifier: workspace:* - version: link:../../libs/shared - dotenv: - specifier: ^17.2.3 - version: 17.2.3 - reflect-metadata: - specifier: ^0.2.2 - version: 0.2.2 - rxjs: - specifier: ^7.8.1 - version: 7.8.2 - devDependencies: - typescript: - specifier: ^5.7.3 - version: 5.8.3 - apps/server/apps/task-worker: dependencies: "@nestjs/common": From aca29e52b90b80f6ba0c64c0d1a14712f92d472a Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 11:45:18 +0800 Subject: [PATCH 04/68] docs: add TaskCast integration implementation plan 14 tasks covering SDK installation, TaskCastService rewrite, task-worker client, TaskBotService/TasksService integration, webhook update, SSE proxy endpoint, and frontend SSE hook with polling replacement. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-13-taskcast-integration.md | 1284 +++++++++++++++++ 1 file changed, 1284 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-13-taskcast-integration.md diff --git a/docs/superpowers/plans/2026-03-13-taskcast-integration.md b/docs/superpowers/plans/2026-03-13-taskcast-integration.md new file mode 100644 index 00000000..d823f096 --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-taskcast-integration.md @@ -0,0 +1,1284 @@ +# TaskCast Integration Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace TODO stubs in TaskCastService with real `@taskcast/server-sdk` calls, publish events during execution, add SSE proxy, and replace frontend polling with SSE. + +**Architecture:** Gateway and task-worker both use `@taskcast/server-sdk` to communicate with a deployed TaskCast Rust instance over internal network. Gateway proxies SSE streams to the frontend. Frontend receives SSE events and invalidates React Query caches instead of 5s polling. + +**Tech Stack:** NestJS, `@taskcast/server-sdk`, `@taskcast/client`, React, TanStack React Query, SSE (EventSource) + +--- + +## Chunk 1: Backend — SDK Installation + TaskCastService Rewrite + Task-Worker Client + +### Task 1: Install `@taskcast/server-sdk` in gateway and task-worker + +**Files:** + +- Modify: `apps/server/apps/gateway/package.json` +- Modify: `apps/server/apps/task-worker/package.json` + +- [ ] **Step 1: Install SDK in gateway** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm --filter @team9/gateway add @taskcast/server-sdk +``` + +Note: `@taskcast/core` is a dependency of `@taskcast/server-sdk` and will be installed automatically. + +- [ ] **Step 2: Install SDK in task-worker** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm --filter @team9/task-worker add @taskcast/server-sdk +``` + +- [ ] **Step 3: Verify installation** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +cat apps/server/apps/gateway/package.json | grep taskcast +cat apps/server/apps/task-worker/package.json | grep taskcast +``` + +Expected: Both show `"@taskcast/server-sdk"` in dependencies. + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/apps/gateway/package.json apps/server/apps/task-worker/package.json pnpm-lock.yaml +git commit -m "chore: add @taskcast/server-sdk to gateway and task-worker" +``` + +--- + +### Task 2: Rewrite TaskCastService (Gateway) + +**Files:** + +- Modify: `apps/server/apps/gateway/src/tasks/taskcast.service.ts` + +Current file is 56 lines with TODO stubs. Replace entirely with real `TaskcastServerClient` wrapper. All methods catch errors internally — callers never need try/catch. + +- [ ] **Step 1: Rewrite taskcast.service.ts** + +Replace the entire contents of `apps/server/apps/gateway/src/tasks/taskcast.service.ts` with: + +```typescript +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { TaskcastServerClient } from "@taskcast/server-sdk"; +import type { TaskStatus } from "@taskcast/core"; + +const STATUS_MAP: Record = { + in_progress: "running", + paused: "paused", + pending_action: "blocked", + completed: "completed", + failed: "failed", + timeout: "timeout", + stopped: "cancelled", +}; + +@Injectable() +export class TaskCastService { + private readonly logger = new Logger(TaskCastService.name); + private readonly client: TaskcastServerClient; + + constructor(config: ConfigService) { + this.client = new TaskcastServerClient({ + baseUrl: config.get("TASKCAST_URL", "http://localhost:3721"), + }); + } + + async createTask(params: { + taskId: string; + executionId: string; + botId: string; + tenantId: string; + ttl?: number; + }): Promise { + try { + const task = await this.client.createTask({ + type: `agent_task.${params.taskId}`, + ttl: params.ttl ?? 86400, + metadata: { + taskId: params.taskId, + executionId: params.executionId, + botId: params.botId, + tenantId: params.tenantId, + }, + }); + return task.id; + } catch (error) { + this.logger.error(`Failed to create TaskCast task: ${error}`); + return null; + } + } + + async transitionStatus( + taskcastTaskId: string, + status: string, + ): Promise { + const mapped = STATUS_MAP[status]; + if (!mapped) { + this.logger.warn(`No TaskCast mapping for status: ${status}`); + return; + } + try { + await this.client.transitionTask(taskcastTaskId, mapped); + } catch (error) { + this.logger.error(`Failed to transition TaskCast status: ${error}`); + } + } + + async publishEvent( + taskcastTaskId: string, + event: { + type: string; + data: Record; + seriesId?: string; + seriesMode?: "accumulate" | "latest" | "keep-all"; + }, + ): Promise { + try { + await this.client.publishEvent(taskcastTaskId, { + type: event.type, + level: "info", + data: event.data, + seriesId: event.seriesId, + seriesMode: event.seriesMode, + }); + } catch (error) { + this.logger.error(`Failed to publish TaskCast event: ${error}`); + } + } + + /** No-op — TaskCast cleanup rules handle expiration via TTL. */ + async deleteTask(_taskcastTaskId: string): Promise {} +} +``` + +- [ ] **Step 2: Verify compilation** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm --filter @team9/gateway build +``` + +Expected: BUILD SUCCESS with no type errors. + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/apps/gateway/src/tasks/taskcast.service.ts +git commit -m "feat(tasks): replace TaskCastService TODO stubs with real @taskcast/server-sdk calls" +``` + +--- + +### Task 3: Create TaskCast client module in task-worker + +**Files:** + +- Create: `apps/server/apps/task-worker/src/taskcast/taskcast.client.ts` +- Create: `apps/server/apps/task-worker/src/taskcast/taskcast.module.ts` +- Modify: `apps/server/apps/task-worker/src/executor/executor.module.ts` +- Modify: `apps/server/apps/task-worker/src/executor/executor.service.ts` +- Modify: `apps/server/apps/task-worker/src/executor/execution-strategy.interface.ts` + +- [ ] **Step 1: Create taskcast.client.ts** + +Create `apps/server/apps/task-worker/src/taskcast/taskcast.client.ts`: + +```typescript +import { Injectable, Logger } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { TaskcastServerClient } from "@taskcast/server-sdk"; + +@Injectable() +export class TaskCastClient { + private readonly logger = new Logger(TaskCastClient.name); + private readonly client: TaskcastServerClient; + + constructor(config: ConfigService) { + this.client = new TaskcastServerClient({ + baseUrl: config.get("TASKCAST_URL", "http://localhost:3721"), + }); + } + + async createTask(params: { + taskId: string; + executionId: string; + botId: string; + tenantId: string; + ttl?: number; + }): Promise { + try { + const task = await this.client.createTask({ + type: `agent_task.${params.taskId}`, + ttl: params.ttl ?? 86400, + metadata: { + taskId: params.taskId, + executionId: params.executionId, + botId: params.botId, + tenantId: params.tenantId, + }, + }); + return task.id; + } catch (error) { + this.logger.error(`Failed to create TaskCast task: ${error}`); + return null; + } + } +} +``` + +- [ ] **Step 2: Create taskcast.module.ts** + +Create `apps/server/apps/task-worker/src/taskcast/taskcast.module.ts`: + +```typescript +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { TaskCastClient } from "./taskcast.client.js"; + +@Module({ + imports: [ConfigModule], + providers: [TaskCastClient], + exports: [TaskCastClient], +}) +export class TaskCastModule {} +``` + +- [ ] **Step 3: Update executor.module.ts to import TaskCastModule** + +In `apps/server/apps/task-worker/src/executor/executor.module.ts`, add `TaskCastModule` to imports: + +```typescript +import { Module, OnModuleInit } from "@nestjs/common"; +import { DatabaseModule } from "@team9/database"; +import { ExecutorService } from "./executor.service.js"; +import { OpenclawStrategy } from "./strategies/openclaw.strategy.js"; +import { TaskCastModule } from "../taskcast/taskcast.module.js"; + +@Module({ + imports: [DatabaseModule, TaskCastModule], + providers: [ExecutorService, OpenclawStrategy], + exports: [ExecutorService], +}) +export class ExecutorModule implements OnModuleInit { + constructor( + private readonly executorService: ExecutorService, + private readonly openclawStrategy: OpenclawStrategy, + ) {} + + onModuleInit() { + this.executorService.registerStrategy("system", this.openclawStrategy); + } +} +``` + +- [ ] **Step 4: Update ExecutionContext interface to allow null taskcastTaskId** + +In `apps/server/apps/task-worker/src/executor/execution-strategy.interface.ts`, change `taskcastTaskId` to allow null: + +```typescript +export interface ExecutionContext { + taskId: string; + executionId: string; + botId: string; + channelId: string; + documentContent?: string; + taskcastTaskId: string | null; +} + +export interface ExecutionStrategy { + execute(context: ExecutionContext): Promise; + pause(context: ExecutionContext): Promise; + resume(context: ExecutionContext): Promise; + stop(context: ExecutionContext): Promise; +} +``` + +- [ ] **Step 5: Update executor.service.ts to inject TaskCastClient and create real TaskCast tasks** + +In `apps/server/apps/task-worker/src/executor/executor.service.ts`: + +1. Add import at top (after existing imports): + +```typescript +import { TaskCastClient } from "../taskcast/taskcast.client.js"; +``` + +2. Update constructor (lines 22-25): + +```typescript + constructor( + @Inject(DATABASE_CONNECTION) + private readonly db: PostgresJsDatabase, + private readonly taskCastClient: TaskCastClient, + ) {} +``` + +3. Replace line 133 (`const taskcastTaskId = uuidv7();`) with: + +```typescript +const taskcastTaskId = await this.taskCastClient.createTask({ + taskId, + executionId, + botId: task.botId, + tenantId: task.tenantId, + ttl: 86400, +}); +``` + +4. Remove the `import { v7 as uuidv7 } from 'uuid';` line (line 2) ONLY IF `uuidv7` is no longer used elsewhere in the file. Check: `uuidv7` is still used at lines 88, 98-99, 132, 178-179 — so keep it. Only the line 133 reference changes; `executionId` at line 132 still uses `uuidv7()`. + +- [ ] **Step 6: Verify compilation** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm --filter @team9/task-worker build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/server/apps/task-worker/src/taskcast/ apps/server/apps/task-worker/src/executor/ +git commit -m "feat(task-worker): create TaskCast client and replace UUID placeholder with real task creation" +``` + +--- + +## Chunk 2: Backend — TaskBotService + TasksService Integration + +### Task 4: Integrate TaskCastService into TaskBotService + +**Files:** + +- Modify: `apps/server/apps/gateway/src/tasks/task-bot.service.ts` + +`TaskCastService` is already a provider in `TasksModule` (see `tasks.module.ts` line 15), so NestJS DI resolves it automatically. + +- [ ] **Step 1: Add TaskCastService to constructor** + +In `apps/server/apps/gateway/src/tasks/task-bot.service.ts`: + +1. Add import after line 22: + +```typescript +import { TaskCastService } from "./taskcast.service.js"; +``` + +2. Update constructor (lines 28-33) to inject TaskCastService: + +```typescript + constructor( + @Inject(DATABASE_CONNECTION) + private readonly db: PostgresJsDatabase, + @Inject(WEBSOCKET_GATEWAY) + private readonly wsGateway: WebsocketGateway, + private readonly taskCastService: TaskCastService, + ) {} +``` + +- [ ] **Step 2: Add TaskCast event publishing to reportSteps()** + +After `return steps;` at line 119, but BEFORE the return statement, add TaskCast event publishing. Replace the end of `reportSteps()` (lines 112-120): + +```typescript +// Return updated steps +const steps = await this.db + .select() + .from(schema.agentTaskSteps) + .where(eq(schema.agentTaskSteps.executionId, execution.id)) + .orderBy(schema.agentTaskSteps.orderIndex); + +// Publish step progress to TaskCast +if (execution.taskcastTaskId) { + await this.taskCastService.publishEvent(execution.taskcastTaskId, { + type: "step", + data: { steps }, + seriesId: "steps", + seriesMode: "latest", + }); +} + +return steps; +``` + +- [ ] **Step 3: Add TaskCast status transition to updateStatus()** + +After the WebSocket broadcast (after line 186), add TaskCast transition: + +```typescript +// Sync terminal status to TaskCast +if (execution.taskcastTaskId) { + await this.taskCastService.transitionStatus(execution.taskcastTaskId, status); +} +``` + +- [ ] **Step 4: Add TaskCast transition + event to createIntervention()** + +After the WebSocket broadcast (after line 236), add TaskCast calls: + +```typescript +// Sync blocked status + intervention event to TaskCast +if (execution.taskcastTaskId) { + await this.taskCastService.transitionStatus( + execution.taskcastTaskId, + "pending_action", + ); + await this.taskCastService.publishEvent(execution.taskcastTaskId, { + type: "intervention", + data: { intervention }, + seriesId: `intervention:${intervention.id}`, + seriesMode: "latest", + }); +} +``` + +- [ ] **Step 5: Add TaskCast event to addDeliverable()** + +After `return deliverable;` at line 270, but BEFORE the return, add: + +```typescript +// Publish deliverable event to TaskCast +if (execution.taskcastTaskId) { + await this.taskCastService.publishEvent(execution.taskcastTaskId, { + type: "deliverable", + data: { deliverable }, + }); +} + +return deliverable; +``` + +(Remove the original `return deliverable;` — it's now at the end of the block above.) + +- [ ] **Step 6: Verify compilation** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm --filter @team9/gateway build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 7: Commit** + +```bash +git add apps/server/apps/gateway/src/tasks/task-bot.service.ts +git commit -m "feat(tasks): integrate TaskCastService into TaskBotService for event publishing" +``` + +--- + +### Task 5: Integrate TaskCastService into TasksService + +**Files:** + +- Modify: `apps/server/apps/gateway/src/tasks/tasks.service.ts` + +The control actions (pause/resume/stop/resolveIntervention) publish commands via RabbitMQ but don't directly sync to TaskCast. We need to add TaskCast status transitions. Since these methods only publish RabbitMQ commands and don't directly update DB status (that's done by the worker), we need to fetch the current execution's `taskcastTaskId` first. + +- [ ] **Step 1: Add TaskCastService import and constructor injection** + +In `apps/server/apps/gateway/src/tasks/tasks.service.ts`: + +1. Add import (after line 30): + +```typescript +import { TaskCastService } from "./taskcast.service.js"; +``` + +2. Update constructor (lines 54-60): + +```typescript + constructor( + @Inject(DATABASE_CONNECTION) + private readonly db: PostgresJsDatabase, + private readonly documentsService: DocumentsService, + private readonly amqpConnection: AmqpConnection, + private readonly triggersService: TriggersService, + private readonly taskCastService: TaskCastService, + ) {} +``` + +- [ ] **Step 2: Add helper to get taskcastTaskId from a known executionId** + +Add this private helper method near the other private helpers (after `getTaskOrThrow`, around line 660): + +```typescript + private async getTaskcastTaskId( + currentExecutionId: string | null, + ): Promise { + if (!currentExecutionId) return null; + + const [execution] = await this.db + .select({ taskcastTaskId: schema.agentTaskExecutions.taskcastTaskId }) + .from(schema.agentTaskExecutions) + .where(eq(schema.agentTaskExecutions.id, currentExecutionId)) + .limit(1); + + return execution?.taskcastTaskId ?? null; + } +``` + +This accepts the `currentExecutionId` from the already-loaded task (via `getTaskOrThrow`), avoiding a redundant task query. + +- [ ] **Step 3: Add TaskCast transition to pause()** + +In the `pause()` method (lines 441-452), add after `publishTaskCommand` and before `return`. Note `task` is already loaded by `getTaskOrThrow` earlier in this method: + +```typescript +// Sync paused status to TaskCast +const tcId = await this.getTaskcastTaskId(task.currentExecutionId); +if (tcId) { + await this.taskCastService.transitionStatus(tcId, "paused"); +} +``` + +- [ ] **Step 4: Add TaskCast transition to resume()** + +In the `resume()` method (lines 454-471), add after `publishTaskCommand` and before `return`: + +```typescript +// Sync running status to TaskCast +const tcId = await this.getTaskcastTaskId(task.currentExecutionId); +if (tcId) { + await this.taskCastService.transitionStatus(tcId, "in_progress"); +} +``` + +- [ ] **Step 5: Add TaskCast transition to stop()** + +In the `stop()` method (lines 473-490), add after `publishTaskCommand` and before `return`: + +```typescript +// Sync cancelled status to TaskCast +const tcId = await this.getTaskcastTaskId(task.currentExecutionId); +if (tcId) { + await this.taskCastService.transitionStatus(tcId, "stopped"); +} +``` + +- [ ] **Step 6: Add TaskCast transition to resolveIntervention()** + +In `resolveIntervention()` (lines 558-633), after the execution status update back to `in_progress` (line 622) and before `publishTaskCommand`: + +```typescript +// Sync running status to TaskCast (unblock) +const [resolvedExecution] = await this.db + .select({ taskcastTaskId: schema.agentTaskExecutions.taskcastTaskId }) + .from(schema.agentTaskExecutions) + .where(eq(schema.agentTaskExecutions.id, intervention.executionId)) + .limit(1); + +if (resolvedExecution?.taskcastTaskId) { + await this.taskCastService.transitionStatus( + resolvedExecution.taskcastTaskId, + "in_progress", + ); + await this.taskCastService.publishEvent(resolvedExecution.taskcastTaskId, { + type: "intervention", + data: { + intervention: { + ...updated, + status: "resolved", + }, + }, + seriesId: `intervention:${interventionId}`, + seriesMode: "latest", + }); +} +``` + +- [ ] **Step 7: Verify compilation** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm --filter @team9/gateway build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 8: Commit** + +```bash +git add apps/server/apps/gateway/src/tasks/tasks.service.ts +git commit -m "feat(tasks): integrate TaskCastService into TasksService for pause/resume/stop/resolve sync" +``` + +--- + +### Task 6: Update WebhookController to look up by taskcastTaskId + +**Files:** + +- Modify: `apps/server/apps/task-worker/src/webhook/webhook.controller.ts` + +Currently, the timeout webhook receives `payload.taskId` which is the TaskCast task ID (not Team9's task ID). We need to look up the execution by `taskcastTaskId` first. + +- [ ] **Step 1: Update the handleTimeout method** + +In `apps/server/apps/task-worker/src/webhook/webhook.controller.ts`, replace the `handleTimeout` method body (lines 39-87): + +```typescript + @Post('timeout') + @HttpCode(200) + async handleTimeout( + @Body() payload: TaskcastTimeoutPayload, + @Headers('x-webhook-secret') secret?: string, + ): Promise { + if (this.webhookSecret && secret !== this.webhookSecret) { + throw new ForbiddenException('Invalid webhook secret'); + } + const { taskId: taskcastId } = payload; + + this.logger.warn(`Received timeout webhook for TaskCast task ${taskcastId}`); + + // Look up execution by taskcastTaskId + const [execution] = await this.db + .select() + .from(schema.agentTaskExecutions) + .where(eq(schema.agentTaskExecutions.taskcastTaskId, taskcastId)) + .limit(1); + + if (!execution) { + this.logger.error( + `Execution not found for TaskCast task: ${taskcastId}`, + ); + return; + } + + const now = new Date(); + + // Update execution status + await this.db + .update(schema.agentTaskExecutions) + .set({ + status: 'timeout', + completedAt: now, + }) + .where(eq(schema.agentTaskExecutions.id, execution.id)); + + // Update task status to timeout + await this.db + .update(schema.agentTasks) + .set({ + status: 'timeout', + updatedAt: now, + }) + .where(eq(schema.agentTasks.id, execution.taskId)); + + this.logger.warn( + `Task ${execution.taskId} and execution ${execution.id} marked as timeout via webhook`, + ); + } +``` + +- [ ] **Step 2: Verify compilation** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm --filter @team9/task-worker build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Commit** + +```bash +git add apps/server/apps/task-worker/src/webhook/webhook.controller.ts +git commit -m "fix(webhook): look up execution by taskcastTaskId instead of Team9 task ID" +``` + +--- + +## Chunk 3: Backend — SSE Proxy Endpoint + +### Task 7: Create SSE proxy controller + +**Files:** + +- Create: `apps/server/apps/gateway/src/tasks/tasks-stream.controller.ts` +- Modify: `apps/server/apps/gateway/src/tasks/tasks.module.ts` + +The gateway proxies SSE streams from TaskCast to the frontend. Since `EventSource` doesn't support custom headers, the endpoint accepts JWT as a query parameter (`?token=`) in addition to the `Authorization` header. + +- [ ] **Step 1: Create tasks-stream.controller.ts** + +Create `apps/server/apps/gateway/src/tasks/tasks-stream.controller.ts`: + +```typescript +import { + Controller, + Get, + Param, + Query, + Req, + Res, + Logger, + NotFoundException, + Inject, +} from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import type { Request, Response } from "express"; +import { JwtService } from "@nestjs/jwt"; +import { + DATABASE_CONNECTION, + eq, + and, + type PostgresJsDatabase, +} from "@team9/database"; +import * as schema from "@team9/database/schemas"; + +@Controller({ path: "tasks", version: "1" }) +export class TasksStreamController { + private readonly logger = new Logger(TasksStreamController.name); + private readonly taskcastUrl: string; + + constructor( + @Inject(DATABASE_CONNECTION) + private readonly db: PostgresJsDatabase, + private readonly jwtService: JwtService, + configService: ConfigService, + ) { + this.taskcastUrl = configService.get( + "TASKCAST_URL", + "http://localhost:3721", + ); + } + + @Get(":taskId/executions/:execId/stream") + async streamExecution( + @Param("taskId") taskId: string, + @Param("execId") execId: string, + @Query("token") queryToken: string | undefined, + @Req() req: Request, + @Res() res: Response, + ): Promise { + // ── Auth: accept Bearer header or ?token= query param ── + const headerToken = req.headers.authorization?.replace("Bearer ", ""); + const token = headerToken || queryToken; + + if (!token) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + + let userId: string; + try { + const payload = this.jwtService.verify(token); + userId = payload.sub; + } catch { + res.status(401).json({ error: "Invalid token" }); + return; + } + + // ── Look up execution + task to get taskcastTaskId and tenantId ── + const [execution] = await this.db + .select({ + taskcastTaskId: schema.agentTaskExecutions.taskcastTaskId, + taskId: schema.agentTaskExecutions.taskId, + }) + .from(schema.agentTaskExecutions) + .where( + and( + eq(schema.agentTaskExecutions.id, execId), + eq(schema.agentTaskExecutions.taskId, taskId), + ), + ) + .limit(1); + + if (!execution?.taskcastTaskId) { + throw new NotFoundException( + "Execution not found or has no TaskCast tracking", + ); + } + + // ── Verify user belongs to the task's workspace ── + const [task] = await this.db + .select({ tenantId: schema.agentTasks.tenantId }) + .from(schema.agentTasks) + .where(eq(schema.agentTasks.id, taskId)) + .limit(1); + + if (task) { + const [membership] = await this.db + .select({ id: schema.tenantMembers.id }) + .from(schema.tenantMembers) + .where( + and( + eq(schema.tenantMembers.tenantId, task.tenantId), + eq(schema.tenantMembers.userId, userId), + ), + ) + .limit(1); + + if (!membership) { + res.status(403).json({ error: "Forbidden" }); + return; + } + } + + // ── Proxy SSE from TaskCast ── + const upstream = `${this.taskcastUrl}/tasks/${execution.taskcastTaskId}/events/stream`; + const headers: Record = { + Accept: "text/event-stream", + }; + + const lastEventId = req.headers["last-event-id"] as string | undefined; + if (lastEventId) { + headers["Last-Event-ID"] = lastEventId; + } + + const controller = new AbortController(); + + // Clean up upstream when client disconnects + req.on("close", () => controller.abort()); + + try { + const upstreamRes = await fetch(upstream, { + headers, + signal: controller.signal, + }); + + if (!upstreamRes.ok || !upstreamRes.body) { + res.status(502).json({ error: "TaskCast upstream unavailable" }); + return; + } + + // Set SSE headers + res.setHeader("Content-Type", "text/event-stream"); + res.setHeader("Cache-Control", "no-cache"); + res.setHeader("Connection", "keep-alive"); + res.setHeader("X-Accel-Buffering", "no"); + res.flushHeaders(); + + // Pipe upstream → client + const reader = upstreamRes.body.getReader(); + const decoder = new TextDecoder(); + + const pump = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(decoder.decode(value, { stream: true })); + } + } catch { + // Client disconnected or abort — expected + } finally { + res.end(); + } + }; + + pump(); + } catch (error) { + if ((error as Error).name === "AbortError") return; + this.logger.error(`SSE proxy error: ${error}`); + if (!res.headersSent) { + res.status(502).json({ error: "TaskCast upstream unavailable" }); + } + } + } +} +``` + +- [ ] **Step 2: Register controller in TasksModule** + +In `apps/server/apps/gateway/src/tasks/tasks.module.ts`, add the new controller: + +```typescript +import { Module, forwardRef } from "@nestjs/common"; +import { AuthModule } from "../auth/auth.module.js"; +import { DocumentsModule } from "../documents/documents.module.js"; +import { WebsocketModule } from "../im/websocket/websocket.module.js"; +import { TasksController } from "./tasks.controller.js"; +import { TaskBotController } from "./task-bot.controller.js"; +import { TasksStreamController } from "./tasks-stream.controller.js"; +import { TasksService } from "./tasks.service.js"; +import { TaskBotService } from "./task-bot.service.js"; +import { TaskCastService } from "./taskcast.service.js"; +import { TriggersService } from "./triggers.service.js"; + +@Module({ + imports: [AuthModule, DocumentsModule, forwardRef(() => WebsocketModule)], + controllers: [TasksController, TaskBotController, TasksStreamController], + providers: [TasksService, TaskBotService, TaskCastService, TriggersService], + exports: [TasksService, TaskCastService, TriggersService], +}) +export class TasksModule {} +``` + +- [ ] **Step 3: Verify compilation** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm --filter @team9/gateway build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 4: Commit** + +```bash +git add apps/server/apps/gateway/src/tasks/tasks-stream.controller.ts apps/server/apps/gateway/src/tasks/tasks.module.ts +git commit -m "feat(tasks): add SSE proxy endpoint for TaskCast event streaming" +``` + +--- + +## Chunk 4: Frontend — SSE Hook + Polling Replacement + +### Task 8: Install `@taskcast/client` in frontend + +**Files:** + +- Modify: `apps/client/package.json` + +- [ ] **Step 1: Install @taskcast/client** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm --filter @team9/client add @taskcast/client +``` + +- [ ] **Step 2: Verify installation** + +```bash +cat apps/client/package.json | grep taskcast +``` + +Expected: `"@taskcast/client"` in dependencies. + +- [ ] **Step 3: Commit** + +```bash +git add apps/client/package.json pnpm-lock.yaml +git commit -m "chore: add @taskcast/client to frontend" +``` + +--- + +### Task 9: Create useExecutionStream custom hook + +**Files:** + +- Create: `apps/client/src/hooks/useExecutionStream.ts` + +This hook opens an SSE connection through the gateway proxy. On events, it invalidates React Query caches to trigger refetches. The hook is a no-op when `taskcastTaskId` is null (legacy executions or TaskCast failure). + +- [ ] **Step 1: Create useExecutionStream.ts** + +Create `apps/client/src/hooks/useExecutionStream.ts`: + +```typescript +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || "http://localhost:3000/api"; + +/** + * Opens an SSE connection to the TaskCast proxy for a specific execution. + * Invalidates React Query caches when events arrive. + * + * Falls back gracefully: if taskcastTaskId is null, no SSE connection is opened + * and the caller should keep polling enabled. + */ +export function useExecutionStream( + taskId: string, + execId: string | undefined, + taskcastTaskId: string | null | undefined, + enabled: boolean, +): void { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!enabled || !execId || !taskcastTaskId) return; + + const token = localStorage.getItem("auth_token"); + if (!token) return; + + const url = `${API_BASE_URL}/v1/tasks/${taskId}/executions/${execId}/stream?token=${encodeURIComponent(token)}`; + const eventSource = new EventSource(url); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + // Invalidate relevant caches based on event type + if ( + data.type === "step" || + data.type === "intervention" || + data.type === "deliverable" + ) { + queryClient.invalidateQueries({ + queryKey: ["task-execution-entries", taskId, execId], + }); + } + + // Status change events invalidate the task and execution queries + if (data.type === "status_changed") { + queryClient.invalidateQueries({ queryKey: ["task", taskId] }); + queryClient.invalidateQueries({ + queryKey: ["task-executions", taskId], + }); + queryClient.invalidateQueries({ + queryKey: ["task-execution", taskId, execId], + }); + } + } catch { + // Ignore parse errors (e.g. heartbeat messages) + } + }; + + eventSource.onerror = () => { + // EventSource auto-reconnects on error; no action needed. + }; + + return () => eventSource.close(); + }, [taskId, execId, taskcastTaskId, enabled, queryClient]); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/client/src/hooks/useExecutionStream.ts +git commit -m "feat(client): add useExecutionStream SSE hook for TaskCast events" +``` + +--- + +### Task 10: Replace polling with SSE in TaskDetailPanel + +**Files:** + +- Modify: `apps/client/src/components/tasks/TaskDetailPanel.tsx` + +- [ ] **Step 1: Add SSE hook to TaskDetailPanel** + +In `apps/client/src/components/tasks/TaskDetailPanel.tsx`: + +1. Add import (after line 14): + +```typescript +import { useExecutionStream } from "@/hooks/useExecutionStream"; +``` + +2. After the `useQuery` block (after line 40), add SSE hook: + +When `taskcastTaskId` is present, SSE handles real-time updates; polling is reduced to 30s safety net. When absent (legacy/failure), 5s polling is retained. + +- [ ] **Step 2: Apply the changes** + +The final changes to `TaskDetailPanel.tsx`: + +1. Add import: + +```typescript +import { useExecutionStream } from "@/hooks/useExecutionStream"; +``` + +2. Change `refetchInterval` (line 39) from `5000` to: + +```typescript + refetchInterval: task?.currentExecution?.execution.taskcastTaskId ? 30000 : 5000, +``` + +Note: `task.currentExecution` has shape `{ execution: AgentTaskExecution; steps; interventions; deliverables } | null`. + +3. After the `useQuery` block and `taskIsActive` definition (after line 49), add: + +```typescript +// SSE for real-time execution progress +useExecutionStream( + taskId, + task?.currentExecution?.execution.id, + task?.currentExecution?.execution.taskcastTaskId, + taskIsActive, +); +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/client/src/components/tasks/TaskDetailPanel.tsx +git commit -m "feat(client): add SSE streaming to TaskDetailPanel, reduce polling when SSE active" +``` + +--- + +### Task 11: Replace polling with SSE in TaskBasicInfoTab + +**Files:** + +- Modify: `apps/client/src/components/tasks/TaskBasicInfoTab.tsx` + +- [ ] **Step 1: Add SSE hook and update polling** + +In `apps/client/src/components/tasks/TaskBasicInfoTab.tsx`: + +1. Add import: + +```typescript +import { useExecutionStream } from "@/hooks/useExecutionStream"; +``` + +2. Find the entries query (lines 230-235) and update `refetchInterval`: + +```typescript + refetchInterval: execution?.taskcastTaskId ? 30000 : 5000, +``` + +3. After the entries query, add SSE hook call (the component receives `task` and `execution` as props or derived values — check the component's props to find where `execution` comes from). The `execution` comes from `task.currentExecution` passed down. The `taskId` is a prop. + +Add after the entries query: + +```typescript +// SSE for real-time entries updates +useExecutionStream( + taskId, + execution?.id, + execution?.taskcastTaskId, + !!execution && + ["in_progress", "pending_action", "paused"].includes(task.status), +); +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/client/src/components/tasks/TaskBasicInfoTab.tsx +git commit -m "feat(client): add SSE streaming to TaskBasicInfoTab" +``` + +--- + +### Task 12: Replace polling with SSE in TaskRunsTab + +**Files:** + +- Modify: `apps/client/src/components/tasks/TaskRunsTab.tsx` + +- [ ] **Step 1: Keep 5s polling (no change needed)** + +In `apps/client/src/components/tasks/TaskRunsTab.tsx`, the executions list query (lines 38-42) uses `refetchInterval: 5000`. Keep this as-is because the `task:execution_created` WebSocket event is not yet implemented (tracked separately in spec non-goals). The 5s polling is currently the only mechanism to detect new executions. Once that WebSocket event is added, this can be reduced to 30s. + +No code change needed for this task — it's a deliberate decision to document. Once `task:execution_created` WebSocket event is implemented, reduce this to 30s. + +--- + +### Task 13: Replace polling with SSE in RunDetailView + +**Files:** + +- Modify: `apps/client/src/components/tasks/RunDetailView.tsx` + +- [ ] **Step 1: Add SSE hook and update polling** + +In `apps/client/src/components/tasks/RunDetailView.tsx`: + +1. Add import: + +```typescript +import { useExecutionStream } from "@/hooks/useExecutionStream"; +``` + +2. Update execution query (lines 58-62) `refetchInterval`: + +```typescript + refetchInterval: execution?.taskcastTaskId ? 30000 : 5000, +``` + +3. Update entries query (lines 74-78) `refetchInterval`: + +```typescript + refetchInterval: execution?.taskcastTaskId ? 30000 : 5000, +``` + +4. After the entries query (after line 78), add SSE hook: + +```typescript +// SSE for real-time updates on this execution +const isActive = execution ? ACTIVE_STATUSES.includes(execution.status) : false; +useExecutionStream(taskId, executionId, execution?.taskcastTaskId, isActive); +``` + +Note: `ACTIVE_STATUSES` is already defined at line 40, and `isActive` is already defined at lines 66-68. Since `isActive` is already computed, just add the hook after it: + +```typescript +useExecutionStream(taskId, executionId, execution?.taskcastTaskId, isActive); +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/client/src/components/tasks/RunDetailView.tsx +git commit -m "feat(client): add SSE streaming to RunDetailView, reduce polling when SSE active" +``` + +--- + +## Chunk 5: Verification + Final Commit + +### Task 14: Full build verification + +**Files:** None (verification only) + +- [ ] **Step 1: Build gateway** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm --filter @team9/gateway build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 2: Build task-worker** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm --filter @team9/task-worker build +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 3: Build client** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm build:client +``` + +Expected: BUILD SUCCESS. + +- [ ] **Step 4: Run linting** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm lint +``` + +Expected: No new lint errors. + +- [ ] **Step 5: Verify dev server starts** + +```bash +cd /Users/winrey/Projects/weightwave/team9 +pnpm dev:server & +sleep 5 +curl http://localhost:3000/api/health +kill %1 +``` + +Expected: Health check returns OK. From 5a7ca7e827609ce08ae4682530d188804e747729 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 13:36:09 +0800 Subject: [PATCH 05/68] docs: update TaskCast integration plan with deterministic ID pattern Use `agent_task_exec_${execId}` as TaskCast task IDs, allowing all services to compute the ID without DB lookups. Updates Tasks 2, 3, 5, 6, and 7 accordingly. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-13-taskcast-integration.md | 163 +++++++++--------- 1 file changed, 79 insertions(+), 84 deletions(-) diff --git a/docs/superpowers/plans/2026-03-13-taskcast-integration.md b/docs/superpowers/plans/2026-03-13-taskcast-integration.md index d823f096..3eef57fd 100644 --- a/docs/superpowers/plans/2026-03-13-taskcast-integration.md +++ b/docs/superpowers/plans/2026-03-13-taskcast-integration.md @@ -6,6 +6,8 @@ **Architecture:** Gateway and task-worker both use `@taskcast/server-sdk` to communicate with a deployed TaskCast Rust instance over internal network. Gateway proxies SSE streams to the frontend. Frontend receives SSE events and invalidates React Query caches instead of 5s polling. +**Key optimization — Deterministic TaskCast IDs:** All TaskCast task IDs follow the pattern `agent_task_exec_${executionId}`. This allows any service to compute the TaskCast ID from the execution ID without querying the DB. The `taskcastTaskId` column still stores the value for reference and as a boolean indicator of whether TaskCast was successfully set up. + **Tech Stack:** NestJS, `@taskcast/server-sdk`, `@taskcast/client`, React, TanStack React Query, SSE (EventSource) --- @@ -93,6 +95,10 @@ export class TaskCastService { }); } + /** + * Deterministic ID: `agent_task_exec_${executionId}`. + * This lets all services compute the TaskCast ID without DB lookups. + */ async createTask(params: { taskId: string; executionId: string; @@ -100,8 +106,10 @@ export class TaskCastService { tenantId: string; ttl?: number; }): Promise { + const deterministicId = TaskCastService.taskcastId(params.executionId); try { const task = await this.client.createTask({ + id: deterministicId, type: `agent_task.${params.taskId}`, ttl: params.ttl ?? 86400, metadata: { @@ -118,6 +126,11 @@ export class TaskCastService { } } + /** Compute the deterministic TaskCast task ID from an execution ID. */ + static taskcastId(executionId: string): string { + return `agent_task_exec_${executionId}`; + } + async transitionStatus( taskcastTaskId: string, status: string, @@ -209,6 +222,10 @@ export class TaskCastClient { }); } + /** + * Deterministic ID: `agent_task_exec_${executionId}`. + * This lets all services compute the TaskCast ID without DB lookups. + */ async createTask(params: { taskId: string; executionId: string; @@ -216,8 +233,10 @@ export class TaskCastClient { tenantId: string; ttl?: number; }): Promise { + const deterministicId = `agent_task_exec_${params.executionId}`; try { const task = await this.client.createTask({ + id: deterministicId, type: `agent_task.${params.taskId}`, ttl: params.ttl ?? 86400, metadata: { @@ -510,96 +529,66 @@ import { TaskCastService } from "./taskcast.service.js"; ) {} ``` -- [ ] **Step 2: Add helper to get taskcastTaskId from a known executionId** - -Add this private helper method near the other private helpers (after `getTaskOrThrow`, around line 660): +- [ ] **Step 2: Add TaskCast transition to pause()** -```typescript - private async getTaskcastTaskId( - currentExecutionId: string | null, - ): Promise { - if (!currentExecutionId) return null; - - const [execution] = await this.db - .select({ taskcastTaskId: schema.agentTaskExecutions.taskcastTaskId }) - .from(schema.agentTaskExecutions) - .where(eq(schema.agentTaskExecutions.id, currentExecutionId)) - .limit(1); - - return execution?.taskcastTaskId ?? null; - } -``` - -This accepts the `currentExecutionId` from the already-loaded task (via `getTaskOrThrow`), avoiding a redundant task query. - -- [ ] **Step 3: Add TaskCast transition to pause()** +With deterministic IDs, we compute `agent_task_exec_${currentExecutionId}` instead of querying the DB. Import `TaskCastService` (already done in Step 1) provides the static helper. In the `pause()` method (lines 441-452), add after `publishTaskCommand` and before `return`. Note `task` is already loaded by `getTaskOrThrow` earlier in this method: ```typescript -// Sync paused status to TaskCast -const tcId = await this.getTaskcastTaskId(task.currentExecutionId); -if (tcId) { +// Sync paused status to TaskCast (deterministic ID — no DB lookup) +if (task.currentExecutionId) { + const tcId = TaskCastService.taskcastId(task.currentExecutionId); await this.taskCastService.transitionStatus(tcId, "paused"); } ``` -- [ ] **Step 4: Add TaskCast transition to resume()** +- [ ] **Step 3: Add TaskCast transition to resume()** In the `resume()` method (lines 454-471), add after `publishTaskCommand` and before `return`: ```typescript -// Sync running status to TaskCast -const tcId = await this.getTaskcastTaskId(task.currentExecutionId); -if (tcId) { +// Sync running status to TaskCast (deterministic ID — no DB lookup) +if (task.currentExecutionId) { + const tcId = TaskCastService.taskcastId(task.currentExecutionId); await this.taskCastService.transitionStatus(tcId, "in_progress"); } ``` -- [ ] **Step 5: Add TaskCast transition to stop()** +- [ ] **Step 4: Add TaskCast transition to stop()** In the `stop()` method (lines 473-490), add after `publishTaskCommand` and before `return`: ```typescript -// Sync cancelled status to TaskCast -const tcId = await this.getTaskcastTaskId(task.currentExecutionId); -if (tcId) { +// Sync cancelled status to TaskCast (deterministic ID — no DB lookup) +if (task.currentExecutionId) { + const tcId = TaskCastService.taskcastId(task.currentExecutionId); await this.taskCastService.transitionStatus(tcId, "stopped"); } ``` -- [ ] **Step 6: Add TaskCast transition to resolveIntervention()** +- [ ] **Step 5: Add TaskCast transition to resolveIntervention()** -In `resolveIntervention()` (lines 558-633), after the execution status update back to `in_progress` (line 622) and before `publishTaskCommand`: +In `resolveIntervention()` (lines 558-633), after the execution status update back to `in_progress` (line 622) and before `publishTaskCommand`. With deterministic IDs, we compute the TaskCast ID from `intervention.executionId` directly — no DB lookup needed: ```typescript -// Sync running status to TaskCast (unblock) -const [resolvedExecution] = await this.db - .select({ taskcastTaskId: schema.agentTaskExecutions.taskcastTaskId }) - .from(schema.agentTaskExecutions) - .where(eq(schema.agentTaskExecutions.id, intervention.executionId)) - .limit(1); - -if (resolvedExecution?.taskcastTaskId) { - await this.taskCastService.transitionStatus( - resolvedExecution.taskcastTaskId, - "in_progress", - ); - await this.taskCastService.publishEvent(resolvedExecution.taskcastTaskId, { - type: "intervention", - data: { - intervention: { - ...updated, - status: "resolved", - }, +// Sync running status to TaskCast (unblock) — deterministic ID, no DB lookup +const tcId = TaskCastService.taskcastId(intervention.executionId); +await this.taskCastService.transitionStatus(tcId, "in_progress"); +await this.taskCastService.publishEvent(tcId, { + type: "intervention", + data: { + intervention: { + ...updated, + status: "resolved", }, - seriesId: `intervention:${interventionId}`, - seriesMode: "latest", - }); -} + }, + seriesId: `intervention:${interventionId}`, + seriesMode: "latest", +}); ``` -- [ ] **Step 7: Verify compilation** +- [ ] **Step 6: Verify compilation** ```bash cd /Users/winrey/Projects/weightwave/team9 @@ -608,7 +597,7 @@ pnpm --filter @team9/gateway build Expected: BUILD SUCCESS. -- [ ] **Step 8: Commit** +- [ ] **Step 7: Commit** ```bash git add apps/server/apps/gateway/src/tasks/tasks.service.ts @@ -617,19 +606,21 @@ git commit -m "feat(tasks): integrate TaskCastService into TasksService for paus --- -### Task 6: Update WebhookController to look up by taskcastTaskId +### Task 6: Update WebhookController to parse executionId from deterministic TaskCast ID **Files:** - Modify: `apps/server/apps/task-worker/src/webhook/webhook.controller.ts` -Currently, the timeout webhook receives `payload.taskId` which is the TaskCast task ID (not Team9's task ID). We need to look up the execution by `taskcastTaskId` first. +The timeout webhook receives `payload.taskId` which is the deterministic TaskCast task ID (`agent_task_exec_${execId}`). We parse the execution ID from the prefix — no DB lookup needed to find the execution. - [ ] **Step 1: Update the handleTimeout method** In `apps/server/apps/task-worker/src/webhook/webhook.controller.ts`, replace the `handleTimeout` method body (lines 39-87): ```typescript + private static readonly TASKCAST_ID_PREFIX = 'agent_task_exec_'; + @Post('timeout') @HttpCode(200) async handleTimeout( @@ -643,17 +634,22 @@ In `apps/server/apps/task-worker/src/webhook/webhook.controller.ts`, replace the this.logger.warn(`Received timeout webhook for TaskCast task ${taskcastId}`); - // Look up execution by taskcastTaskId + // Parse execution ID from deterministic TaskCast ID (agent_task_exec_{execId}) + if (!taskcastId.startsWith(WebhookController.TASKCAST_ID_PREFIX)) { + this.logger.error(`Unexpected TaskCast ID format: ${taskcastId}`); + return; + } + const executionId = taskcastId.slice(WebhookController.TASKCAST_ID_PREFIX.length); + + // Verify execution exists and get its taskId const [execution] = await this.db - .select() + .select({ id: schema.agentTaskExecutions.id, taskId: schema.agentTaskExecutions.taskId }) .from(schema.agentTaskExecutions) - .where(eq(schema.agentTaskExecutions.taskcastTaskId, taskcastId)) + .where(eq(schema.agentTaskExecutions.id, executionId)) .limit(1); if (!execution) { - this.logger.error( - `Execution not found for TaskCast task: ${taskcastId}`, - ); + this.logger.error(`Execution not found: ${executionId}`); return; } @@ -666,7 +662,7 @@ In `apps/server/apps/task-worker/src/webhook/webhook.controller.ts`, replace the status: 'timeout', completedAt: now, }) - .where(eq(schema.agentTaskExecutions.id, execution.id)); + .where(eq(schema.agentTaskExecutions.id, executionId)); // Update task status to timeout await this.db @@ -678,7 +674,7 @@ In `apps/server/apps/task-worker/src/webhook/webhook.controller.ts`, replace the .where(eq(schema.agentTasks.id, execution.taskId)); this.logger.warn( - `Task ${execution.taskId} and execution ${execution.id} marked as timeout via webhook`, + `Task ${execution.taskId} and execution ${executionId} marked as timeout via webhook`, ); } ``` @@ -696,7 +692,7 @@ Expected: BUILD SUCCESS. ```bash git add apps/server/apps/task-worker/src/webhook/webhook.controller.ts -git commit -m "fix(webhook): look up execution by taskcastTaskId instead of Team9 task ID" +git commit -m "fix(webhook): parse executionId from deterministic TaskCast ID prefix" ``` --- @@ -782,12 +778,12 @@ export class TasksStreamController { return; } - // ── Look up execution + task to get taskcastTaskId and tenantId ── + // ── Deterministic TaskCast ID — no DB lookup for taskcastTaskId ── + const taskcastTaskId = `agent_task_exec_${execId}`; + + // ── Verify execution exists and belongs to this task ── const [execution] = await this.db - .select({ - taskcastTaskId: schema.agentTaskExecutions.taskcastTaskId, - taskId: schema.agentTaskExecutions.taskId, - }) + .select({ id: schema.agentTaskExecutions.id }) .from(schema.agentTaskExecutions) .where( and( @@ -797,10 +793,8 @@ export class TasksStreamController { ) .limit(1); - if (!execution?.taskcastTaskId) { - throw new NotFoundException( - "Execution not found or has no TaskCast tracking", - ); + if (!execution) { + throw new NotFoundException("Execution not found"); } // ── Verify user belongs to the task's workspace ── @@ -829,7 +823,7 @@ export class TasksStreamController { } // ── Proxy SSE from TaskCast ── - const upstream = `${this.taskcastUrl}/tasks/${execution.taskcastTaskId}/events/stream`; + const upstream = `${this.taskcastUrl}/tasks/${taskcastTaskId}/events/stream`; const headers: Record = { Accept: "text/event-stream", }; @@ -974,7 +968,7 @@ git commit -m "chore: add @taskcast/client to frontend" - Create: `apps/client/src/hooks/useExecutionStream.ts` -This hook opens an SSE connection through the gateway proxy. On events, it invalidates React Query caches to trigger refetches. The hook is a no-op when `taskcastTaskId` is null (legacy executions or TaskCast failure). +This hook opens an SSE connection through the gateway proxy. On events, it invalidates React Query caches to trigger refetches. The hook is a no-op when `taskcastTaskId` is null (legacy executions or TaskCast failure). With deterministic IDs, the SSE proxy computes the TaskCast ID server-side from the execId — the frontend doesn't need to know it. - [ ] **Step 1: Create useExecutionStream.ts** @@ -991,8 +985,9 @@ const API_BASE_URL = * Opens an SSE connection to the TaskCast proxy for a specific execution. * Invalidates React Query caches when events arrive. * - * Falls back gracefully: if taskcastTaskId is null, no SSE connection is opened - * and the caller should keep polling enabled. + * `taskcastTaskId` is used as a boolean gate — if null/undefined, TaskCast + * was not set up for this execution and no SSE connection is opened. + * The actual TaskCast ID is computed server-side from the execId (deterministic). */ export function useExecutionStream( taskId: string, From 7d9eb0848478f3980dce0627b0cb629376a034c0 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 13:39:24 +0800 Subject: [PATCH 06/68] chore: add @taskcast/server-sdk to gateway and task-worker Co-Authored-By: Claude Opus 4.6 --- apps/server/apps/gateway/package.json | 3 +- apps/server/apps/task-worker/package.json | 3 +- pnpm-lock.yaml | 49 +++++++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/apps/server/apps/gateway/package.json b/apps/server/apps/gateway/package.json index bd545ff4..60e21678 100644 --- a/apps/server/apps/gateway/package.json +++ b/apps/server/apps/gateway/package.json @@ -36,14 +36,15 @@ "@sentry/nestjs": "^10.40.0", "@sentry/profiling-node": "^10.40.0", "@socket.io/redis-adapter": "^8.3.0", + "@taskcast/server-sdk": "^1.1.0", "@team9/ai-client": "workspace:*", "@team9/auth": "workspace:*", "@team9/database": "workspace:*", "@team9/email": "workspace:*", + "@team9/observability": "workspace:*", "@team9/rabbitmq": "workspace:*", "@team9/redis": "workspace:*", "@team9/shared": "workspace:*", - "@team9/observability": "workspace:*", "@team9/storage": "workspace:*", "axios": "^1.13.2", "bcrypt": "^6.0.0", diff --git a/apps/server/apps/task-worker/package.json b/apps/server/apps/task-worker/package.json index 99c7241a..d0dce87d 100644 --- a/apps/server/apps/task-worker/package.json +++ b/apps/server/apps/task-worker/package.json @@ -18,10 +18,11 @@ "@nestjs/config": "^4.0.2", "@nestjs/core": "^11.0.1", "@nestjs/platform-express": "^11.0.1", + "@nestjs/schedule": "^6.1.1", + "@taskcast/server-sdk": "^1.1.0", "@team9/database": "workspace:*", "@team9/rabbitmq": "workspace:*", "@team9/redis": "workspace:*", - "@nestjs/schedule": "^6.1.1", "@team9/shared": "workspace:*", "dotenv": "^17.2.3", "reflect-metadata": "^0.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 77a4c16e..b0bc95e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -496,6 +496,9 @@ importers: "@socket.io/redis-adapter": specifier: ^8.3.0 version: 8.3.0(socket.io-adapter@2.5.6) + "@taskcast/server-sdk": + specifier: ^1.1.0 + version: 1.1.0(@taskcast/core@1.1.0) "@team9/ai-client": specifier: workspace:* version: link:../../libs/ai-client @@ -661,6 +664,9 @@ importers: "@nestjs/schedule": specifier: ^6.1.1 version: 6.1.1(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10) + "@taskcast/server-sdk": + specifier: ^1.1.0 + version: 1.1.0(@taskcast/core@1.1.0) "@team9/database": specifier: workspace:* version: link:../../libs/database @@ -6692,6 +6698,20 @@ packages: } engines: { node: ">=12" } + "@taskcast/core@1.1.0": + resolution: + { + integrity: sha512-OCN7dMRNL9VsvHW7IB4HeNmX4mJ4HeGSN50TfgfWwEhDbuv54w0ZKlVe9pQYRzBCDNsqZ5LA5f4A1+tUgRbRyg==, + } + + "@taskcast/server-sdk@1.1.0": + resolution: + { + integrity: sha512-+t7H6NhcyGzLt+CxtNZ4K2jlqR56TTI6H6v+qqj/Fd0HbkmCL6UQ/s0W5b+f/aCoh8tYcpJvAuvd2wqaHUc86Q==, + } + peerDependencies: + "@taskcast/core": ^1.1.0 + "@tauri-apps/api@2.10.1": resolution: { @@ -10627,6 +10647,12 @@ packages: integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, } + layerr@3.0.0: + resolution: + { + integrity: sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==, + } + leac@0.6.0: resolution: { @@ -13490,6 +13516,13 @@ packages: } engines: { node: ">=18" } + ulidx@2.4.1: + resolution: + { + integrity: sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==, + } + engines: { node: ">=16" } + undici-types@6.21.0: resolution: { @@ -18543,6 +18576,16 @@ snapshots: "@tanstack/virtual-file-routes@1.141.0": {} + "@taskcast/core@1.1.0": + dependencies: + js-yaml: 4.1.1 + ulidx: 2.4.1 + zod: 3.25.76 + + "@taskcast/server-sdk@1.1.0(@taskcast/core@1.1.0)": + dependencies: + "@taskcast/core": 1.1.0 + "@tauri-apps/api@2.10.1": {} "@tauri-apps/cli-darwin-arm64@2.9.6": @@ -21150,6 +21193,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + layerr@3.0.0: {} + leac@0.6.0: {} leven@3.1.0: {} @@ -23013,6 +23058,10 @@ snapshots: uint8array-extras@1.5.0: {} + ulidx@2.4.1: + dependencies: + layerr: 3.0.0 + undici-types@6.21.0: {} undici-types@7.16.0: {} From bd21806fa50f7119a3931262503c1a337964df5c Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 13:45:18 +0800 Subject: [PATCH 07/68] feat(tasks): replace TaskCastService TODO stubs with real @taskcast/server-sdk calls Uses deterministic IDs (agent_task_exec_{execId}) and fire-and-forget error handling. All methods catch errors internally. Co-Authored-By: Claude Opus 4.6 --- .../gateway/src/tasks/taskcast.service.ts | 109 ++++++++++++++---- 1 file changed, 85 insertions(+), 24 deletions(-) diff --git a/apps/server/apps/gateway/src/tasks/taskcast.service.ts b/apps/server/apps/gateway/src/tasks/taskcast.service.ts index 77dd23c2..047defbd 100644 --- a/apps/server/apps/gateway/src/tasks/taskcast.service.ts +++ b/apps/server/apps/gateway/src/tasks/taskcast.service.ts @@ -1,55 +1,116 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { + TaskcastServerClient, + type CreateTaskInput, +} from '@taskcast/server-sdk'; + +// TaskStatus from @taskcast/core — defined inline to avoid pnpm strict resolution issues +type TaskStatus = + | 'pending' + | 'running' + | 'paused' + | 'blocked' + | 'completed' + | 'failed' + | 'timeout' + | 'cancelled'; + +const STATUS_MAP: Record = { + in_progress: 'running', + paused: 'paused', + pending_action: 'blocked', + completed: 'completed', + failed: 'failed', + timeout: 'timeout', + stopped: 'cancelled', +}; @Injectable() export class TaskCastService { private readonly logger = new Logger(TaskCastService.name); - private readonly baseUrl: string; + private readonly client: TaskcastServerClient; - constructor(private readonly config: ConfigService) { - this.baseUrl = this.config.get( - 'TASKCAST_URL', - 'http://localhost:3721', - ); + constructor(config: ConfigService) { + this.client = new TaskcastServerClient({ + baseUrl: config.get('TASKCAST_URL', 'http://localhost:3721'), + }); } - /** Create a TaskCast task for an execution */ + /** + * Deterministic ID: `agent_task_exec_${executionId}`. + * This lets all services compute the TaskCast ID without DB lookups. + */ async createTask(params: { taskId: string; executionId: string; botId: string; tenantId: string; ttl?: number; - }): Promise { - this.logger.log( - `Creating TaskCast task for execution ${params.executionId}`, - ); - // TODO: POST ${baseUrl}/tasks with actual TaskCast SDK when available - return `tc_${params.executionId}`; + }): Promise { + const deterministicId = TaskCastService.taskcastId(params.executionId); + try { + const task = await this.client.createTask({ + id: deterministicId, + type: `agent_task.${params.taskId}`, + ttl: params.ttl ?? 86400, + metadata: { + taskId: params.taskId, + executionId: params.executionId, + botId: params.botId, + tenantId: params.tenantId, + }, + } as CreateTaskInput & { id: string }); + return task.id; + } catch (error) { + this.logger.error(`Failed to create TaskCast task: ${error}`); + return null; + } } - /** Transition TaskCast task status */ - async updateStatus(taskcastTaskId: string, status: string): Promise { - this.logger.log(`TaskCast status → ${status} for ${taskcastTaskId}`); - // TODO: PATCH ${baseUrl}/tasks/${taskcastTaskId}/status + async transitionStatus( + taskcastTaskId: string, + status: string, + ): Promise { + const mapped = STATUS_MAP[status]; + if (!mapped) { + this.logger.warn(`No TaskCast mapping for status: ${status}`); + return; + } + try { + await this.client.transitionTask(taskcastTaskId, mapped); + } catch (error) { + this.logger.error(`Failed to transition TaskCast status: ${error}`); + } } - /** Publish an event to TaskCast */ async publishEvent( taskcastTaskId: string, event: { type: string; data: Record; seriesId?: string; + seriesMode?: 'accumulate' | 'latest' | 'keep-all'; }, ): Promise { - this.logger.log(`TaskCast event: ${event.type} for ${taskcastTaskId}`); - // TODO: POST ${baseUrl}/tasks/${taskcastTaskId}/events + try { + await this.client.publishEvent(taskcastTaskId, { + type: event.type, + level: 'info', + data: event.data, + seriesId: event.seriesId, + seriesMode: event.seriesMode, + }); + } catch (error) { + this.logger.error(`Failed to publish TaskCast event: ${error}`); + } } - /** Delete / cleanup a TaskCast task */ - async deleteTask(taskcastTaskId: string): Promise { - this.logger.log(`TaskCast delete: ${taskcastTaskId}`); - // TODO: DELETE ${baseUrl}/tasks/${taskcastTaskId} + /** No-op — TaskCast cleanup rules handle expiration via TTL. */ + async deleteTask(_taskcastTaskId: string): Promise {} + + /** Compute the deterministic TaskCast task ID from an execution ID. */ + static taskcastId(executionId: string): string { + return `agent_task_exec_${executionId}`; } } From 49f5a460521dec827e8f6d0b4139d4f01b4cb6d3 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 13:47:46 +0800 Subject: [PATCH 08/68] feat(task-worker): create TaskCast client and replace UUID placeholder with real task creation Co-Authored-By: Claude Sonnet 4.6 --- .../executor/execution-strategy.interface.ts | 2 +- .../src/executor/executor.module.ts | 3 +- .../src/executor/executor.service.ts | 10 +++- .../src/taskcast/taskcast.client.ts | 48 +++++++++++++++++++ .../src/taskcast/taskcast.module.ts | 10 ++++ 5 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 apps/server/apps/task-worker/src/taskcast/taskcast.client.ts create mode 100644 apps/server/apps/task-worker/src/taskcast/taskcast.module.ts diff --git a/apps/server/apps/task-worker/src/executor/execution-strategy.interface.ts b/apps/server/apps/task-worker/src/executor/execution-strategy.interface.ts index fc80f67c..aa5fbbcf 100644 --- a/apps/server/apps/task-worker/src/executor/execution-strategy.interface.ts +++ b/apps/server/apps/task-worker/src/executor/execution-strategy.interface.ts @@ -4,7 +4,7 @@ export interface ExecutionContext { botId: string; channelId: string; documentContent?: string; - taskcastTaskId: string; + taskcastTaskId: string | null; } export interface ExecutionStrategy { diff --git a/apps/server/apps/task-worker/src/executor/executor.module.ts b/apps/server/apps/task-worker/src/executor/executor.module.ts index d73762ca..35a5e16f 100644 --- a/apps/server/apps/task-worker/src/executor/executor.module.ts +++ b/apps/server/apps/task-worker/src/executor/executor.module.ts @@ -2,9 +2,10 @@ import { Module, OnModuleInit } from '@nestjs/common'; import { DatabaseModule } from '@team9/database'; import { ExecutorService } from './executor.service.js'; import { OpenclawStrategy } from './strategies/openclaw.strategy.js'; +import { TaskCastModule } from '../taskcast/taskcast.module.js'; @Module({ - imports: [DatabaseModule], + imports: [DatabaseModule, TaskCastModule], providers: [ExecutorService, OpenclawStrategy], exports: [ExecutorService], }) diff --git a/apps/server/apps/task-worker/src/executor/executor.service.ts b/apps/server/apps/task-worker/src/executor/executor.service.ts index 9c224e7f..1776397e 100644 --- a/apps/server/apps/task-worker/src/executor/executor.service.ts +++ b/apps/server/apps/task-worker/src/executor/executor.service.ts @@ -13,6 +13,7 @@ import type { ExecutionContext, ExecutionStrategy, } from './execution-strategy.interface.js'; +import { TaskCastClient } from '../taskcast/taskcast.client.js'; @Injectable() export class ExecutorService { @@ -22,6 +23,7 @@ export class ExecutorService { constructor( @Inject(DATABASE_CONNECTION) private readonly db: PostgresJsDatabase, + private readonly taskCastClient: TaskCastClient, ) {} /** @@ -130,7 +132,13 @@ export class ExecutorService { // ── 5. Create execution record ──────────────────────────────────── const executionId = uuidv7(); - const taskcastTaskId = uuidv7(); // Placeholder — will be replaced by external system ID + const taskcastTaskId = await this.taskCastClient.createTask({ + taskId, + executionId, + botId: task.botId, + tenantId: task.tenantId, + ttl: 86400, + }); await this.db.insert(schema.agentTaskExecutions).values({ id: executionId, diff --git a/apps/server/apps/task-worker/src/taskcast/taskcast.client.ts b/apps/server/apps/task-worker/src/taskcast/taskcast.client.ts new file mode 100644 index 00000000..2d32ecb3 --- /dev/null +++ b/apps/server/apps/task-worker/src/taskcast/taskcast.client.ts @@ -0,0 +1,48 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { + TaskcastServerClient, + type CreateTaskInput, +} from '@taskcast/server-sdk'; + +@Injectable() +export class TaskCastClient { + private readonly logger = new Logger(TaskCastClient.name); + private readonly client: TaskcastServerClient; + + constructor(config: ConfigService) { + this.client = new TaskcastServerClient({ + baseUrl: config.get('TASKCAST_URL', 'http://localhost:3721'), + }); + } + + /** + * Deterministic ID: `agent_task_exec_${executionId}`. + */ + async createTask(params: { + taskId: string; + executionId: string; + botId: string; + tenantId: string; + ttl?: number; + }): Promise { + const deterministicId = `agent_task_exec_${params.executionId}`; + try { + const task = await this.client.createTask({ + id: deterministicId, + type: `agent_task.${params.taskId}`, + ttl: params.ttl ?? 86400, + metadata: { + taskId: params.taskId, + executionId: params.executionId, + botId: params.botId, + tenantId: params.tenantId, + }, + } as CreateTaskInput & { id: string }); + return task.id; + } catch (error) { + this.logger.error(`Failed to create TaskCast task: ${error}`); + return null; + } + } +} diff --git a/apps/server/apps/task-worker/src/taskcast/taskcast.module.ts b/apps/server/apps/task-worker/src/taskcast/taskcast.module.ts new file mode 100644 index 00000000..7f794cd1 --- /dev/null +++ b/apps/server/apps/task-worker/src/taskcast/taskcast.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { TaskCastClient } from './taskcast.client.js'; + +@Module({ + imports: [ConfigModule], + providers: [TaskCastClient], + exports: [TaskCastClient], +}) +export class TaskCastModule {} From bc0243189f557c0bf7ea91e8f670ac31281f4980 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 13:50:49 +0800 Subject: [PATCH 09/68] feat(tasks): integrate TaskCastService into TaskBotService for event publishing Injects TaskCastService into TaskBotService and wires it into reportSteps, updateStatus, createIntervention, and addDeliverable to publish real-time progress, status transitions, interventions, and deliverable events to TaskCast. Co-Authored-By: Claude Sonnet 4.6 --- .../gateway/src/tasks/task-bot.service.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/apps/server/apps/gateway/src/tasks/task-bot.service.ts b/apps/server/apps/gateway/src/tasks/task-bot.service.ts index 99c6896e..addcfbe1 100644 --- a/apps/server/apps/gateway/src/tasks/task-bot.service.ts +++ b/apps/server/apps/gateway/src/tasks/task-bot.service.ts @@ -20,6 +20,7 @@ import { WEBSOCKET_GATEWAY } from '../shared/constants/injection-tokens.js'; import type { WebsocketGateway } from '../im/websocket/websocket.gateway.js'; import type { ReportStepsDto } from './dto/report-steps.dto.js'; import type { CreateInterventionDto } from './dto/create-intervention.dto.js'; +import { TaskCastService } from './taskcast.service.js'; // ── Service ───────────────────────────────────────────────────────── @@ -30,6 +31,7 @@ export class TaskBotService { private readonly db: PostgresJsDatabase, @Inject(WEBSOCKET_GATEWAY) private readonly wsGateway: WebsocketGateway, + private readonly taskCastService: TaskCastService, ) {} // ── Report step progress ───────────────────────────────────────── @@ -116,6 +118,16 @@ export class TaskBotService { .where(eq(schema.agentTaskSteps.executionId, execution.id)) .orderBy(schema.agentTaskSteps.orderIndex); + // Publish step progress to TaskCast + if (execution.taskcastTaskId) { + await this.taskCastService.publishEvent(execution.taskcastTaskId, { + type: 'step', + data: { steps }, + seriesId: 'steps', + seriesMode: 'latest', + }); + } + return steps; } @@ -185,6 +197,14 @@ export class TaskBotService { }, ); + // Sync status to TaskCast + if (execution.taskcastTaskId) { + await this.taskCastService.transitionStatus( + execution.taskcastTaskId, + status, + ); + } + return { task: updatedTask, execution: updatedExecution }; } @@ -235,6 +255,20 @@ export class TaskBotService { }, ); + // Sync blocked status + intervention event to TaskCast + if (execution.taskcastTaskId) { + await this.taskCastService.transitionStatus( + execution.taskcastTaskId, + 'pending_action', + ); + await this.taskCastService.publishEvent(execution.taskcastTaskId, { + type: 'intervention', + data: { intervention }, + seriesId: `intervention:${intervention.id}`, + seriesMode: 'latest', + }); + } + return intervention; } @@ -267,6 +301,14 @@ export class TaskBotService { }) .returning(); + // Publish deliverable event to TaskCast + if (execution.taskcastTaskId) { + await this.taskCastService.publishEvent(execution.taskcastTaskId, { + type: 'deliverable', + data: { deliverable }, + }); + } + return deliverable; } From c3bcfbc86582a7a2f9ea6e3886ad0a453eacdab6 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 13:52:45 +0800 Subject: [PATCH 10/68] fix(webhook): parse executionId from deterministic TaskCast ID prefix Co-Authored-By: Claude Sonnet 4.6 --- .../src/webhook/webhook.controller.ts | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/apps/server/apps/task-worker/src/webhook/webhook.controller.ts b/apps/server/apps/task-worker/src/webhook/webhook.controller.ts index 3e5ea017..230f6cd3 100644 --- a/apps/server/apps/task-worker/src/webhook/webhook.controller.ts +++ b/apps/server/apps/task-worker/src/webhook/webhook.controller.ts @@ -23,6 +23,7 @@ interface TaskcastTimeoutPayload { @Controller('webhooks/taskcast') export class WebhookController { + private static readonly TASKCAST_ID_PREFIX = 'agent_task_exec_'; private readonly logger = new Logger(WebhookController.name); private readonly webhookSecret: string | undefined; @@ -43,34 +44,46 @@ export class WebhookController { if (this.webhookSecret && secret !== this.webhookSecret) { throw new ForbiddenException('Invalid webhook secret'); } - const { taskId } = payload; + const { taskId: taskcastId } = payload; - this.logger.warn(`Received timeout webhook for task ${taskId}`); + this.logger.warn( + `Received timeout webhook for TaskCast task ${taskcastId}`, + ); + + // Parse execution ID from deterministic TaskCast ID (agent_task_exec_{execId}) + if (!taskcastId.startsWith(WebhookController.TASKCAST_ID_PREFIX)) { + this.logger.error(`Unexpected TaskCast ID format: ${taskcastId}`); + return; + } + const executionId = taskcastId.slice( + WebhookController.TASKCAST_ID_PREFIX.length, + ); - // Find the current in-progress execution for this task - const [task] = await this.db - .select() - .from(schema.agentTasks) - .where(eq(schema.agentTasks.id, taskId)) + // Verify execution exists and get its taskId + const [execution] = await this.db + .select({ + id: schema.agentTaskExecutions.id, + taskId: schema.agentTaskExecutions.taskId, + }) + .from(schema.agentTaskExecutions) + .where(eq(schema.agentTaskExecutions.id, executionId)) .limit(1); - if (!task) { - this.logger.error(`Task not found for timeout webhook: ${taskId}`); + if (!execution) { + this.logger.error(`Execution not found: ${executionId}`); return; } const now = new Date(); - // Update execution status if there is a current execution - if (task.currentExecutionId) { - await this.db - .update(schema.agentTaskExecutions) - .set({ - status: 'timeout', - completedAt: now, - }) - .where(eq(schema.agentTaskExecutions.id, task.currentExecutionId)); - } + // Update execution status + await this.db + .update(schema.agentTaskExecutions) + .set({ + status: 'timeout', + completedAt: now, + }) + .where(eq(schema.agentTaskExecutions.id, executionId)); // Update task status to timeout await this.db @@ -79,10 +92,10 @@ export class WebhookController { status: 'timeout', updatedAt: now, }) - .where(eq(schema.agentTasks.id, taskId)); + .where(eq(schema.agentTasks.id, execution.taskId)); this.logger.warn( - `Task ${taskId} and execution ${task.currentExecutionId} marked as timeout via webhook`, + `Task ${execution.taskId} and execution ${executionId} marked as timeout via webhook`, ); } } From 5198dccb95020953abf57c25cb6631e6d5ea135e Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 13:52:48 +0800 Subject: [PATCH 11/68] feat(tasks): integrate TaskCastService into TasksService for pause/resume/stop/resolve sync Co-Authored-By: Claude Sonnet 4.6 --- .../apps/gateway/src/tasks/tasks.service.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/apps/server/apps/gateway/src/tasks/tasks.service.ts b/apps/server/apps/gateway/src/tasks/tasks.service.ts index df3e3145..4f6466ed 100644 --- a/apps/server/apps/gateway/src/tasks/tasks.service.ts +++ b/apps/server/apps/gateway/src/tasks/tasks.service.ts @@ -36,6 +36,7 @@ import type { ResumeTaskDto } from './dto/task-control.dto.js'; import type { StopTaskDto } from './dto/task-control.dto.js'; import type { ResolveInterventionDto } from './dto/resolve-intervention.dto.js'; import type { RetryExecutionDto } from './dto/trigger.dto.js'; +import { TaskCastService } from './taskcast.service.js'; // ── Filter types ──────────────────────────────────────────────────── @@ -57,6 +58,7 @@ export class TasksService { private readonly documentsService: DocumentsService, private readonly amqpConnection: AmqpConnection, private readonly triggersService: TriggersService, + private readonly taskCastService: TaskCastService, ) {} // ── CRUD ──────────────────────────────────────────────────────── @@ -448,6 +450,12 @@ export class TasksService { userId, }); + // Sync paused status to TaskCast (deterministic ID — no DB lookup) + if (task.currentExecutionId) { + const tcId = TaskCastService.taskcastId(task.currentExecutionId); + await this.taskCastService.transitionStatus(tcId, 'paused'); + } + return { success: true }; } @@ -467,6 +475,12 @@ export class TasksService { message: dto.message, }); + // Sync running status to TaskCast (deterministic ID — no DB lookup) + if (task.currentExecutionId) { + const tcId = TaskCastService.taskcastId(task.currentExecutionId); + await this.taskCastService.transitionStatus(tcId, 'in_progress'); + } + return { success: true }; } @@ -486,6 +500,12 @@ export class TasksService { message: dto.reason, }); + // Sync cancelled status to TaskCast (deterministic ID — no DB lookup) + if (task.currentExecutionId) { + const tcId = TaskCastService.taskcastId(task.currentExecutionId); + await this.taskCastService.transitionStatus(tcId, 'stopped'); + } + return { success: true }; } @@ -621,6 +641,21 @@ export class TasksService { ), ); + // Sync running status to TaskCast (unblock) — deterministic ID, no DB lookup + const tcId = TaskCastService.taskcastId(intervention.executionId); + await this.taskCastService.transitionStatus(tcId, 'in_progress'); + await this.taskCastService.publishEvent(tcId, { + type: 'intervention', + data: { + intervention: { + ...updated, + status: 'resolved', + }, + }, + seriesId: `intervention:${interventionId}`, + seriesMode: 'latest', + }); + // Publish resume command via RabbitMQ await this.publishTaskCommand({ type: 'resume', From 5d4e79b538188e3ee3a682d673879481141b7d8a Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 13:57:00 +0800 Subject: [PATCH 12/68] feat(tasks): add SSE proxy endpoint for TaskCast event streaming Co-Authored-By: Claude Sonnet 4.6 --- .../src/tasks/tasks-stream.controller.ts | 171 ++++++++++++++++++ .../apps/gateway/src/tasks/tasks.module.ts | 3 +- 2 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 apps/server/apps/gateway/src/tasks/tasks-stream.controller.ts diff --git a/apps/server/apps/gateway/src/tasks/tasks-stream.controller.ts b/apps/server/apps/gateway/src/tasks/tasks-stream.controller.ts new file mode 100644 index 00000000..65430a67 --- /dev/null +++ b/apps/server/apps/gateway/src/tasks/tasks-stream.controller.ts @@ -0,0 +1,171 @@ +import { + Controller, + Get, + Param, + Query, + Req, + Res, + Logger, + NotFoundException, + Inject, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import type { Request, Response } from 'express'; +import { JwtService } from '@nestjs/jwt'; +import { + DATABASE_CONNECTION, + eq, + and, + type PostgresJsDatabase, +} from '@team9/database'; +import * as schema from '@team9/database/schemas'; + +@Controller({ path: 'tasks', version: '1' }) +export class TasksStreamController { + private readonly logger = new Logger(TasksStreamController.name); + private readonly taskcastUrl: string; + + constructor( + @Inject(DATABASE_CONNECTION) + private readonly db: PostgresJsDatabase, + private readonly jwtService: JwtService, + configService: ConfigService, + ) { + this.taskcastUrl = configService.get( + 'TASKCAST_URL', + 'http://localhost:3721', + ); + } + + @Get(':taskId/executions/:execId/stream') + async streamExecution( + @Param('taskId') taskId: string, + @Param('execId') execId: string, + @Query('token') queryToken: string | undefined, + @Req() req: Request, + @Res() res: Response, + ): Promise { + // ── Auth: accept Bearer header or ?token= query param ── + const headerToken = req.headers.authorization?.replace('Bearer ', ''); + const token = headerToken || queryToken; + + if (!token) { + res.status(401).json({ error: 'Unauthorized' }); + return; + } + + let userId: string; + try { + const payload = this.jwtService.verify(token); + userId = payload.sub; + } catch { + res.status(401).json({ error: 'Invalid token' }); + return; + } + + // ── Deterministic TaskCast ID — no DB lookup for taskcastTaskId ── + const taskcastTaskId = `agent_task_exec_${execId}`; + + // ── Verify execution exists and belongs to this task ── + const [execution] = await this.db + .select({ id: schema.agentTaskExecutions.id }) + .from(schema.agentTaskExecutions) + .where( + and( + eq(schema.agentTaskExecutions.id, execId), + eq(schema.agentTaskExecutions.taskId, taskId), + ), + ) + .limit(1); + + if (!execution) { + throw new NotFoundException('Execution not found'); + } + + // ── Verify user belongs to the task's workspace ── + const [task] = await this.db + .select({ tenantId: schema.agentTasks.tenantId }) + .from(schema.agentTasks) + .where(eq(schema.agentTasks.id, taskId)) + .limit(1); + + if (task) { + const [membership] = await this.db + .select({ id: schema.tenantMembers.id }) + .from(schema.tenantMembers) + .where( + and( + eq(schema.tenantMembers.tenantId, task.tenantId), + eq(schema.tenantMembers.userId, userId), + ), + ) + .limit(1); + + if (!membership) { + res.status(403).json({ error: 'Forbidden' }); + return; + } + } + + // ── Proxy SSE from TaskCast ── + const upstream = `${this.taskcastUrl}/tasks/${taskcastTaskId}/events/stream`; + const headers: Record = { + Accept: 'text/event-stream', + }; + + const lastEventId = req.headers['last-event-id'] as string | undefined; + if (lastEventId) { + headers['Last-Event-ID'] = lastEventId; + } + + const controller = new AbortController(); + + // Clean up upstream when client disconnects + req.on('close', () => controller.abort()); + + try { + const upstreamRes = await fetch(upstream, { + headers, + signal: controller.signal, + }); + + if (!upstreamRes.ok || !upstreamRes.body) { + res.status(502).json({ error: 'TaskCast upstream unavailable' }); + return; + } + + // Set SSE headers + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.setHeader('X-Accel-Buffering', 'no'); + res.flushHeaders(); + + // Pipe upstream → client + const reader = upstreamRes.body.getReader(); + const decoder = new TextDecoder(); + + const pump = async () => { + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + res.write(decoder.decode(value, { stream: true })); + } + } catch { + // Client disconnected or abort — expected + } finally { + res.end(); + } + }; + + pump(); + } catch (error) { + if ((error as Error).name === 'AbortError') return; + this.logger.error(`SSE proxy error: ${error}`); + if (!res.headersSent) { + res.status(502).json({ error: 'TaskCast upstream unavailable' }); + } + } + } +} diff --git a/apps/server/apps/gateway/src/tasks/tasks.module.ts b/apps/server/apps/gateway/src/tasks/tasks.module.ts index d4cd9ec1..79f28569 100644 --- a/apps/server/apps/gateway/src/tasks/tasks.module.ts +++ b/apps/server/apps/gateway/src/tasks/tasks.module.ts @@ -8,10 +8,11 @@ import { TaskBotController } from './task-bot.controller.js'; import { TaskBotService } from './task-bot.service.js'; import { TaskCastService } from './taskcast.service.js'; import { TriggersService } from './triggers.service.js'; +import { TasksStreamController } from './tasks-stream.controller.js'; @Module({ imports: [AuthModule, DocumentsModule, forwardRef(() => WebsocketModule)], - controllers: [TasksController, TaskBotController], + controllers: [TasksController, TaskBotController, TasksStreamController], providers: [TasksService, TaskBotService, TaskCastService, TriggersService], exports: [TasksService, TaskCastService, TriggersService], }) From 77ebf107c3b582414861306c6e966a1248baf747 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 13:57:41 +0800 Subject: [PATCH 13/68] chore: add @taskcast/client to frontend Co-Authored-By: Claude Opus 4.6 --- apps/client/package.json | 1 + pnpm-lock.yaml | 25 +++++++++++++++++++++++++ 2 files changed, 26 insertions(+) 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/pnpm-lock.yaml b/pnpm-lock.yaml index b0bc95e8..f46fafd7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,6 +140,9 @@ importers: "@tanstack/router-plugin": specifier: ^1.141.7 version: 1.143.11(@tanstack/react-router@1.143.11(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.3)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(webpack@5.103.0(@swc/core@1.15.7)) + "@taskcast/client": + specifier: ^1.1.0 + version: 1.1.0(@taskcast/core@1.1.0) "@tauri-apps/api": specifier: ^2.10.1 version: 2.10.1 @@ -6698,6 +6701,14 @@ packages: } engines: { node: ">=12" } + "@taskcast/client@1.1.0": + resolution: + { + integrity: sha512-1PGcir8fEDol+8UH7jtSwCyVtcRN8bmPu9bEtOiPQZeQgGEteXligcwCElb/lI842+q2eABIwIMKnqhf/jaACg==, + } + peerDependencies: + "@taskcast/core": ^1.1.0 + "@taskcast/core@1.1.0": resolution: { @@ -9341,6 +9352,13 @@ packages: } engines: { node: ">=0.8.x" } + eventsource-parser@2.0.1: + resolution: + { + integrity: sha512-gMaRLm5zejEH9mNXC54AnIteFI9YwL/q5JKMdBnoG+lEI1JWVGFVk0Taaj9Xb5SKgzIBDZoQX5IzMe44ILWODg==, + } + engines: { node: ">=18.0.0" } + execa@5.1.1: resolution: { @@ -18576,6 +18594,11 @@ snapshots: "@tanstack/virtual-file-routes@1.141.0": {} + "@taskcast/client@1.1.0(@taskcast/core@1.1.0)": + dependencies: + "@taskcast/core": 1.1.0 + eventsource-parser: 2.0.1 + "@taskcast/core@1.1.0": dependencies: js-yaml: 4.1.1 @@ -20150,6 +20173,8 @@ snapshots: events@3.3.0: {} + eventsource-parser@2.0.1: {} + execa@5.1.1: dependencies: cross-spawn: 7.0.6 From 9f259a0fadb3088a071a8e62c220b7e30913c46d Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 13:59:32 +0800 Subject: [PATCH 14/68] feat(client): add useExecutionStream SSE hook for TaskCast events Co-Authored-By: Claude Sonnet 4.6 --- apps/client/src/hooks/useExecutionStream.ts | 68 +++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 apps/client/src/hooks/useExecutionStream.ts diff --git a/apps/client/src/hooks/useExecutionStream.ts b/apps/client/src/hooks/useExecutionStream.ts new file mode 100644 index 00000000..208c5b9d --- /dev/null +++ b/apps/client/src/hooks/useExecutionStream.ts @@ -0,0 +1,68 @@ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +const API_BASE_URL = + import.meta.env.VITE_API_BASE_URL || "http://localhost:3000/api"; + +/** + * Opens an SSE connection to the TaskCast proxy for a specific execution. + * Invalidates React Query caches when events arrive. + * + * `taskcastTaskId` is used as a boolean gate — if null/undefined, TaskCast + * was not set up for this execution and no SSE connection is opened. + * The actual TaskCast ID is computed server-side from the execId (deterministic). + */ +export function useExecutionStream( + taskId: string, + execId: string | undefined, + taskcastTaskId: string | null | undefined, + enabled: boolean, +): void { + const queryClient = useQueryClient(); + + useEffect(() => { + if (!enabled || !execId || !taskcastTaskId) return; + + const token = localStorage.getItem("auth_token"); + if (!token) return; + + const url = `${API_BASE_URL}/v1/tasks/${taskId}/executions/${execId}/stream?token=${encodeURIComponent(token)}`; + const eventSource = new EventSource(url); + + eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + + // Invalidate relevant caches based on event type + if ( + data.type === "step" || + data.type === "intervention" || + data.type === "deliverable" + ) { + queryClient.invalidateQueries({ + queryKey: ["task-execution-entries", taskId, execId], + }); + } + + // Status change events invalidate the task and execution queries + if (data.type === "status_changed") { + queryClient.invalidateQueries({ queryKey: ["task", taskId] }); + queryClient.invalidateQueries({ + queryKey: ["task-executions", taskId], + }); + queryClient.invalidateQueries({ + queryKey: ["task-execution", taskId, execId], + }); + } + } catch { + // Ignore parse errors (e.g. heartbeat messages) + } + }; + + eventSource.onerror = () => { + // EventSource auto-reconnects on error; no action needed. + }; + + return () => eventSource.close(); + }, [taskId, execId, taskcastTaskId, enabled, queryClient]); +} From 8f9177ac018fe19831f4635608b1c917eb92d65f Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 13:59:59 +0800 Subject: [PATCH 15/68] feat(client): add SSE streaming to TaskDetailPanel, reduce polling when SSE active Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/tasks/TaskDetailPanel.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/client/src/components/tasks/TaskDetailPanel.tsx b/apps/client/src/components/tasks/TaskDetailPanel.tsx index 052365ea..e6e756e6 100644 --- a/apps/client/src/components/tasks/TaskDetailPanel.tsx +++ b/apps/client/src/components/tasks/TaskDetailPanel.tsx @@ -8,6 +8,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { tasksApi } from "@/services/api/tasks"; import { useAppStore } from "@/stores"; +import { useExecutionStream } from "@/hooks/useExecutionStream"; import { TaskBasicInfoTab } from "./TaskBasicInfoTab"; import { TaskDocumentTab } from "./TaskDocumentTab"; import { TaskRunsTab } from "./TaskRunsTab"; @@ -36,7 +37,9 @@ export function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProps) { } = useQuery({ queryKey: ["task", taskId], queryFn: () => tasksApi.getById(taskId), - refetchInterval: 5000, // Poll while panel is open + refetchInterval: task?.currentExecution?.execution.taskcastTaskId + ? 30000 + : 5000, }); // Track active tab & whether viewing an active run @@ -47,6 +50,15 @@ export function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProps) { // - Info tab: task has an active execution // - Runs tab: viewing a specific active run const taskIsActive = task ? ACTIVE_STATUSES.includes(task.status) : false; + + // SSE for real-time execution progress + useExecutionStream( + taskId, + task?.currentExecution?.execution.id, + task?.currentExecution?.execution.taskcastTaskId, + taskIsActive, + ); + const hasExecution = !!task?.currentExecution; const showMessageInput = (activeTab === "info" && taskIsActive && hasExecution) || From 50c121c855b1c042ae884991255596a651945890 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 14:00:16 +0800 Subject: [PATCH 16/68] feat(client): add SSE streaming to TaskBasicInfoTab Co-Authored-By: Claude Sonnet 4.6 --- .../client/src/components/tasks/TaskBasicInfoTab.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/client/src/components/tasks/TaskBasicInfoTab.tsx b/apps/client/src/components/tasks/TaskBasicInfoTab.tsx index 78bed1ac..be4a7f0f 100644 --- a/apps/client/src/components/tasks/TaskBasicInfoTab.tsx +++ b/apps/client/src/components/tasks/TaskBasicInfoTab.tsx @@ -27,6 +27,7 @@ import { } from "@/components/ui/select"; import { tasksApi } from "@/services/api/tasks"; import { api } from "@/services/api"; +import { useExecutionStream } from "@/hooks/useExecutionStream"; import { useSelectedWorkspaceId } from "@/stores/useWorkspaceStore"; import type { OpenClawBotInfo } from "@/services/api/applications"; import { TaskInterventionCard } from "./TaskInterventionCard"; @@ -231,9 +232,18 @@ export function TaskBasicInfoTab({ queryKey: ["task-execution-entries", taskId, execution?.id], queryFn: () => tasksApi.getExecutionEntries(taskId, execution!.id), enabled: !!execution, - refetchInterval: 5000, + refetchInterval: execution?.taskcastTaskId ? 30000 : 5000, }); + // SSE for real-time entries updates + useExecutionStream( + taskId, + execution?.id, + execution?.taskcastTaskId, + !!execution && + ["in_progress", "pending_action", "paused"].includes(task.status), + ); + const handleStartTask = () => { setShowStartDialog(true); }; From 08e67175cd3f7cc4936836edbd22adfd3500c7f0 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 14:00:44 +0800 Subject: [PATCH 17/68] feat(client): add SSE streaming to RunDetailView, reduce polling when SSE active Co-Authored-By: Claude Sonnet 4.6 --- apps/client/src/components/tasks/RunDetailView.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/client/src/components/tasks/RunDetailView.tsx b/apps/client/src/components/tasks/RunDetailView.tsx index 9404b8ed..889be193 100644 --- a/apps/client/src/components/tasks/RunDetailView.tsx +++ b/apps/client/src/components/tasks/RunDetailView.tsx @@ -7,6 +7,7 @@ import { Badge } from "@/components/ui/badge"; import { Separator } from "@/components/ui/separator"; import { Textarea } from "@/components/ui/textarea"; import { tasksApi } from "@/services/api/tasks"; +import { useExecutionStream } from "@/hooks/useExecutionStream"; import { ExecutionTimeline, type TimelineUserMessage, @@ -58,7 +59,7 @@ export function RunDetailView({ const { data: execution, isLoading: execLoading } = useQuery({ queryKey: ["task-execution", taskId, executionId], queryFn: () => tasksApi.getExecution(taskId, executionId), - refetchInterval: 5000, + refetchInterval: execution?.taskcastTaskId ? 30000 : 5000, }); // Notify parent of this run's channelId for the message input @@ -66,6 +67,9 @@ export function RunDetailView({ const isActive = execution ? ACTIVE_STATUSES.includes(execution.status) : false; + + useExecutionStream(taskId, executionId, execution?.taskcastTaskId, isActive); + useEffect(() => { onChannelChange?.(isActive ? channelId : null); return () => onChannelChange?.(null); @@ -74,7 +78,7 @@ export function RunDetailView({ const { data: entries = [] } = useQuery({ queryKey: ["task-execution-entries", taskId, executionId], queryFn: () => tasksApi.getExecutionEntries(taskId, executionId), - refetchInterval: 5000, + refetchInterval: execution?.taskcastTaskId ? 30000 : 5000, }); const retryMutation = useMutation({ From 762dd8a85b9833b83c3aea6f633cf1ab12ddde75 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 14:11:39 +0800 Subject: [PATCH 18/68] test(tasks): add unit tests for TaskCastService Co-Authored-By: Claude Sonnet 4.6 --- .../src/tasks/taskcast.service.spec.ts | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 apps/server/apps/gateway/src/tasks/taskcast.service.spec.ts diff --git a/apps/server/apps/gateway/src/tasks/taskcast.service.spec.ts b/apps/server/apps/gateway/src/tasks/taskcast.service.spec.ts new file mode 100644 index 00000000..1bb50234 --- /dev/null +++ b/apps/server/apps/gateway/src/tasks/taskcast.service.spec.ts @@ -0,0 +1,248 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; + +const mockCreateTask = jest.fn(); +const mockTransitionTask = jest.fn(); +const mockPublishEvent = jest.fn(); + +jest.unstable_mockModule('@taskcast/server-sdk', () => ({ + TaskcastServerClient: jest.fn().mockImplementation(() => ({ + createTask: mockCreateTask, + transitionTask: mockTransitionTask, + publishEvent: mockPublishEvent, + })), +})); + +const configService = { + get: jest.fn().mockReturnValue('http://localhost:3721'), +}; + +describe('TaskCastService', () => { + let TaskCastService: any; + let service: any; + + beforeEach(async () => { + jest.clearAllMocks(); + configService.get.mockReturnValue('http://localhost:3721'); + + ({ TaskCastService } = await import('./taskcast.service.js')); + service = new TaskCastService(configService as any); + }); + + // ── Constructor ──────────────────────────────────────────────────── + + describe('constructor', () => { + it('should create client with baseUrl from ConfigService', async () => { + const { TaskcastServerClient } = await import('@taskcast/server-sdk'); + expect(TaskcastServerClient).toHaveBeenCalledWith({ + baseUrl: 'http://localhost:3721', + }); + }); + + it('should use the value returned by ConfigService.get for TASKCAST_URL', async () => { + configService.get.mockReturnValue('http://custom-taskcast:9999'); + const { TaskCastService: Svc } = await import('./taskcast.service.js'); + new Svc(configService as any); + expect(configService.get).toHaveBeenCalledWith( + 'TASKCAST_URL', + 'http://localhost:3721', + ); + }); + }); + + // ── createTask ───────────────────────────────────────────────────── + + describe('createTask', () => { + const params = { + taskId: 'task-abc', + executionId: 'exec-123', + botId: 'bot-xyz', + tenantId: 'tenant-999', + }; + + it('should call SDK createTask with deterministic ID and correct metadata', async () => { + mockCreateTask.mockResolvedValue({ id: 'agent_task_exec_exec-123' }); + + const result = await service.createTask(params); + + expect(mockCreateTask).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'agent_task_exec_exec-123', + type: `agent_task.${params.taskId}`, + ttl: 86400, + metadata: { + taskId: params.taskId, + executionId: params.executionId, + botId: params.botId, + tenantId: params.tenantId, + }, + }), + ); + expect(result).toBe('agent_task_exec_exec-123'); + }); + + it('should use provided ttl when specified', async () => { + mockCreateTask.mockResolvedValue({ id: 'agent_task_exec_exec-123' }); + + await service.createTask({ ...params, ttl: 3600 }); + + expect(mockCreateTask).toHaveBeenCalledWith( + expect.objectContaining({ ttl: 3600 }), + ); + }); + + it('should return task.id from the SDK response', async () => { + mockCreateTask.mockResolvedValue({ id: 'returned-task-id' }); + + const result = await service.createTask(params); + + expect(result).toBe('returned-task-id'); + }); + + it('should return null when SDK throws (fire-and-forget)', async () => { + mockCreateTask.mockRejectedValue(new Error('network error')); + + const result = await service.createTask(params); + + expect(result).toBeNull(); + }); + }); + + // ── transitionStatus ─────────────────────────────────────────────── + + describe('transitionStatus', () => { + const taskId = 'agent_task_exec_exec-123'; + + const statusMappings: Array<[string, string]> = [ + ['in_progress', 'running'], + ['paused', 'paused'], + ['pending_action', 'blocked'], + ['completed', 'completed'], + ['failed', 'failed'], + ['timeout', 'timeout'], + ['stopped', 'cancelled'], + ]; + + for (const [team9Status, taskcastStatus] of statusMappings) { + it(`should map Team9 status '${team9Status}' to TaskCast status '${taskcastStatus}'`, async () => { + mockTransitionTask.mockResolvedValue(undefined); + + await service.transitionStatus(taskId, team9Status); + + expect(mockTransitionTask).toHaveBeenCalledWith(taskId, taskcastStatus); + }); + } + + it('should silently ignore unmapped status (e.g. upcoming)', async () => { + await service.transitionStatus(taskId, 'upcoming'); + + expect(mockTransitionTask).not.toHaveBeenCalled(); + }); + + it('should silently ignore other unmapped statuses', async () => { + await service.transitionStatus(taskId, 'unknown_status'); + + expect(mockTransitionTask).not.toHaveBeenCalled(); + }); + + it('should catch SDK errors silently and not rethrow', async () => { + mockTransitionTask.mockRejectedValue(new Error('SDK error')); + + await expect( + service.transitionStatus(taskId, 'completed'), + ).resolves.toBeUndefined(); + }); + }); + + // ── publishEvent ─────────────────────────────────────────────────── + + describe('publishEvent', () => { + const taskId = 'agent_task_exec_exec-123'; + + it('should forward event to SDK with level info', async () => { + mockPublishEvent.mockResolvedValue(undefined); + + const event = { + type: 'step.completed', + data: { step: 1, message: 'done' }, + }; + + await service.publishEvent(taskId, event); + + expect(mockPublishEvent).toHaveBeenCalledWith(taskId, { + type: event.type, + level: 'info', + data: event.data, + seriesId: undefined, + seriesMode: undefined, + }); + }); + + it('should forward seriesId and seriesMode when provided', async () => { + mockPublishEvent.mockResolvedValue(undefined); + + const event = { + type: 'progress', + data: { pct: 50 }, + seriesId: 'series-001', + seriesMode: 'latest' as const, + }; + + await service.publishEvent(taskId, event); + + expect(mockPublishEvent).toHaveBeenCalledWith(taskId, { + type: event.type, + level: 'info', + data: event.data, + seriesId: 'series-001', + seriesMode: 'latest', + }); + }); + + it('should catch SDK errors silently and not rethrow', async () => { + mockPublishEvent.mockRejectedValue(new Error('publish failed')); + + await expect( + service.publishEvent(taskId, { type: 'ev', data: {} }), + ).resolves.toBeUndefined(); + }); + }); + + // ── deleteTask ───────────────────────────────────────────────────── + + describe('deleteTask', () => { + it('should be a no-op and not throw', async () => { + await expect( + service.deleteTask('agent_task_exec_exec-123'), + ).resolves.toBeUndefined(); + }); + + it('should not call any SDK methods', async () => { + await service.deleteTask('agent_task_exec_exec-123'); + + expect(mockCreateTask).not.toHaveBeenCalled(); + expect(mockTransitionTask).not.toHaveBeenCalled(); + expect(mockPublishEvent).not.toHaveBeenCalled(); + }); + }); + + // ── static taskcastId ────────────────────────────────────────────── + + describe('static taskcastId', () => { + it('should return agent_task_exec_ prefixed executionId', () => { + expect(TaskCastService.taskcastId('exec-123')).toBe( + 'agent_task_exec_exec-123', + ); + }); + + it('should handle arbitrary executionId strings', () => { + expect(TaskCastService.taskcastId('abc-def-ghi')).toBe( + 'agent_task_exec_abc-def-ghi', + ); + }); + + it('should handle uuid-style executionId', () => { + const uuid = '550e8400-e29b-41d4-a716-446655440000'; + expect(TaskCastService.taskcastId(uuid)).toBe(`agent_task_exec_${uuid}`); + }); + }); +}); From c97bd0bea9c6996f921e81999fa2f51acf806ba6 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 14:12:44 +0800 Subject: [PATCH 19/68] test(webhook): add unit tests for WebhookController timeout handler Set up jest with ts-jest and ESM support in task-worker, and add 8 unit tests covering secret validation, ID parsing, execution lookup, and the full timeout status-update path. Co-Authored-By: Claude Sonnet 4.6 --- apps/server/apps/task-worker/jest.config.cjs | 30 ++ apps/server/apps/task-worker/package.json | 7 +- .../src/webhook/webhook.controller.spec.ts | 196 +++++++++++++ pnpm-lock.yaml | 259 +++++++++++++++--- 4 files changed, 454 insertions(+), 38 deletions(-) create mode 100644 apps/server/apps/task-worker/jest.config.cjs create mode 100644 apps/server/apps/task-worker/src/webhook/webhook.controller.spec.ts diff --git a/apps/server/apps/task-worker/jest.config.cjs b/apps/server/apps/task-worker/jest.config.cjs new file mode 100644 index 00000000..72a1bb85 --- /dev/null +++ b/apps/server/apps/task-worker/jest.config.cjs @@ -0,0 +1,30 @@ +/** @type {import('jest').Config} */ +module.exports = { + moduleFileExtensions: ['js', 'json', 'ts'], + rootDir: '.', + testMatch: ['/src/**/*.spec.ts'], + transform: { + '^.+\\.(t|j)s$': [ + 'ts-jest', + { + useESM: true, + tsconfig: '/tsconfig.json', + }, + ], + }, + collectCoverageFrom: ['src/**/*.(t|j)s'], + coverageDirectory: 'coverage', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + '^@team9/redis$': '/../../libs/redis/src/index.ts', + '^@team9/shared$': '/../../libs/shared/src/index.ts', + '^@team9/database$': '/../../libs/database/src/index.ts', + '^@team9/database/schemas$': '/../../libs/database/src/schemas/index.ts', + '^@team9/rabbitmq$': '/../../libs/rabbitmq/src/index.ts', + }, + transformIgnorePatterns: [ + 'node_modules/(?!(@team9)/)', + ], +}; diff --git a/apps/server/apps/task-worker/package.json b/apps/server/apps/task-worker/package.json index d0dce87d..a360f2f3 100644 --- a/apps/server/apps/task-worker/package.json +++ b/apps/server/apps/task-worker/package.json @@ -9,7 +9,7 @@ "start:dev": "node --loader @swc-node/register/esm --watch src/main.ts", "start:debug": "node --inspect --loader @swc-node/register/esm src/main.ts", "start:prod": "node dist/main.js", - "test": "jest", + "test": "NODE_OPTIONS='--experimental-vm-modules' jest", "test:watch": "jest --watch", "test:cov": "jest --coverage" }, @@ -30,6 +30,11 @@ "uuid": "^13.0.0" }, "devDependencies": { + "@jest/globals": "^30.2.0", + "@nestjs/testing": "^11.1.10", + "@types/jest": "^30.0.0", + "jest": "^30.2.0", + "ts-jest": "^29.4.6", "typescript": "^5.7.3" } } diff --git a/apps/server/apps/task-worker/src/webhook/webhook.controller.spec.ts b/apps/server/apps/task-worker/src/webhook/webhook.controller.spec.ts new file mode 100644 index 00000000..cd951570 --- /dev/null +++ b/apps/server/apps/task-worker/src/webhook/webhook.controller.spec.ts @@ -0,0 +1,196 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { ForbiddenException } from '@nestjs/common'; +import { WebhookController } from './webhook.controller.js'; +import { DATABASE_CONNECTION } from '@team9/database'; +import { ConfigService } from '@nestjs/config'; + +type MockFn = jest.Mock<(...args: any[]) => any>; + +function mockDb() { + const chain: Record = {}; + const methods = [ + 'select', + 'from', + 'where', + 'limit', + 'insert', + 'values', + 'returning', + 'update', + 'set', + ]; + for (const m of methods) { + chain[m] = jest.fn().mockReturnValue(chain); + } + // Default: no execution found + chain.limit.mockResolvedValue([]); + chain.returning.mockResolvedValue([]); + return chain; +} + +const EXEC_ID = 'exec-uuid-1234'; +const TASK_ID = 'task-uuid-5678'; +const TASKCAST_ID = `agent_task_exec_${EXEC_ID}`; + +describe('WebhookController', () => { + let controller: WebhookController; + let db: ReturnType; + + const configService = { + get: jest.fn().mockImplementation((key: string) => { + if (key === 'TASKCAST_WEBHOOK_SECRET') return 'test-secret'; + return undefined; + }), + }; + + beforeEach(async () => { + db = mockDb(); + + const module: TestingModule = await Test.createTestingModule({ + controllers: [WebhookController], + providers: [ + { provide: DATABASE_CONNECTION, useValue: db }, + { provide: ConfigService, useValue: configService }, + ], + }).compile(); + + controller = module.get(WebhookController); + }); + + // ────────────────────────────────────────────────────────────────── + // Webhook secret validation + // ────────────────────────────────────────────────────────────────── + + describe('webhook secret validation', () => { + it('rejects request with wrong webhook secret', async () => { + await expect( + controller.handleTimeout( + { taskId: TASKCAST_ID, status: 'timeout' }, + 'wrong-secret', + ), + ).rejects.toThrow(ForbiddenException); + }); + + it('rejects request with missing webhook secret', async () => { + await expect( + controller.handleTimeout( + { taskId: TASKCAST_ID, status: 'timeout' }, + undefined, + ), + ).rejects.toThrow(ForbiddenException); + }); + + it('allows request with valid webhook secret', async () => { + // execution not found — just verifying no ForbiddenException + db.limit.mockResolvedValue([]); + + await expect( + controller.handleTimeout( + { taskId: TASKCAST_ID, status: 'timeout' }, + 'test-secret', + ), + ).resolves.toBeUndefined(); + }); + + it('allows request when no webhook secret is configured', async () => { + const noSecretConfig = { + get: jest.fn().mockReturnValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [WebhookController], + providers: [ + { provide: DATABASE_CONNECTION, useValue: db }, + { provide: ConfigService, useValue: noSecretConfig }, + ], + }).compile(); + + const controllerNoSecret = + module.get(WebhookController); + + db.limit.mockResolvedValue([]); + + // Should not throw even with no secret header + await expect( + controllerNoSecret.handleTimeout( + { taskId: TASKCAST_ID, status: 'timeout' }, + undefined, + ), + ).resolves.toBeUndefined(); + }); + }); + + // ────────────────────────────────────────────────────────────────── + // TaskCast ID parsing + // ────────────────────────────────────────────────────────────────── + + describe('TaskCast ID parsing', () => { + it('parses executionId from agent_task_exec_ prefix correctly', async () => { + db.limit.mockResolvedValue([{ id: EXEC_ID, taskId: TASK_ID }]); + + await controller.handleTimeout( + { taskId: TASKCAST_ID, status: 'timeout' }, + 'test-secret', + ); + + // The where clause on the first select should have been called, + // meaning the ID was parsed and used for DB lookup + expect(db.where).toHaveBeenCalled(); + }); + + it('returns early for unexpected TaskCast ID format (no prefix)', async () => { + await controller.handleTimeout( + { taskId: 'some_other_id_format', status: 'timeout' }, + 'test-secret', + ); + + // DB should never be queried for unknown format + expect(db.select).not.toHaveBeenCalled(); + }); + }); + + // ────────────────────────────────────────────────────────────────── + // Execution lookup + // ────────────────────────────────────────────────────────────────── + + describe('execution lookup', () => { + it('returns early when execution is not found', async () => { + db.limit.mockResolvedValue([]); + + await controller.handleTimeout( + { taskId: TASKCAST_ID, status: 'timeout' }, + 'test-secret', + ); + + // update should never be called if execution is absent + expect(db.update).not.toHaveBeenCalled(); + }); + }); + + // ────────────────────────────────────────────────────────────────── + // Timeout status update + // ────────────────────────────────────────────────────────────────── + + describe('timeout status update', () => { + it('updates both execution and task status to timeout on valid webhook', async () => { + db.limit.mockResolvedValue([{ id: EXEC_ID, taskId: TASK_ID }]); + + await controller.handleTimeout( + { taskId: TASKCAST_ID, status: 'timeout' }, + 'test-secret', + ); + + // update() should be called twice: once for execution, once for task + expect(db.update).toHaveBeenCalledTimes(2); + + // set() should carry 'timeout' status for execution update + const setCalls = (db.set as MockFn).mock.calls; + const executionSetCall = setCalls[0]?.[0] as Record; + expect(executionSetCall).toMatchObject({ status: 'timeout' }); + + const taskSetCall = setCalls[1]?.[0] as Record; + expect(taskSetCall).toMatchObject({ status: 'timeout' }); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f46fafd7..a2856f94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -695,6 +695,21 @@ importers: specifier: ^13.0.0 version: 13.0.0 devDependencies: + "@jest/globals": + specifier: ^30.2.0 + version: 30.2.0 + "@nestjs/testing": + specifier: ^11.1.10 + version: 11.1.10(@nestjs/common@11.1.10(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.10)(@nestjs/microservices@11.1.11)(@nestjs/platform-express@11.1.10) + "@types/jest": + specifier: ^30.0.0 + version: 30.0.0 + jest: + specifier: ^30.2.0 + version: 30.2.0(@types/node@25.0.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3)) + ts-jest: + specifier: ^29.4.6 + version: 29.4.6(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.0.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3)))(typescript@5.8.3) typescript: specifier: ^5.7.3 version: 5.8.3 @@ -8341,12 +8356,6 @@ packages: integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==, } - cjs-module-lexer@2.1.1: - resolution: - { - integrity: sha512-+CmxIZ/L2vNcEfvNtLdU0ZQ6mbq3FZnwAP2PPTiKP+1QOoKwlKlPgb8UKV0Dds7QVaMnHm+FwSft2VB0s/SLjQ==, - } - cjs-module-lexer@2.2.0: resolution: { @@ -15503,7 +15512,7 @@ snapshots: "@jest/console@30.2.0": dependencies: "@jest/types": 30.2.0 - "@types/node": 22.19.3 + "@types/node": 25.0.3 chalk: 4.1.2 jest-message-util: 30.2.0 jest-util: 30.2.0 @@ -15517,14 +15526,50 @@ snapshots: "@jest/test-result": 30.2.0 "@jest/transform": 30.2.0 "@jest/types": 30.2.0 - "@types/node": 22.19.3 + "@types/node": 25.0.3 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 4.3.1 exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.2.0 - jest-config: 30.2.0(@types/node@22.19.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@22.19.3)(typescript@5.8.3)) + jest-config: 30.2.0(@types/node@25.0.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@22.19.3)(typescript@5.8.3)) + jest-haste-map: 30.2.0 + jest-message-util: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-resolve-dependencies: 30.2.0 + jest-runner: 30.2.0 + jest-runtime: 30.2.0 + jest-snapshot: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + jest-watcher: 30.2.0 + micromatch: 4.0.8 + pretty-format: 30.2.0 + slash: 3.0.0 + transitivePeerDependencies: + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + + "@jest/core@30.2.0(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3))": + dependencies: + "@jest/console": 30.2.0 + "@jest/pattern": 30.0.1 + "@jest/reporters": 30.2.0 + "@jest/test-result": 30.2.0 + "@jest/transform": 30.2.0 + "@jest/types": 30.2.0 + "@types/node": 25.0.3 + ansi-escapes: 4.3.2 + chalk: 4.1.2 + ci-info: 4.3.1 + exit-x: 0.2.2 + graceful-fs: 4.2.11 + jest-changed-files: 30.2.0 + jest-config: 30.2.0(@types/node@25.0.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3)) jest-haste-map: 30.2.0 jest-message-util: 30.2.0 jest-regex-util: 30.0.1 @@ -15551,7 +15596,7 @@ snapshots: dependencies: "@jest/fake-timers": 30.2.0 "@jest/types": 30.2.0 - "@types/node": 22.19.3 + "@types/node": 25.0.3 jest-mock: 30.2.0 "@jest/expect-utils@30.2.0": @@ -15569,7 +15614,7 @@ snapshots: dependencies: "@jest/types": 30.2.0 "@sinonjs/fake-timers": 13.0.5 - "@types/node": 22.19.3 + "@types/node": 25.0.3 jest-message-util: 30.2.0 jest-mock: 30.2.0 jest-util: 30.2.0 @@ -15587,7 +15632,7 @@ snapshots: "@jest/pattern@30.0.1": dependencies: - "@types/node": 22.19.3 + "@types/node": 25.0.3 jest-regex-util: 30.0.1 "@jest/reporters@30.2.0": @@ -15598,7 +15643,7 @@ snapshots: "@jest/transform": 30.2.0 "@jest/types": 30.2.0 "@jridgewell/trace-mapping": 0.3.31 - "@types/node": 22.19.3 + "@types/node": 25.0.3 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit-x: 0.2.2 @@ -15675,7 +15720,7 @@ snapshots: "@jest/schemas": 30.0.5 "@types/istanbul-lib-coverage": 2.0.6 "@types/istanbul-reports": 3.0.4 - "@types/node": 22.19.3 + "@types/node": 25.0.3 "@types/yargs": 17.0.35 chalk: 4.1.2 @@ -18738,7 +18783,7 @@ snapshots: "@types/bunyan@1.8.11": dependencies: - "@types/node": 22.19.3 + "@types/node": 25.0.3 "@types/chai@5.2.3": dependencies: @@ -18747,13 +18792,13 @@ snapshots: "@types/connect@3.4.38": dependencies: - "@types/node": 22.19.3 + "@types/node": 25.0.3 "@types/cookiejar@2.1.5": {} "@types/cors@2.8.19": dependencies: - "@types/node": 22.19.3 + "@types/node": 25.0.3 "@types/debug@4.1.12": dependencies: @@ -18830,7 +18875,7 @@ snapshots: "@types/memcached@2.2.10": dependencies: - "@types/node": 22.19.3 + "@types/node": 25.0.3 "@types/methods@1.1.4": {} @@ -18838,11 +18883,11 @@ snapshots: "@types/mysql@2.15.26": dependencies: - "@types/node": 22.19.3 + "@types/node": 25.0.3 "@types/mysql@2.15.27": dependencies: - "@types/node": 22.19.3 + "@types/node": 25.0.3 "@types/node@22.19.3": dependencies: @@ -18876,7 +18921,7 @@ snapshots: "@types/pg@8.15.6": dependencies: - "@types/node": 22.19.3 + "@types/node": 25.0.3 pg-protocol: 1.10.3 pg-types: 2.2.0 @@ -18888,7 +18933,7 @@ snapshots: "@types/pg@8.6.1": dependencies: - "@types/node": 22.19.3 + "@types/node": 25.0.3 pg-protocol: 1.10.3 pg-types: 2.2.0 @@ -18910,7 +18955,7 @@ snapshots: "@types/send@1.2.1": dependencies: - "@types/node": 22.19.3 + "@types/node": 25.0.3 "@types/serve-static@2.2.0": dependencies: @@ -18939,7 +18984,7 @@ snapshots: "@types/tedious@4.0.14": dependencies: - "@types/node": 22.19.3 + "@types/node": 25.0.3 "@types/unist@2.0.11": {} @@ -19597,8 +19642,6 @@ snapshots: cjs-module-lexer@1.4.3: {} - cjs-module-lexer@2.1.1: {} - cjs-module-lexer@2.2.0: {} class-transformer@0.5.1: {} @@ -20839,7 +20882,7 @@ snapshots: "@jest/expect": 30.2.0 "@jest/test-result": 30.2.0 "@jest/types": 30.2.0 - "@types/node": 22.19.3 + "@types/node": 25.0.3 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.1 @@ -20878,6 +20921,25 @@ snapshots: - supports-color - ts-node + jest-cli@30.2.0(@types/node@25.0.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3)): + dependencies: + "@jest/core": 30.2.0(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3)) + "@jest/test-result": 30.2.0 + "@jest/types": 30.2.0 + chalk: 4.1.2 + exit-x: 0.2.2 + import-local: 3.2.0 + jest-config: 30.2.0(@types/node@25.0.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3)) + jest-util: 30.2.0 + jest-validate: 30.2.0 + yargs: 17.7.2 + transitivePeerDependencies: + - "@types/node" + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jest-config@30.2.0(@types/node@22.19.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@22.19.3)(typescript@5.8.3)): dependencies: "@babel/core": 7.28.5 @@ -20912,6 +20974,74 @@ snapshots: - babel-plugin-macros - supports-color + jest-config@30.2.0(@types/node@25.0.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@22.19.3)(typescript@5.8.3)): + dependencies: + "@babel/core": 7.28.5 + "@jest/get-type": 30.1.0 + "@jest/pattern": 30.0.1 + "@jest/test-sequencer": 30.2.0 + "@jest/types": 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 4.3.1 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.2.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.2.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + "@types/node": 25.0.3 + esbuild-register: 3.6.0(esbuild@0.27.2) + ts-node: 10.9.2(@swc/core@1.15.7)(@types/node@22.19.3)(typescript@5.8.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + + jest-config@30.2.0(@types/node@25.0.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3)): + dependencies: + "@babel/core": 7.28.5 + "@jest/get-type": 30.1.0 + "@jest/pattern": 30.0.1 + "@jest/test-sequencer": 30.2.0 + "@jest/types": 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.5) + chalk: 4.1.2 + ci-info: 4.3.1 + deepmerge: 4.3.1 + glob: 10.5.0 + graceful-fs: 4.2.11 + jest-circus: 30.2.0 + jest-docblock: 30.2.0 + jest-environment-node: 30.2.0 + jest-regex-util: 30.0.1 + jest-resolve: 30.2.0 + jest-runner: 30.2.0 + jest-util: 30.2.0 + jest-validate: 30.2.0 + micromatch: 4.0.8 + parse-json: 5.2.0 + pretty-format: 30.2.0 + slash: 3.0.0 + strip-json-comments: 3.1.1 + optionalDependencies: + "@types/node": 25.0.3 + esbuild-register: 3.6.0(esbuild@0.27.2) + ts-node: 10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3) + transitivePeerDependencies: + - babel-plugin-macros + - supports-color + jest-diff@30.2.0: dependencies: "@jest/diff-sequences": 30.0.1 @@ -20936,7 +21066,7 @@ snapshots: "@jest/environment": 30.2.0 "@jest/fake-timers": 30.2.0 "@jest/types": 30.2.0 - "@types/node": 22.19.3 + "@types/node": 25.0.3 jest-mock: 30.2.0 jest-util: 30.2.0 jest-validate: 30.2.0 @@ -20944,7 +21074,7 @@ snapshots: jest-haste-map@30.2.0: dependencies: "@jest/types": 30.2.0 - "@types/node": 22.19.3 + "@types/node": 25.0.3 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -20983,7 +21113,7 @@ snapshots: jest-mock@30.2.0: dependencies: "@jest/types": 30.2.0 - "@types/node": 22.19.3 + "@types/node": 25.0.3 jest-util: 30.2.0 jest-pnp-resolver@1.2.3(jest-resolve@30.2.0): @@ -21017,7 +21147,7 @@ snapshots: "@jest/test-result": 30.2.0 "@jest/transform": 30.2.0 "@jest/types": 30.2.0 - "@types/node": 22.19.3 + "@types/node": 25.0.3 chalk: 4.1.2 emittery: 0.13.1 exit-x: 0.2.2 @@ -21046,9 +21176,9 @@ snapshots: "@jest/test-result": 30.2.0 "@jest/transform": 30.2.0 "@jest/types": 30.2.0 - "@types/node": 22.19.3 + "@types/node": 25.0.3 chalk: 4.1.2 - cjs-module-lexer: 2.1.1 + cjs-module-lexer: 2.2.0 collect-v8-coverage: 1.0.3 glob: 10.5.0 graceful-fs: 4.2.11 @@ -21093,7 +21223,7 @@ snapshots: jest-util@30.2.0: dependencies: "@jest/types": 30.2.0 - "@types/node": 22.19.3 + "@types/node": 25.0.3 chalk: 4.1.2 ci-info: 4.3.1 graceful-fs: 4.2.11 @@ -21112,7 +21242,7 @@ snapshots: dependencies: "@jest/test-result": 30.2.0 "@jest/types": 30.2.0 - "@types/node": 22.19.3 + "@types/node": 25.0.3 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -21121,13 +21251,13 @@ snapshots: jest-worker@27.5.1: dependencies: - "@types/node": 22.19.3 + "@types/node": 25.0.3 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@30.2.0: dependencies: - "@types/node": 22.19.3 + "@types/node": 25.0.3 "@ungap/structured-clone": 1.3.0 jest-util: 30.2.0 merge-stream: 2.0.0 @@ -21146,6 +21276,19 @@ snapshots: - supports-color - ts-node + jest@30.2.0(@types/node@25.0.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3)): + dependencies: + "@jest/core": 30.2.0(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3)) + "@jest/types": 30.2.0 + import-local: 3.2.0 + jest-cli: 30.2.0(@types/node@25.0.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3)) + transitivePeerDependencies: + - "@types/node" + - babel-plugin-macros + - esbuild-register + - supports-color + - ts-node + jiti@2.6.1: {} js-tokens@4.0.0: {} @@ -22955,6 +23098,27 @@ snapshots: esbuild: 0.27.2 jest-util: 30.2.0 + ts-jest@29.4.6(@babel/core@7.28.5)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.5))(esbuild@0.27.2)(jest-util@30.2.0)(jest@30.2.0(@types/node@25.0.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3)))(typescript@5.8.3): + dependencies: + bs-logger: 0.2.6 + fast-json-stable-stringify: 2.1.0 + handlebars: 4.7.8 + jest: 30.2.0(@types/node@25.0.3)(esbuild-register@3.6.0(esbuild@0.27.2))(ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3)) + json5: 2.2.3 + lodash.memoize: 4.1.2 + make-error: 1.3.6 + semver: 7.7.3 + type-fest: 4.41.0 + typescript: 5.8.3 + yargs-parser: 21.1.1 + optionalDependencies: + "@babel/core": 7.28.5 + "@jest/transform": 30.2.0 + "@jest/types": 30.2.0 + babel-jest: 30.2.0(@babel/core@7.28.5) + esbuild: 0.27.2 + jest-util: 30.2.0 + ts-loader@9.5.4(typescript@5.8.3)(webpack@5.103.0(@swc/core@1.15.7)(esbuild@0.27.2)): dependencies: chalk: 4.1.2 @@ -23003,6 +23167,27 @@ snapshots: optionalDependencies: "@swc/core": 1.15.7 + ts-node@10.9.2(@swc/core@1.15.7)(@types/node@25.0.3)(typescript@5.8.3): + dependencies: + "@cspotcode/source-map-support": 0.8.1 + "@tsconfig/node10": 1.0.12 + "@tsconfig/node12": 1.0.11 + "@tsconfig/node14": 1.0.3 + "@tsconfig/node16": 1.0.4 + "@types/node": 25.0.3 + acorn: 8.15.0 + acorn-walk: 8.3.4 + arg: 4.1.3 + create-require: 1.1.1 + diff: 4.0.2 + make-error: 1.3.6 + typescript: 5.8.3 + v8-compile-cache-lib: 3.0.1 + yn: 3.1.1 + optionalDependencies: + "@swc/core": 1.15.7 + optional: true + tsconfig-paths-webpack-plugin@4.2.0: dependencies: chalk: 4.1.2 From 7a07a404172b5fdb5fb60416dfa235801260ac62 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 14:41:19 +0800 Subject: [PATCH 20/68] test(task-worker): add unit tests for TaskCastClient Co-Authored-By: Claude Sonnet 4.6 --- .../src/taskcast/taskcast.client.spec.ts | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 apps/server/apps/task-worker/src/taskcast/taskcast.client.spec.ts diff --git a/apps/server/apps/task-worker/src/taskcast/taskcast.client.spec.ts b/apps/server/apps/task-worker/src/taskcast/taskcast.client.spec.ts new file mode 100644 index 00000000..92a9bee8 --- /dev/null +++ b/apps/server/apps/task-worker/src/taskcast/taskcast.client.spec.ts @@ -0,0 +1,93 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; + +const mockCreateTask = jest.fn(); + +jest.unstable_mockModule('@taskcast/server-sdk', () => ({ + TaskcastServerClient: jest.fn().mockImplementation(() => ({ + createTask: mockCreateTask, + })), +})); + +const configService = { + get: jest.fn().mockReturnValue('http://localhost:3721'), +}; + +describe('TaskCastClient', () => { + let TaskCastClient: any; + let client: any; + + const params = { + taskId: 'task-abc', + executionId: 'exec-123', + botId: 'bot-xyz', + tenantId: 'tenant-999', + }; + + beforeEach(async () => { + jest.clearAllMocks(); + configService.get.mockReturnValue('http://localhost:3721'); + + ({ TaskCastClient } = await import('./taskcast.client.js')); + client = new TaskCastClient(configService as any); + }); + + // ── createTask ───────────────────────────────────────────────────── + + describe('createTask', () => { + it('should pass deterministic ID and correct metadata to SDK createTask', async () => { + mockCreateTask.mockResolvedValue({ id: 'agent_task_exec_exec-123' }); + + await client.createTask(params); + + expect(mockCreateTask).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'agent_task_exec_exec-123', + type: `agent_task.${params.taskId}`, + ttl: 86400, + metadata: { + taskId: params.taskId, + executionId: params.executionId, + botId: params.botId, + tenantId: params.tenantId, + }, + }), + ); + }); + + it('should return task.id on success', async () => { + mockCreateTask.mockResolvedValue({ id: 'agent_task_exec_exec-123' }); + + const result = await client.createTask(params); + + expect(result).toBe('agent_task_exec_exec-123'); + }); + + it('should return null on SDK error (fire-and-forget)', async () => { + mockCreateTask.mockRejectedValue(new Error('network error')); + + const result = await client.createTask(params); + + expect(result).toBeNull(); + }); + + it('should use custom TTL when provided', async () => { + mockCreateTask.mockResolvedValue({ id: 'agent_task_exec_exec-123' }); + + await client.createTask({ ...params, ttl: 3600 }); + + expect(mockCreateTask).toHaveBeenCalledWith( + expect.objectContaining({ ttl: 3600 }), + ); + }); + + it('should default TTL to 86400 when not provided', async () => { + mockCreateTask.mockResolvedValue({ id: 'agent_task_exec_exec-123' }); + + await client.createTask(params); + + expect(mockCreateTask).toHaveBeenCalledWith( + expect.objectContaining({ ttl: 86400 }), + ); + }); + }); +}); From ebc4813e63f0906388b5d7aaeb36e8344aa0c4f6 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 15:53:14 +0800 Subject: [PATCH 21/68] test(tasks): add unit tests for TasksStreamController SSE proxy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers auth (no-token 401, invalid-JWT 401, header token, query-param token), execution NotFoundException, workspace membership 403, and deterministic TaskCast ID format — skipping actual SSE proxy / fetch streaming. Co-Authored-By: Claude Sonnet 4.6 --- .../src/tasks/tasks-stream.controller.spec.ts | 280 ++++++++++++++++++ 1 file changed, 280 insertions(+) create mode 100644 apps/server/apps/gateway/src/tasks/tasks-stream.controller.spec.ts diff --git a/apps/server/apps/gateway/src/tasks/tasks-stream.controller.spec.ts b/apps/server/apps/gateway/src/tasks/tasks-stream.controller.spec.ts new file mode 100644 index 00000000..82a924e8 --- /dev/null +++ b/apps/server/apps/gateway/src/tasks/tasks-stream.controller.spec.ts @@ -0,0 +1,280 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { Test, type TestingModule } from '@nestjs/testing'; +import { JwtService } from '@nestjs/jwt'; +import { ConfigService } from '@nestjs/config'; +import { NotFoundException } from '@nestjs/common'; +import { DATABASE_CONNECTION } from '@team9/database'; +import { TasksStreamController } from './tasks-stream.controller.js'; + +// ── Types ────────────────────────────────────────────────────────────────── + +type MockFn = jest.Mock<(...args: any[]) => any>; + +// ── DB mock factory ──────────────────────────────────────────────────────── + +function mockDb() { + const chain: Record = {}; + const methods = ['select', 'from', 'where', 'limit']; + for (const m of methods) { + chain[m] = jest.fn().mockReturnValue(chain); + } + chain.limit.mockResolvedValue([]); + return chain; +} + +// ── Request / Response helpers ───────────────────────────────────────────── + +function mockReq(overrides: Partial = {}) { + return { + headers: { authorization: undefined, 'last-event-id': undefined }, + on: jest.fn(), + ...overrides, + } as any; +} + +function mockRes() { + const res: any = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + setHeader: jest.fn(), + flushHeaders: jest.fn(), + write: jest.fn(), + end: jest.fn(), + headersSent: false, + }; + return res; +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/** + * Set up the db.limit mock to return different values on sequential calls. + * Call order: + * 1st call → execution lookup + * 2nd call → task lookup + * 3rd call → membership check + */ +function setupDbSequence( + db: ReturnType, + sequence: Array, +) { + let callCount = 0; + db.limit.mockImplementation((_n: number) => { + const result = sequence[callCount] ?? []; + callCount++; + return Promise.resolve(result); + }); +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +describe('TasksStreamController', () => { + let controller: TasksStreamController; + let jwtService: { verify: MockFn }; + let db: ReturnType; + + const TASK_ID = 'task-1'; + const EXEC_ID = 'exec-1'; + const USER_ID = 'user-1'; + const TENANT_ID = 'tenant-1'; + + beforeEach(async () => { + db = mockDb(); + jwtService = { verify: jest.fn().mockReturnValue({ sub: USER_ID }) }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [TasksStreamController], + providers: [ + { provide: DATABASE_CONNECTION, useValue: db }, + { provide: JwtService, useValue: jwtService }, + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue('http://localhost:3721'), + }, + }, + ], + }).compile(); + + controller = module.get(TasksStreamController); + }); + + // ── Auth: no token ─────────────────────────────────────────────────────── + + describe('authentication', () => { + it('returns 401 when no token is provided (no header, no query param)', async () => { + const req = mockReq({ headers: { authorization: undefined } }); + const res = mockRes(); + + await controller.streamExecution(TASK_ID, EXEC_ID, undefined, req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Unauthorized' }); + }); + + it('returns 401 when JWT is invalid (verify throws)', async () => { + jwtService.verify.mockImplementation(() => { + throw new Error('invalid signature'); + }); + + const req = mockReq({ + headers: { authorization: 'Bearer bad-token' }, + }); + const res = mockRes(); + + await controller.streamExecution(TASK_ID, EXEC_ID, undefined, req, res); + + expect(res.status).toHaveBeenCalledWith(401); + expect(res.json).toHaveBeenCalledWith({ error: 'Invalid token' }); + }); + + it('accepts token from Authorization Bearer header', async () => { + // Provide full DB sequence so the controller proceeds past auth + setupDbSequence(db, [ + [{ id: EXEC_ID }], + [{ tenantId: TENANT_ID }], + [{ id: 'member-1' }], + ]); + + // Mock fetch to avoid actual network call; return a non-ok response so + // it exits early after the auth/validation path. + const mockFetch = jest + .fn() + .mockResolvedValue({ ok: false, body: null }); + global.fetch = mockFetch as any; + + const req = mockReq({ + headers: { authorization: 'Bearer valid-token' }, + }); + const res = mockRes(); + + await controller.streamExecution(TASK_ID, EXEC_ID, undefined, req, res); + + expect(jwtService.verify).toHaveBeenCalledWith('valid-token'); + // Did not get a 401 — auth passed + expect(res.status).not.toHaveBeenCalledWith(401); + }); + + it('accepts token from ?token= query param', async () => { + setupDbSequence(db, [ + [{ id: EXEC_ID }], + [{ tenantId: TENANT_ID }], + [{ id: 'member-1' }], + ]); + + const mockFetch = jest + .fn() + .mockResolvedValue({ ok: false, body: null }); + global.fetch = mockFetch as any; + + const req = mockReq({ headers: {} }); + const res = mockRes(); + + await controller.streamExecution( + TASK_ID, + EXEC_ID, + 'query-token', + req, + res, + ); + + expect(jwtService.verify).toHaveBeenCalledWith('query-token'); + expect(res.status).not.toHaveBeenCalledWith(401); + }); + }); + + // ── Execution lookup ───────────────────────────────────────────────────── + + describe('execution lookup', () => { + it('throws NotFoundException when execution is not found', async () => { + // First limit() call returns empty → execution not found + setupDbSequence(db, [[]]); + + const req = mockReq({ + headers: { authorization: 'Bearer valid-token' }, + }); + const res = mockRes(); + + await expect( + controller.streamExecution(TASK_ID, EXEC_ID, undefined, req, res), + ).rejects.toThrow(NotFoundException); + }); + }); + + // ── Workspace membership check ─────────────────────────────────────────── + + describe('workspace membership', () => { + it('returns 403 when user is not a workspace member', async () => { + // execution found, task found with tenantId, membership empty + setupDbSequence(db, [[{ id: EXEC_ID }], [{ tenantId: TENANT_ID }], []]); + + const req = mockReq({ + headers: { authorization: 'Bearer valid-token' }, + }); + const res = mockRes(); + + await controller.streamExecution(TASK_ID, EXEC_ID, undefined, req, res); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' }); + }); + + it('does not return 403 when user is a workspace member', async () => { + setupDbSequence(db, [ + [{ id: EXEC_ID }], + [{ tenantId: TENANT_ID }], + [{ id: 'member-1' }], + ]); + + const mockFetch = jest + .fn() + .mockResolvedValue({ ok: false, body: null }); + global.fetch = mockFetch as any; + + const req = mockReq({ + headers: { authorization: 'Bearer valid-token' }, + }); + const res = mockRes(); + + await controller.streamExecution(TASK_ID, EXEC_ID, undefined, req, res); + + expect(res.status).not.toHaveBeenCalledWith(403); + }); + }); + + // ── TaskCast ID computation ────────────────────────────────────────────── + + describe('TaskCast ID computation', () => { + it('computes deterministic TaskCast ID as agent_task_exec_', async () => { + const specificExecId = 'my-execution-id'; + + setupDbSequence(db, [ + [{ id: specificExecId }], + [{ tenantId: TENANT_ID }], + [{ id: 'member-1' }], + ]); + + let capturedUrl: string | undefined; + const mockFetch = jest.fn().mockImplementation((url: string) => { + capturedUrl = url; + return Promise.resolve({ ok: false, body: null }); + }); + global.fetch = mockFetch as any; + + const req = mockReq({ + headers: { authorization: 'Bearer valid-token' }, + }); + const res = mockRes(); + + await controller.streamExecution( + TASK_ID, + specificExecId, + undefined, + req, + res, + ); + + expect(capturedUrl).toContain(`agent_task_exec_${specificExecId}`); + }); + }); +}); From fc73efcfc6feab39a51de205f46c8ba5d1d99cbb Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 15:53:15 +0800 Subject: [PATCH 22/68] test(tasks): add unit tests for TasksService TaskCast integration Tests cover pause/resume/stop calling transitionStatus with the deterministic ID and correct status, the null-executionId no-op paths, and resolveIntervention calling both transitionStatus and publishEvent in order with the correct payload and ID format. Co-Authored-By: Claude Sonnet 4.6 --- .../gateway/src/tasks/tasks.service.spec.ts | 376 ++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 apps/server/apps/gateway/src/tasks/tasks.service.spec.ts diff --git a/apps/server/apps/gateway/src/tasks/tasks.service.spec.ts b/apps/server/apps/gateway/src/tasks/tasks.service.spec.ts new file mode 100644 index 00000000..8f07d540 --- /dev/null +++ b/apps/server/apps/gateway/src/tasks/tasks.service.spec.ts @@ -0,0 +1,376 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TasksService } from './tasks.service.js'; +import { TaskCastService } from './taskcast.service.js'; +import { DATABASE_CONNECTION } from '@team9/database'; +import { DocumentsService } from '../documents/documents.service.js'; +import { TriggersService } from './triggers.service.js'; +import { AmqpConnection } from '@team9/rabbitmq'; + +// ── helpers ────────────────────────────────────────────────────────── + +type MockFn = jest.Mock<(...args: any[]) => any>; + +function mockDb() { + const chain: Record = {}; + const methods = [ + 'select', + 'from', + 'where', + 'limit', + 'insert', + 'values', + 'returning', + 'update', + 'set', + 'and', + 'orderBy', + 'desc', + 'delete', + 'leftJoin', + 'innerJoin', + ]; + for (const m of methods) { + chain[m] = jest.fn().mockReturnValue(chain); + } + chain.limit.mockResolvedValue([]); + chain.returning.mockResolvedValue([]); + return chain; +} + +// ── fixtures ───────────────────────────────────────────────────────── + +const BASE_TASK = { + id: 'task-1', + tenantId: 'tenant-1', + botId: 'bot-1', + creatorId: 'user-1', + title: 'Test task', + description: null, + status: 'in_progress' as const, + currentExecutionId: 'exec-1', + scheduleType: 'once' as const, + scheduleConfig: null, + documentId: 'doc-1', + createdAt: new Date(), + updatedAt: new Date(), +}; + +describe('TasksService — TaskCast integration', () => { + let service: TasksService; + let db: ReturnType; + let taskCastService: { transitionStatus: MockFn; publishEvent: MockFn }; + let amqpConnection: { publish: MockFn }; + let documentsService: object; + let triggersService: object; + + beforeEach(async () => { + db = mockDb(); + amqpConnection = { publish: jest.fn().mockResolvedValue(undefined) }; + documentsService = {}; + triggersService = {}; + taskCastService = { + transitionStatus: jest.fn().mockResolvedValue(undefined), + publishEvent: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TasksService, + { provide: DATABASE_CONNECTION, useValue: db }, + { provide: AmqpConnection, useValue: amqpConnection }, + { provide: DocumentsService, useValue: documentsService }, + { provide: TriggersService, useValue: triggersService }, + { provide: TaskCastService, useValue: taskCastService }, + ], + }).compile(); + + service = module.get(TasksService); + }); + + // ── pause ───────────────────────────────────────────────────────── + + describe('pause', () => { + it('calls transitionStatus with deterministic ID and "paused" when currentExecutionId exists', async () => { + const task = { + ...BASE_TASK, + status: 'in_progress' as const, + currentExecutionId: 'exec-1', + }; + db.limit.mockResolvedValue([task] as any); + + await service.pause('task-1', 'user-1', 'tenant-1'); + + const expectedTcId = TaskCastService.taskcastId('exec-1'); + expect(taskCastService.transitionStatus).toHaveBeenCalledWith( + expectedTcId, + 'paused', + ); + }); + + it('does NOT call transitionStatus when currentExecutionId is null', async () => { + const task = { + ...BASE_TASK, + status: 'in_progress' as const, + currentExecutionId: null, + }; + db.limit.mockResolvedValue([task] as any); + + await service.pause('task-1', 'user-1', 'tenant-1'); + + expect(taskCastService.transitionStatus).not.toHaveBeenCalled(); + }); + }); + + // ── resume ──────────────────────────────────────────────────────── + + describe('resume', () => { + it('calls transitionStatus with deterministic ID and "in_progress" when currentExecutionId exists', async () => { + const task = { + ...BASE_TASK, + status: 'paused' as const, + currentExecutionId: 'exec-1', + }; + db.limit.mockResolvedValue([task] as any); + + await service.resume('task-1', 'user-1', 'tenant-1', { + message: 'resuming', + }); + + const expectedTcId = TaskCastService.taskcastId('exec-1'); + expect(taskCastService.transitionStatus).toHaveBeenCalledWith( + expectedTcId, + 'in_progress', + ); + }); + + it('does NOT call transitionStatus when currentExecutionId is null', async () => { + const task = { + ...BASE_TASK, + status: 'paused' as const, + currentExecutionId: null, + }; + db.limit.mockResolvedValue([task] as any); + + await service.resume('task-1', 'user-1', 'tenant-1', { + message: 'resuming', + }); + + expect(taskCastService.transitionStatus).not.toHaveBeenCalled(); + }); + }); + + // ── stop ────────────────────────────────────────────────────────── + + describe('stop', () => { + it('calls transitionStatus with deterministic ID and "stopped" when currentExecutionId exists', async () => { + const task = { + ...BASE_TASK, + status: 'in_progress' as const, + currentExecutionId: 'exec-1', + }; + db.limit.mockResolvedValue([task] as any); + + await service.stop('task-1', 'user-1', 'tenant-1', { + reason: 'manual stop', + }); + + const expectedTcId = TaskCastService.taskcastId('exec-1'); + expect(taskCastService.transitionStatus).toHaveBeenCalledWith( + expectedTcId, + 'stopped', + ); + }); + + it('does NOT call transitionStatus when currentExecutionId is null', async () => { + const task = { + ...BASE_TASK, + status: 'in_progress' as const, + currentExecutionId: null, + }; + db.limit.mockResolvedValue([task] as any); + + await service.stop('task-1', 'user-1', 'tenant-1', { + reason: 'manual stop', + }); + + expect(taskCastService.transitionStatus).not.toHaveBeenCalled(); + }); + + it('calls transitionStatus when task is paused (valid stop transition)', async () => { + const task = { + ...BASE_TASK, + status: 'paused' as const, + currentExecutionId: 'exec-2', + }; + db.limit.mockResolvedValue([task] as any); + + await service.stop('task-1', 'user-1', 'tenant-1', {}); + + expect(taskCastService.transitionStatus).toHaveBeenCalledWith( + TaskCastService.taskcastId('exec-2'), + 'stopped', + ); + }); + }); + + // ── resolveIntervention ─────────────────────────────────────────── + + describe('resolveIntervention', () => { + const intervention = { + id: 'int-1', + taskId: 'task-1', + executionId: 'exec-1', + status: 'pending', + createdAt: new Date(), + updatedAt: new Date(), + resolvedBy: null, + resolvedAt: null, + response: null, + }; + + const updatedIntervention = { + ...intervention, + status: 'resolved', + resolvedBy: 'user-1', + resolvedAt: new Date(), + response: { action: 'approve', message: 'looks good' }, + }; + + function setupResolveInterventionMocks() { + // task has pending_action status so that validateStatusTransition is satisfied + // (resolveIntervention doesn't call validateStatusTransition, so any status works) + const task = { + ...BASE_TASK, + status: 'pending_action' as const, + currentExecutionId: 'exec-1', + }; + + let limitCallCount = 0; + db.limit.mockImplementation((() => { + limitCallCount++; + if (limitCallCount === 1) return Promise.resolve([task]); + if (limitCallCount === 2) return Promise.resolve([intervention]); + return Promise.resolve([]); + }) as any); + + let returningCallCount = 0; + db.returning.mockImplementation((() => { + returningCallCount++; + if (returningCallCount === 1) + return Promise.resolve([updatedIntervention]); + return Promise.resolve([{}]); + }) as any); + } + + it('calls transitionStatus with "in_progress" after resolving intervention', async () => { + setupResolveInterventionMocks(); + + await service.resolveIntervention( + 'task-1', + 'int-1', + 'user-1', + 'tenant-1', + { action: 'approve', message: 'looks good' }, + ); + + const expectedTcId = TaskCastService.taskcastId('exec-1'); + expect(taskCastService.transitionStatus).toHaveBeenCalledWith( + expectedTcId, + 'in_progress', + ); + }); + + it('calls publishEvent with type "intervention" using the deterministic ID', async () => { + setupResolveInterventionMocks(); + + await service.resolveIntervention( + 'task-1', + 'int-1', + 'user-1', + 'tenant-1', + { action: 'approve', message: 'looks good' }, + ); + + const expectedTcId = TaskCastService.taskcastId('exec-1'); + expect(taskCastService.publishEvent).toHaveBeenCalledWith( + expectedTcId, + expect.objectContaining({ + type: 'intervention', + }), + ); + }); + + it('calls both transitionStatus and publishEvent in order', async () => { + const callOrder: string[] = []; + taskCastService.transitionStatus.mockImplementation(async () => { + callOrder.push('transitionStatus'); + }); + taskCastService.publishEvent.mockImplementation(async () => { + callOrder.push('publishEvent'); + }); + + setupResolveInterventionMocks(); + + await service.resolveIntervention( + 'task-1', + 'int-1', + 'user-1', + 'tenant-1', + { action: 'approve' }, + ); + + expect(callOrder).toEqual(['transitionStatus', 'publishEvent']); + }); + + it('uses the deterministic ID format agent_task_exec_${executionId}', async () => { + setupResolveInterventionMocks(); + + await service.resolveIntervention( + 'task-1', + 'int-1', + 'user-1', + 'tenant-1', + { action: 'approve' }, + ); + + // Verify the exact deterministic ID format + const expectedTcId = `agent_task_exec_exec-1`; + expect(taskCastService.transitionStatus).toHaveBeenCalledWith( + expectedTcId, + expect.any(String), + ); + expect(taskCastService.publishEvent).toHaveBeenCalledWith( + expectedTcId, + expect.any(Object), + ); + }); + + it('passes the resolved intervention data in the publishEvent payload', async () => { + setupResolveInterventionMocks(); + + await service.resolveIntervention( + 'task-1', + 'int-1', + 'user-1', + 'tenant-1', + { action: 'approve', message: 'looks good' }, + ); + + expect(taskCastService.publishEvent).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + type: 'intervention', + data: expect.objectContaining({ + intervention: expect.objectContaining({ + id: 'int-1', + status: 'resolved', + }), + }), + seriesId: `intervention:int-1`, + seriesMode: 'latest', + }), + ); + }); + }); +}); From d14a38573d7bbe6b11ecc04c917d75a134260180 Mon Sep 17 00:00:00 2001 From: Winrey Date: Sat, 14 Mar 2026 15:55:15 +0800 Subject: [PATCH 23/68] test(tasks): add unit tests for TaskBotService TaskCast integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers all 8 TaskCast call sites in TaskBotService (reportSteps, updateStatus, createIntervention, addDeliverable) — verifying both the "taskcastTaskId is set → call TaskCast" and "null → skip" paths. Co-Authored-By: Claude Sonnet 4.6 --- .../src/tasks/task-bot.service.spec.ts | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 apps/server/apps/gateway/src/tasks/task-bot.service.spec.ts diff --git a/apps/server/apps/gateway/src/tasks/task-bot.service.spec.ts b/apps/server/apps/gateway/src/tasks/task-bot.service.spec.ts new file mode 100644 index 00000000..835f99dd --- /dev/null +++ b/apps/server/apps/gateway/src/tasks/task-bot.service.spec.ts @@ -0,0 +1,320 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { TaskBotService } from './task-bot.service.js'; +import { TaskCastService } from './taskcast.service.js'; +import { DATABASE_CONNECTION } from '@team9/database'; +import { WEBSOCKET_GATEWAY } from '../shared/constants/injection-tokens.js'; + +// ── helpers ─────────────────────────────────────────────────────────── + +type MockFn = jest.Mock<(...args: any[]) => any>; + +function mockDb() { + const chain: Record = {}; + const methods = [ + 'select', + 'from', + 'where', + 'limit', + 'insert', + 'values', + 'returning', + 'update', + 'set', + 'and', + 'orderBy', + ]; + for (const m of methods) { + chain[m] = jest.fn().mockReturnValue(chain); + } + // Default terminal resolutions + chain.limit.mockResolvedValue([]); + chain.returning.mockResolvedValue([]); + // orderBy is also used as a terminal await in reportSteps (final steps list) + chain.orderBy.mockResolvedValue([]); + return chain; +} + +// ── Shared fixtures ─────────────────────────────────────────────────── + +const TASK = { + id: 'task-1', + botId: 'bot-1', + tenantId: 'tenant-1', + currentExecutionId: 'exec-1', + status: 'in_progress', +}; + +const BOT = { userId: 'bot-user-1' }; + +const EXECUTION_WITH_TASKCAST = { + id: 'exec-1', + taskcastTaskId: 'agent_task_exec_exec-1', + startedAt: new Date(), + status: 'in_progress', +}; + +const EXECUTION_WITHOUT_TASKCAST = { + id: 'exec-1', + taskcastTaskId: null, + startedAt: new Date(), + status: 'in_progress', +}; + +/** + * Set up the `limit` mock so that the first three calls return: + * 1. task (for getActiveExecution — select from agentTasks) + * 2. bot (for getActiveExecution — select from bots) + * 3. execution (for getActiveExecution — select from agentTaskExecutions) + * Any subsequent calls (e.g. per-step look-ups in reportSteps) resolve to []. + */ +function setupGetActiveExecutionMocks( + db: ReturnType, + execution: typeof EXECUTION_WITH_TASKCAST | typeof EXECUTION_WITHOUT_TASKCAST, +) { + db.limit + .mockResolvedValueOnce([TASK] as any) + .mockResolvedValueOnce([BOT] as any) + .mockResolvedValueOnce([execution] as any); +} + +// ── Tests ───────────────────────────────────────────────────────────── + +describe('TaskBotService — TaskCast integration', () => { + let service: TaskBotService; + let db: ReturnType; + let wsGateway: { broadcastToWorkspace: MockFn }; + let taskCastService: { publishEvent: MockFn; transitionStatus: MockFn }; + + beforeEach(async () => { + db = mockDb(); + wsGateway = { + broadcastToWorkspace: jest.fn().mockResolvedValue(undefined), + }; + taskCastService = { + publishEvent: jest.fn().mockResolvedValue(undefined), + transitionStatus: jest.fn().mockResolvedValue(undefined), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TaskBotService, + { provide: DATABASE_CONNECTION, useValue: db }, + { provide: WEBSOCKET_GATEWAY, useValue: wsGateway }, + { provide: TaskCastService, useValue: taskCastService }, + ], + }).compile(); + + service = module.get(TaskBotService); + }); + + // ── reportSteps ────────────────────────────────────────────────── + + describe('reportSteps', () => { + const dto = { + steps: [{ orderIndex: 0, title: 'Step 1', status: 'completed' as const }], + }; + + /** + * Helper: configure db.where so that calls #1-3 (getActiveExecution) and + * call #4 (per-step lookup) return the chain (enabling .limit() chaining), + * while call #5 (sum query — no trailing .limit/.orderBy) returns a + * resolved Promise with [{ total: 0 }], and calls #6+ (update where, + * final select where) return the chain again. + * + * where call map for reportSteps with one step (no existing): + * #1 getActiveExecution: agentTasks.where → chain → .limit(1) + * #2 getActiveExecution: bots.where → chain → .limit(1) + * #3 getActiveExecution: agentTaskExecutions.where → chain → .limit(1) + * #4 step lookup: agentTaskSteps.where(and(...)) → chain → .limit(1) + * #5 sum query: agentTaskSteps.where(eq(executionId)) → awaited directly + * #6 update execution: agentTaskExecutions.where → chain (awaited as update) + * #7 final steps: agentTaskSteps.where → chain → .orderBy() + */ + function setupReportStepsMocks(db: ReturnType) { + let whereCallCount = 0; + db.where.mockImplementation((..._args: any[]) => { + whereCallCount++; + if (whereCallCount === 5) { + // Sum query — no .limit/.orderBy follows; must be directly awaitable + return Promise.resolve([{ total: 0 }]) as any; + } + return db as any; + }); + } + + it('calls publishEvent with type "step", seriesId "steps", seriesMode "latest" when taskcastTaskId is set', async () => { + setupGetActiveExecutionMocks(db, EXECUTION_WITH_TASKCAST); + setupReportStepsMocks(db); + + await service.reportSteps('task-1', 'bot-user-1', dto); + + expect(taskCastService.publishEvent).toHaveBeenCalledTimes(1); + expect(taskCastService.publishEvent).toHaveBeenCalledWith( + EXECUTION_WITH_TASKCAST.taskcastTaskId, + expect.objectContaining({ + type: 'step', + seriesId: 'steps', + seriesMode: 'latest', + }), + ); + }); + + it('does NOT call publishEvent when taskcastTaskId is null', async () => { + setupGetActiveExecutionMocks(db, EXECUTION_WITHOUT_TASKCAST); + setupReportStepsMocks(db); + + await service.reportSteps('task-1', 'bot-user-1', dto); + + expect(taskCastService.publishEvent).not.toHaveBeenCalled(); + }); + }); + + // ── updateStatus ───────────────────────────────────────────────── + + describe('updateStatus', () => { + it('calls transitionStatus with the status when taskcastTaskId is set', async () => { + setupGetActiveExecutionMocks(db, EXECUTION_WITH_TASKCAST); + + db.returning + .mockResolvedValueOnce([ + { ...EXECUTION_WITH_TASKCAST, status: 'completed' }, + ] as any) + .mockResolvedValueOnce([{ ...TASK, status: 'completed' }] as any); + + await service.updateStatus('task-1', 'bot-user-1', 'completed'); + + expect(taskCastService.transitionStatus).toHaveBeenCalledTimes(1); + expect(taskCastService.transitionStatus).toHaveBeenCalledWith( + EXECUTION_WITH_TASKCAST.taskcastTaskId, + 'completed', + ); + }); + + it('does NOT call transitionStatus when taskcastTaskId is null', async () => { + setupGetActiveExecutionMocks(db, EXECUTION_WITHOUT_TASKCAST); + + db.returning + .mockResolvedValueOnce([ + { ...EXECUTION_WITHOUT_TASKCAST, status: 'completed' }, + ] as any) + .mockResolvedValueOnce([{ ...TASK, status: 'completed' }] as any); + + await service.updateStatus('task-1', 'bot-user-1', 'completed'); + + expect(taskCastService.transitionStatus).not.toHaveBeenCalled(); + }); + }); + + // ── createIntervention ─────────────────────────────────────────── + + describe('createIntervention', () => { + const dto = { + prompt: 'Should I proceed?', + actions: [{ label: 'Yes', value: 'yes' }], + }; + + it('calls transitionStatus("pending_action") AND publishEvent with type "intervention" when taskcastTaskId is set', async () => { + setupGetActiveExecutionMocks(db, EXECUTION_WITH_TASKCAST); + + const interventionRow = { + id: 'intervention-1', + executionId: 'exec-1', + taskId: 'task-1', + prompt: dto.prompt, + actions: dto.actions, + stepId: null, + }; + db.returning.mockResolvedValueOnce([interventionRow] as any); + // second update (task status) — no returning needed, chain handles it + + await service.createIntervention('task-1', 'bot-user-1', dto); + + expect(taskCastService.transitionStatus).toHaveBeenCalledTimes(1); + expect(taskCastService.transitionStatus).toHaveBeenCalledWith( + EXECUTION_WITH_TASKCAST.taskcastTaskId, + 'pending_action', + ); + + expect(taskCastService.publishEvent).toHaveBeenCalledTimes(1); + expect(taskCastService.publishEvent).toHaveBeenCalledWith( + EXECUTION_WITH_TASKCAST.taskcastTaskId, + expect.objectContaining({ + type: 'intervention', + }), + ); + }); + + it('does NOT call TaskCast methods when taskcastTaskId is null', async () => { + setupGetActiveExecutionMocks(db, EXECUTION_WITHOUT_TASKCAST); + + const interventionRow = { + id: 'intervention-2', + executionId: 'exec-1', + taskId: 'task-1', + prompt: dto.prompt, + actions: dto.actions, + stepId: null, + }; + db.returning.mockResolvedValueOnce([interventionRow] as any); + + await service.createIntervention('task-1', 'bot-user-1', dto); + + expect(taskCastService.transitionStatus).not.toHaveBeenCalled(); + expect(taskCastService.publishEvent).not.toHaveBeenCalled(); + }); + }); + + // ── addDeliverable ─────────────────────────────────────────────── + + describe('addDeliverable', () => { + const deliverableData = { + fileName: 'report.pdf', + fileUrl: 'https://example.com/report.pdf', + }; + + it('calls publishEvent with type "deliverable" when taskcastTaskId is set', async () => { + setupGetActiveExecutionMocks(db, EXECUTION_WITH_TASKCAST); + + const deliverableRow = { + id: 'deliverable-1', + executionId: 'exec-1', + taskId: 'task-1', + fileName: 'report.pdf', + fileUrl: 'https://example.com/report.pdf', + fileSize: null, + mimeType: null, + }; + db.returning.mockResolvedValueOnce([deliverableRow] as any); + + await service.addDeliverable('task-1', 'bot-user-1', deliverableData); + + expect(taskCastService.publishEvent).toHaveBeenCalledTimes(1); + expect(taskCastService.publishEvent).toHaveBeenCalledWith( + EXECUTION_WITH_TASKCAST.taskcastTaskId, + expect.objectContaining({ + type: 'deliverable', + }), + ); + }); + + it('does NOT call publishEvent when taskcastTaskId is null', async () => { + setupGetActiveExecutionMocks(db, EXECUTION_WITHOUT_TASKCAST); + + const deliverableRow = { + id: 'deliverable-2', + executionId: 'exec-1', + taskId: 'task-1', + fileName: 'report.pdf', + fileUrl: 'https://example.com/report.pdf', + fileSize: null, + mimeType: null, + }; + db.returning.mockResolvedValueOnce([deliverableRow] as any); + + await service.addDeliverable('task-1', 'bot-user-1', deliverableData); + + expect(taskCastService.publishEvent).not.toHaveBeenCalled(); + }); + }); +}); From 536da0ae8fa0e498609c6d2b966f60f6cac515e0 Mon Sep 17 00:00:00 2001 From: Heathcliff Date: Mon, 16 Mar 2026 19:35:25 +0800 Subject: [PATCH 24/68] feat(tasks): add custom strategy support and fix refetchInterval - Register 'custom' executor strategy reusing openclaw strategy - Fix refetchInterval in RunDetailView and TaskDetailPanel to use query callback pattern avoiding stale data reference - Include task-worker in dev and dev:server:all scripts --- apps/client/src/components/tasks/RunDetailView.tsx | 3 ++- apps/client/src/components/tasks/TaskDetailPanel.tsx | 7 ++++--- .../apps/task-worker/src/executor/executor.module.ts | 1 + package.json | 4 ++-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/client/src/components/tasks/RunDetailView.tsx b/apps/client/src/components/tasks/RunDetailView.tsx index 889be193..89b74fe0 100644 --- a/apps/client/src/components/tasks/RunDetailView.tsx +++ b/apps/client/src/components/tasks/RunDetailView.tsx @@ -59,7 +59,8 @@ export function RunDetailView({ const { data: execution, isLoading: execLoading } = useQuery({ queryKey: ["task-execution", taskId, executionId], queryFn: () => tasksApi.getExecution(taskId, executionId), - refetchInterval: execution?.taskcastTaskId ? 30000 : 5000, + refetchInterval: (query) => + query.state.data?.taskcastTaskId ? 30000 : 5000, }); // Notify parent of this run's channelId for the message input diff --git a/apps/client/src/components/tasks/TaskDetailPanel.tsx b/apps/client/src/components/tasks/TaskDetailPanel.tsx index e6e756e6..cca52049 100644 --- a/apps/client/src/components/tasks/TaskDetailPanel.tsx +++ b/apps/client/src/components/tasks/TaskDetailPanel.tsx @@ -37,9 +37,10 @@ export function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProps) { } = useQuery({ queryKey: ["task", taskId], queryFn: () => tasksApi.getById(taskId), - refetchInterval: task?.currentExecution?.execution.taskcastTaskId - ? 30000 - : 5000, + refetchInterval: (query) => + query.state.data?.currentExecution?.execution.taskcastTaskId + ? 30000 + : 5000, }); // Track active tab & whether viewing an active run diff --git a/apps/server/apps/task-worker/src/executor/executor.module.ts b/apps/server/apps/task-worker/src/executor/executor.module.ts index 35a5e16f..6130bc51 100644 --- a/apps/server/apps/task-worker/src/executor/executor.module.ts +++ b/apps/server/apps/task-worker/src/executor/executor.module.ts @@ -17,5 +17,6 @@ export class ExecutorModule implements OnModuleInit { onModuleInit() { this.executorService.registerStrategy('system', this.openclawStrategy); + this.executorService.registerStrategy('custom', this.openclawStrategy); } } diff --git a/package.json b/package.json index 6ddc98fb..1207b965 100644 --- a/package.json +++ b/package.json @@ -10,8 +10,8 @@ "dev:server": "pnpm -C apps/server dev", "dev:im-worker": "pnpm -C apps/server dev:im-worker", "dev:task-worker": "pnpm -C apps/server dev:task-worker", - "dev:server:all": "concurrently \"pnpm dev:server\" \"pnpm dev:im-worker\"", - "dev": "concurrently \"pnpm dev:server\" \"pnpm dev:im-worker\" \"pnpm dev:client\"", + "dev:server:all": "concurrently \"pnpm dev:server\" \"pnpm dev:im-worker\" \"pnpm dev:task-worker\"", + "dev": "concurrently \"pnpm dev:server\" \"pnpm dev:im-worker\" \"pnpm dev:task-worker\" \"pnpm dev:client\"", "build:client": "pnpm -C apps/client build", "build:client:mac": "pnpm -C apps/client build:mac", "build:client:windows": "pnpm -C apps/client build:windows", From c667b22062ad284ac83d096f3315f182885a3414 Mon Sep 17 00:00:00 2001 From: Heathcliff Date: Mon, 16 Mar 2026 21:29:46 +0800 Subject: [PATCH 25/68] feat(tasks): snapshot task version on execution and implement OpenClaw strategy Replace auto-incrementing execution version with a snapshot of the task's version at execution time. Bump task version when its linked document is updated. Implement the OpenClaw strategy to actually call the agent execute API endpoint. --- .../src/components/tasks/RunDetailView.tsx | 4 +- .../src/components/tasks/TaskBasicInfoTab.tsx | 2 +- .../src/components/tasks/TaskRunsTab.tsx | 4 +- apps/client/src/types/task.ts | 3 +- .../src/documents/documents.service.ts | 10 + .../apps/gateway/src/tasks/tasks.service.ts | 2 +- .../src/executor/executor.service.ts | 39 +- .../executor/strategies/openclaw.strategy.ts | 66 +- .../migrations/0024_ambitious_mentor.sql | 7 + .../migrations/meta/0024_snapshot.json | 5788 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + .../src/schemas/task/task-executions.ts | 4 +- .../libs/database/src/schemas/task/tasks.ts | 3 + 13 files changed, 5899 insertions(+), 40 deletions(-) create mode 100644 apps/server/libs/database/migrations/0024_ambitious_mentor.sql create mode 100644 apps/server/libs/database/migrations/meta/0024_snapshot.json diff --git a/apps/client/src/components/tasks/RunDetailView.tsx b/apps/client/src/components/tasks/RunDetailView.tsx index 89b74fe0..ab8ec846 100644 --- a/apps/client/src/components/tasks/RunDetailView.tsx +++ b/apps/client/src/components/tasks/RunDetailView.tsx @@ -117,9 +117,7 @@ export function RunDetailView({ - - {t("runs.version", { version: execution.version })} - + v{execution.taskVersion} {execution.triggerType && ( {t(`runs.triggerType.${execution.triggerType}`)} diff --git a/apps/client/src/components/tasks/TaskBasicInfoTab.tsx b/apps/client/src/components/tasks/TaskBasicInfoTab.tsx index be4a7f0f..cb1fcee2 100644 --- a/apps/client/src/components/tasks/TaskBasicInfoTab.tsx +++ b/apps/client/src/components/tasks/TaskBasicInfoTab.tsx @@ -261,7 +261,7 @@ export function TaskBasicInfoTab({ {execution && ( - v{execution.version} + v{execution.taskVersion} )} {execution && execution.tokenUsage > 0 && ( diff --git a/apps/client/src/components/tasks/TaskRunsTab.tsx b/apps/client/src/components/tasks/TaskRunsTab.tsx index 7a201446..045cae60 100644 --- a/apps/client/src/components/tasks/TaskRunsTab.tsx +++ b/apps/client/src/components/tasks/TaskRunsTab.tsx @@ -78,9 +78,7 @@ export function TaskRunsTab({ className="w-full text-left p-3 rounded-md border border-border hover:bg-muted/50 transition-colors space-y-1" >
- - {t("runs.version", { version: exec.version })} - + v{exec.taskVersion} {exec.triggerType && ( {(() => { diff --git a/apps/client/src/types/task.ts b/apps/client/src/types/task.ts index 2aa351a1..25a8c560 100644 --- a/apps/client/src/types/task.ts +++ b/apps/client/src/types/task.ts @@ -65,6 +65,7 @@ export interface AgentTask { scheduleConfig: ScheduleConfig | null; /** @deprecated Use triggers table instead */ nextRunAt: string | null; + version: number; documentId: string | null; currentExecutionId: string | null; /** Token usage from the current execution (included in list responses) */ @@ -76,7 +77,7 @@ export interface AgentTask { export interface AgentTaskExecution { id: string; taskId: string; - version: number; + taskVersion: number; status: AgentTaskStatus; channelId: string | null; taskcastTaskId: string | null; diff --git a/apps/server/apps/gateway/src/documents/documents.service.ts b/apps/server/apps/gateway/src/documents/documents.service.ts index 29f282b3..9d3633c4 100644 --- a/apps/server/apps/gateway/src/documents/documents.service.ts +++ b/apps/server/apps/gateway/src/documents/documents.service.ts @@ -10,6 +10,7 @@ import { eq, and, desc, + sql, type PostgresJsDatabase, } from '@team9/database'; import * as schema from '@team9/database/schemas'; @@ -276,6 +277,15 @@ export class DocumentsService { }) .where(eq(schema.documents.id, id)); + // Bump version on any task linked to this document + await this.db + .update(schema.agentTasks) + .set({ + version: sql`${schema.agentTasks.version} + 1`, + updatedAt: new Date(), + }) + .where(eq(schema.agentTasks.documentId, id)); + return { id: version.id, documentId: version.documentId, diff --git a/apps/server/apps/gateway/src/tasks/tasks.service.ts b/apps/server/apps/gateway/src/tasks/tasks.service.ts index 4f6466ed..c381c924 100644 --- a/apps/server/apps/gateway/src/tasks/tasks.service.ts +++ b/apps/server/apps/gateway/src/tasks/tasks.service.ts @@ -236,7 +236,7 @@ export class TasksService { .select() .from(schema.agentTaskExecutions) .where(eq(schema.agentTaskExecutions.taskId, taskId)) - .orderBy(desc(schema.agentTaskExecutions.version)); + .orderBy(desc(schema.agentTaskExecutions.createdAt)); return executions; } diff --git a/apps/server/apps/task-worker/src/executor/executor.service.ts b/apps/server/apps/task-worker/src/executor/executor.service.ts index 1776397e..0c1893bb 100644 --- a/apps/server/apps/task-worker/src/executor/executor.service.ts +++ b/apps/server/apps/task-worker/src/executor/executor.service.ts @@ -4,8 +4,6 @@ import { DATABASE_CONNECTION, eq, and, - desc, - sql, type PostgresJsDatabase, } from '@team9/database'; import * as schema from '@team9/database/schemas'; @@ -38,14 +36,13 @@ export class ExecutorService { * Trigger a full execution lifecycle for the given task. * * 1. Load task from DB - * 2. Determine next version number - * 3. Create task channel (type='task') - * 4. Fetch document content (if linked) - * 5. Create execution record in DB - * 6. Update task status to in_progress - * 7. Look up bot's shadow userId from bots table - * 8. Delegate to strategy - * 9. Log completion + * 2. Create task channel (type='task') + * 3. Fetch document content (if linked) + * 4. Create execution record in DB (with task version snapshot) + * 5. Update task status to in_progress + * 6. Look up bot's shadow userId from bots table + * 7. Delegate to strategy + * 8. Log completion */ async triggerExecution( taskId: string, @@ -76,22 +73,12 @@ export class ExecutorService { return; } - // ── 2. Determine next version number ────────────────────────────── - const [lastExecution] = await this.db - .select({ version: schema.agentTaskExecutions.version }) - .from(schema.agentTaskExecutions) - .where(eq(schema.agentTaskExecutions.taskId, taskId)) - .orderBy(desc(schema.agentTaskExecutions.version)) - .limit(1); - - const nextVersion = (lastExecution?.version ?? 0) + 1; - - // ── 3. Create task channel (type='task') ────────────────────────── + // ── 2. Create task channel (type='task') ────────────────────────── const channelId = uuidv7(); await this.db.insert(schema.channels).values({ id: channelId, tenantId: task.tenantId, - name: `task-${task.title.slice(0, 60).replace(/\s+/g, '-').toLowerCase()}-v${nextVersion}`, + name: `task-${task.title.slice(0, 60).replace(/\s+/g, '-').toLowerCase()}-${channelId.slice(-6)}`, type: 'task', createdBy: task.creatorId, }); @@ -143,7 +130,7 @@ export class ExecutorService { await this.db.insert(schema.agentTaskExecutions).values({ id: executionId, taskId, - version: nextVersion, + taskVersion: task.version, status: 'in_progress', channelId, taskcastTaskId, @@ -205,7 +192,7 @@ export class ExecutorService { try { await strategy.execute(context); this.logger.log( - `Execution ${executionId} (v${nextVersion}) delegated to ${bot.type} strategy`, + `Execution ${executionId} delegated to ${bot.type} strategy`, ); } catch (error) { const errorMessage = @@ -253,9 +240,7 @@ export class ExecutorService { } // ── 9. Log completion ────────────────────────────────────────────── - this.logger.log( - `Execution ${executionId} (v${nextVersion}) initiated for task ${taskId}`, - ); + this.logger.log(`Execution ${executionId} initiated for task ${taskId}`); } private async markExecutionFailed( diff --git a/apps/server/apps/task-worker/src/executor/strategies/openclaw.strategy.ts b/apps/server/apps/task-worker/src/executor/strategies/openclaw.strategy.ts index b150bdd6..4b70aa4f 100644 --- a/apps/server/apps/task-worker/src/executor/strategies/openclaw.strategy.ts +++ b/apps/server/apps/task-worker/src/executor/strategies/openclaw.strategy.ts @@ -1,16 +1,78 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { + DATABASE_CONNECTION, + eq, + type PostgresJsDatabase, +} from '@team9/database'; +import * as schema from '@team9/database/schemas'; import type { ExecutionStrategy, ExecutionContext, } from '../execution-strategy.interface.js'; +type OpenclawSecrets = { + instanceResult?: { + access_url?: string; + instance?: { + access_url?: string; + }; + }; +}; + @Injectable() export class OpenclawStrategy implements ExecutionStrategy { private readonly logger = new Logger(OpenclawStrategy.name); + constructor( + @Inject(DATABASE_CONNECTION) + private readonly db: PostgresJsDatabase, + ) {} + async execute(context: ExecutionContext): Promise { this.logger.log(`Starting OpenClaw agent for task ${context.taskId}`); - // TODO: POST {openclaw_url}/api/agents/{agentId}/execute + const [bot] = await this.db + .select({ + extra: schema.bots.extra, + secrets: schema.installedApplications.secrets, + }) + .from(schema.bots) + .leftJoin( + schema.installedApplications, + eq(schema.installedApplications.id, schema.bots.installedApplicationId), + ) + .where(eq(schema.bots.id, context.botId)) + .limit(1); + + if (!bot) { + throw new Error(`OpenClaw bot not found: ${context.botId}`); + } + + const agentId = bot.extra?.openclaw?.agentId ?? 'default'; + const secrets = bot.secrets as OpenclawSecrets | null; + const openclawUrl = + secrets?.instanceResult?.access_url ?? + secrets?.instanceResult?.instance?.access_url; + + if (!openclawUrl) { + throw new Error(`OpenClaw URL not configured for bot ${context.botId}`); + } + + const response = await fetch( + new URL( + `/api/agents/${encodeURIComponent(agentId)}/execute`, + openclawUrl, + ), + { + method: 'POST', + }, + ); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error( + `OpenClaw execute failed for agent ${agentId} (${response.status}): ${errorText || response.statusText}`, + ); + } } async pause(context: ExecutionContext): Promise { diff --git a/apps/server/libs/database/migrations/0024_ambitious_mentor.sql b/apps/server/libs/database/migrations/0024_ambitious_mentor.sql new file mode 100644 index 00000000..b803948f --- /dev/null +++ b/apps/server/libs/database/migrations/0024_ambitious_mentor.sql @@ -0,0 +1,7 @@ +ALTER TABLE "tracker_tasks" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +DROP TABLE "tracker_tasks" CASCADE;--> statement-breakpoint +ALTER TABLE "agent_task__executions" RENAME COLUMN "version" TO "task_version";--> statement-breakpoint +DROP INDEX "idx_agent_task__executions_task_version";--> statement-breakpoint +ALTER TABLE "agent_task__tasks" ADD COLUMN "version" integer DEFAULT 1 NOT NULL;--> statement-breakpoint +CREATE INDEX "idx_agent_task__executions_task_version" ON "agent_task__executions" USING btree ("task_id","task_version");--> statement-breakpoint +DROP TYPE "public"."task_status"; \ No newline at end of file diff --git a/apps/server/libs/database/migrations/meta/0024_snapshot.json b/apps/server/libs/database/migrations/meta/0024_snapshot.json new file mode 100644 index 00000000..aa09c3c0 --- /dev/null +++ b/apps/server/libs/database/migrations/meta/0024_snapshot.json @@ -0,0 +1,5788 @@ +{ + "id": "984bed22-5920-4441-8ab4-af79bc2c038d", + "prevId": "cd97f670-fd8f-4bff-8066-dc93ac36b3ad", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.config": { + "name": "config", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_suggestions": { + "name": "document_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_version_id": { + "name": "from_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "suggested_by": { + "name": "suggested_by", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "document_suggestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "result_version_id": { + "name": "result_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_document_suggestions_document_id": { + "name": "idx_document_suggestions_document_id", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_document_suggestions_status": { + "name": "idx_document_suggestions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_suggestions_document_id_documents_id_fk": { + "name": "document_suggestions_document_id_documents_id_fk", + "tableFrom": "document_suggestions", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_suggestions_from_version_id_document_versions_id_fk": { + "name": "document_suggestions_from_version_id_document_versions_id_fk", + "tableFrom": "document_suggestions", + "tableTo": "document_versions", + "columnsFrom": ["from_version_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "document_suggestions_result_version_id_document_versions_id_fk": { + "name": "document_suggestions_result_version_id_document_versions_id_fk", + "tableFrom": "document_suggestions", + "tableTo": "document_versions", + "columnsFrom": ["result_version_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_versions": { + "name": "document_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version_index": { + "name": "version_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_by": { + "name": "updated_by", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_document_versions_document_id": { + "name": "idx_document_versions_document_id", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_versions_document_id_documents_id_fk": { + "name": "document_versions_document_id_documents_id_fk", + "tableFrom": "document_versions", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_document_versions_doc_version": { + "name": "uq_document_versions_doc_version", + "nullsNotDistinct": false, + "columns": ["document_id", "version_index"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "document_type": { + "name": "document_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "privileges": { + "name": "privileges", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "current_version_id": { + "name": "current_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_documents_tenant_id": { + "name": "idx_documents_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_document_type": { + "name": "idx_documents_document_type", + "columns": [ + { + "expression": "document_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_tenant_id_tenants_id_fk": { + "name": "documents_tenant_id_tenants_id_fk", + "tableFrom": "documents", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_bots": { + "name": "im_bots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "bot_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "installed_application_id": { + "name": "installed_application_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capabilities": { + "name": "capabilities", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "webhook_headers": { + "name": "webhook_headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "extra": { + "name": "extra", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_bots_user_id": { + "name": "idx_bots_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bots_type": { + "name": "idx_bots_type", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bots_owner_id": { + "name": "idx_bots_owner_id", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bots_mentor_id": { + "name": "idx_bots_mentor_id", + "columns": [ + { + "expression": "mentor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bots_installed_application_id": { + "name": "idx_bots_installed_application_id", + "columns": [ + { + "expression": "installed_application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_bots_access_token": { + "name": "idx_bots_access_token", + "columns": [ + { + "expression": "access_token", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "text_pattern_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_bots_user_id_im_users_id_fk": { + "name": "im_bots_user_id_im_users_id_fk", + "tableFrom": "im_bots", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_bots_owner_id_im_users_id_fk": { + "name": "im_bots_owner_id_im_users_id_fk", + "tableFrom": "im_bots", + "tableTo": "im_users", + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "im_bots_mentor_id_im_users_id_fk": { + "name": "im_bots_mentor_id_im_users_id_fk", + "tableFrom": "im_bots", + "tableTo": "im_users", + "columnsFrom": ["mentor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "im_bots_installed_application_id_im_installed_applications_id_fk": { + "name": "im_bots_installed_application_id_im_installed_applications_id_fk", + "tableFrom": "im_bots", + "tableTo": "im_installed_applications", + "columnsFrom": ["installed_application_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_bots_user_id_unique": { + "name": "im_bots_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_channel_members": { + "name": "im_channel_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "is_muted": { + "name": "is_muted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notifications_enabled": { + "name": "notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "left_at": { + "name": "left_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_channel_members_user_id": { + "name": "idx_channel_members_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_channel_members_channel_id_im_channels_id_fk": { + "name": "im_channel_members_channel_id_im_channels_id_fk", + "tableFrom": "im_channel_members", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_channel_members_user_id_im_users_id_fk": { + "name": "im_channel_members_user_id_im_users_id_fk", + "tableFrom": "im_channel_members", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_channel_user": { + "name": "unique_channel_user", + "nullsNotDistinct": false, + "columns": ["channel_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_channel_sections": { + "name": "im_channel_sections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_channel_sections_tenant": { + "name": "idx_channel_sections_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_channel_sections_tenant_id_tenants_id_fk": { + "name": "im_channel_sections_tenant_id_tenants_id_fk", + "tableFrom": "im_channel_sections", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_channel_sections_created_by_im_users_id_fk": { + "name": "im_channel_sections_created_by_im_users_id_fk", + "tableFrom": "im_channel_sections", + "tableTo": "im_users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_channels": { + "name": "im_channels", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "channel_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "section_id": { + "name": "section_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_channels_tenant": { + "name": "idx_channels_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_channels_tenant_id_tenants_id_fk": { + "name": "im_channels_tenant_id_tenants_id_fk", + "tableFrom": "im_channels", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_channels_created_by_im_users_id_fk": { + "name": "im_channels_created_by_im_users_id_fk", + "tableFrom": "im_channels", + "tableTo": "im_users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "im_channels_section_id_im_channel_sections_id_fk": { + "name": "im_channels_section_id_im_channel_sections_id_fk", + "tableFrom": "im_channels", + "tableTo": "im_channel_sections", + "columnsFrom": ["section_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_email_verification_tokens": { + "name": "im_email_verification_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_verification_tokens_user_id": { + "name": "idx_verification_tokens_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_verification_tokens_token": { + "name": "idx_verification_tokens_token", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_verification_tokens_expires_at": { + "name": "idx_verification_tokens_expires_at", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_email_verification_tokens_user_id_im_users_id_fk": { + "name": "im_email_verification_tokens_user_id_im_users_id_fk", + "tableFrom": "im_email_verification_tokens", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_email_verification_tokens_token_unique": { + "name": "im_email_verification_tokens_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_files": { + "name": "im_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "bucket": { + "name": "bucket", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "file_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'workspace'" + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "uploader_id": { + "name": "uploader_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_files_key": { + "name": "idx_files_key", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_tenant": { + "name": "idx_files_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_channel": { + "name": "idx_files_channel", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_files_uploader": { + "name": "idx_files_uploader", + "columns": [ + { + "expression": "uploader_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_files_tenant_id_tenants_id_fk": { + "name": "im_files_tenant_id_tenants_id_fk", + "tableFrom": "im_files", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_files_channel_id_im_channels_id_fk": { + "name": "im_files_channel_id_im_channels_id_fk", + "tableFrom": "im_files", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "im_files_uploader_id_im_users_id_fk": { + "name": "im_files_uploader_id_im_users_id_fk", + "tableFrom": "im_files", + "tableTo": "im_users", + "columnsFrom": ["uploader_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_users": { + "name": "im_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "user_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'offline'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "email_verified_at": { + "name": "email_verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_type": { + "name": "user_type", + "type": "user_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'human'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_users_email_unique": { + "name": "im_users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + }, + "im_users_username_unique": { + "name": "im_users_username_unique", + "nullsNotDistinct": false, + "columns": ["username"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_messages": { + "name": "im_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "root_id": { + "name": "root_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "message_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_edited": { + "name": "is_edited", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_deleted": { + "name": "is_deleted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seq_id": { + "name": "seq_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "client_msg_id": { + "name": "client_msg_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "gateway_id": { + "name": "gateway_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_messages_channel_id": { + "name": "idx_messages_channel_id", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_sender_id": { + "name": "idx_messages_sender_id", + "columns": [ + { + "expression": "sender_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_parent_id": { + "name": "idx_messages_parent_id", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_root_id": { + "name": "idx_messages_root_id", + "columns": [ + { + "expression": "root_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_created_at": { + "name": "idx_messages_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_seq_id": { + "name": "idx_messages_seq_id", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_messages_client_msg_id": { + "name": "idx_messages_client_msg_id", + "columns": [ + { + "expression": "client_msg_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_messages_channel_id_im_channels_id_fk": { + "name": "im_messages_channel_id_im_channels_id_fk", + "tableFrom": "im_messages", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_messages_sender_id_im_users_id_fk": { + "name": "im_messages_sender_id_im_users_id_fk", + "tableFrom": "im_messages", + "tableTo": "im_users", + "columnsFrom": ["sender_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_message_attachments": { + "name": "im_message_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "file_key": { + "name": "file_key", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_message_attachments_message_id": { + "name": "idx_message_attachments_message_id", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_message_attachments_message_id_im_messages_id_fk": { + "name": "im_message_attachments_message_id_im_messages_id_fk", + "tableFrom": "im_message_attachments", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_message_reactions": { + "name": "im_message_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "im_message_reactions_message_id_im_messages_id_fk": { + "name": "im_message_reactions_message_id_im_messages_id_fk", + "tableFrom": "im_message_reactions", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_message_reactions_user_id_im_users_id_fk": { + "name": "im_message_reactions_user_id_im_users_id_fk", + "tableFrom": "im_message_reactions", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_reaction": { + "name": "unique_reaction", + "nullsNotDistinct": false, + "columns": ["message_id", "user_id", "emoji"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_message_acks": { + "name": "im_message_acks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "ack_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sent'" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "gateway_id": { + "name": "gateway_id", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_retry_at": { + "name": "last_retry_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_acks_message_id": { + "name": "idx_message_acks_message_id", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_acks_user_id": { + "name": "idx_message_acks_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_acks_status": { + "name": "idx_message_acks_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_acks_retry": { + "name": "idx_message_acks_retry", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "retry_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_message_acks_message_id_im_messages_id_fk": { + "name": "im_message_acks_message_id_im_messages_id_fk", + "tableFrom": "im_message_acks", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_message_acks_user_id_im_users_id_fk": { + "name": "im_message_acks_user_id_im_users_id_fk", + "tableFrom": "im_message_acks", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_message_user_ack": { + "name": "unique_message_user_ack", + "nullsNotDistinct": false, + "columns": ["message_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_message_outbox": { + "name": "im_message_outbox", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "outbox_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_outbox_status": { + "name": "idx_outbox_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_outbox_created": { + "name": "idx_outbox_created", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_outbox_message_id": { + "name": "idx_outbox_message_id", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_outbox_status_created": { + "name": "idx_outbox_status_created", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_message_outbox_message_id_im_messages_id_fk": { + "name": "im_message_outbox_message_id_im_messages_id_fk", + "tableFrom": "im_message_outbox", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_user_channel_read_status": { + "name": "im_user_channel_read_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_read_message_id": { + "name": "last_read_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "unread_count": { + "name": "unread_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_sync_seq_id": { + "name": "last_sync_seq_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "im_user_channel_read_status_user_id_im_users_id_fk": { + "name": "im_user_channel_read_status_user_id_im_users_id_fk", + "tableFrom": "im_user_channel_read_status", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_user_channel_read_status_channel_id_im_channels_id_fk": { + "name": "im_user_channel_read_status_channel_id_im_channels_id_fk", + "tableFrom": "im_user_channel_read_status", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_user_channel_read_status_last_read_message_id_im_messages_id_fk": { + "name": "im_user_channel_read_status_last_read_message_id_im_messages_id_fk", + "tableFrom": "im_user_channel_read_status", + "tableTo": "im_messages", + "columnsFrom": ["last_read_message_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_channel_read": { + "name": "unique_user_channel_read", + "nullsNotDistinct": false, + "columns": ["user_id", "channel_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_notifications": { + "name": "im_notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "notification_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "notification_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'normal'" + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reference_type": { + "name": "reference_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "action_url": { + "name": "action_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_notifications_user_unread": { + "name": "idx_notifications_user_unread", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_archived", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_user_category": { + "name": "idx_notifications_user_category", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_user_type": { + "name": "idx_notifications_user_type", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_read", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_expires": { + "name": "idx_notifications_expires", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_reference": { + "name": "idx_notifications_reference", + "columns": [ + { + "expression": "reference_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_message": { + "name": "idx_notifications_message", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_notifications_channel": { + "name": "idx_notifications_channel", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_notifications_user_id_im_users_id_fk": { + "name": "im_notifications_user_id_im_users_id_fk", + "tableFrom": "im_notifications", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_notifications_actor_id_im_users_id_fk": { + "name": "im_notifications_actor_id_im_users_id_fk", + "tableFrom": "im_notifications", + "tableTo": "im_users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "im_notifications_tenant_id_tenants_id_fk": { + "name": "im_notifications_tenant_id_tenants_id_fk", + "tableFrom": "im_notifications", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_notifications_channel_id_im_channels_id_fk": { + "name": "im_notifications_channel_id_im_channels_id_fk", + "tableFrom": "im_notifications", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_notifications_message_id_im_messages_id_fk": { + "name": "im_notifications_message_id_im_messages_id_fk", + "tableFrom": "im_notifications", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_channel_notification_mutes": { + "name": "im_channel_notification_mutes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "muted_until": { + "name": "muted_until", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_channel_mutes_user": { + "name": "idx_channel_mutes_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_channel_mutes_channel": { + "name": "idx_channel_mutes_channel", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_channel_notification_mutes_user_id_im_users_id_fk": { + "name": "im_channel_notification_mutes_user_id_im_users_id_fk", + "tableFrom": "im_channel_notification_mutes", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "im_channel_notification_mutes_channel_id_im_channels_id_fk": { + "name": "im_channel_notification_mutes_channel_id_im_channels_id_fk", + "tableFrom": "im_channel_notification_mutes", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_channel_notification_mute": { + "name": "unique_channel_notification_mute", + "nullsNotDistinct": false, + "columns": ["user_id", "channel_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_notification_preferences": { + "name": "im_notification_preferences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "mentions_enabled": { + "name": "mentions_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "replies_enabled": { + "name": "replies_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "dms_enabled": { + "name": "dms_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "system_enabled": { + "name": "system_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "workspace_enabled": { + "name": "workspace_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "desktop_enabled": { + "name": "desktop_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sound_enabled": { + "name": "sound_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "dnd_enabled": { + "name": "dnd_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dnd_start": { + "name": "dnd_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "dnd_end": { + "name": "dnd_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "im_notification_preferences_user_id_im_users_id_fk": { + "name": "im_notification_preferences_user_id_im_users_id_fk", + "tableFrom": "im_notification_preferences", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_user_notification_preferences": { + "name": "unique_user_notification_preferences", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_channel_search": { + "name": "im_channel_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_type": { + "name": "channel_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "member_count": { + "name": "member_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "channel_created_at": { + "name": "channel_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_channel_search_vector": { + "name": "idx_channel_search_vector", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_channel_search_tenant": { + "name": "idx_channel_search_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_channel_search_type": { + "name": "idx_channel_search_type", + "columns": [ + { + "expression": "channel_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_channel_search_channel_id_im_channels_id_fk": { + "name": "im_channel_search_channel_id_im_channels_id_fk", + "tableFrom": "im_channel_search", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_channel_search_channel_id_unique": { + "name": "im_channel_search_channel_id_unique", + "nullsNotDistinct": false, + "columns": ["channel_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_file_search": { + "name": "im_file_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "uploader_id": { + "name": "uploader_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "uploader_username": { + "name": "uploader_username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "file_created_at": { + "name": "file_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_file_search_vector": { + "name": "idx_file_search_vector", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_file_search_channel": { + "name": "idx_file_search_channel", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_file_search_tenant": { + "name": "idx_file_search_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_file_search_mime": { + "name": "idx_file_search_mime", + "columns": [ + { + "expression": "mime_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_file_search_file_id_im_files_id_fk": { + "name": "im_file_search_file_id_im_files_id_fk", + "tableFrom": "im_file_search", + "tableTo": "im_files", + "columnsFrom": ["file_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_file_search_file_id_unique": { + "name": "im_file_search_file_id_unique", + "nullsNotDistinct": false, + "columns": ["file_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_message_search": { + "name": "im_message_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true + }, + "content_snapshot": { + "name": "content_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_name": { + "name": "channel_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "sender_username": { + "name": "sender_username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "message_type": { + "name": "message_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "has_attachment": { + "name": "has_attachment", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_thread_reply": { + "name": "is_thread_reply", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_created_at": { + "name": "message_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_message_search_vector": { + "name": "idx_message_search_vector", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_message_search_channel": { + "name": "idx_message_search_channel", + "columns": [ + { + "expression": "channel_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_search_sender": { + "name": "idx_message_search_sender", + "columns": [ + { + "expression": "sender_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_search_tenant": { + "name": "idx_message_search_tenant", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_search_created": { + "name": "idx_message_search_created", + "columns": [ + { + "expression": "message_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_message_search_tenant_created": { + "name": "idx_message_search_tenant_created", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_message_search_message_id_im_messages_id_fk": { + "name": "im_message_search_message_id_im_messages_id_fk", + "tableFrom": "im_message_search", + "tableTo": "im_messages", + "columnsFrom": ["message_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_message_search_message_id_unique": { + "name": "im_message_search_message_id_unique", + "nullsNotDistinct": false, + "columns": ["message_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_user_search": { + "name": "im_user_search", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "search_vector": { + "name": "search_vector", + "type": "tsvector", + "primaryKey": false, + "notNull": true + }, + "username": { + "name": "username", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "user_created_at": { + "name": "user_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_search_vector": { + "name": "idx_user_search_vector", + "columns": [ + { + "expression": "search_vector", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "idx_user_search_status": { + "name": "idx_user_search_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_user_search_user_id_im_users_id_fk": { + "name": "im_user_search_user_id_im_users_id_fk", + "tableFrom": "im_user_search", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "im_user_search_user_id_unique": { + "name": "im_user_search_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.im_installed_applications": { + "name": "im_installed_applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installed_by": { + "name": "installed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "secrets": { + "name": "secrets", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "status": { + "name": "status", + "type": "installed_application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_installed_applications_tenant_id": { + "name": "idx_installed_applications_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_installed_applications_application_id": { + "name": "idx_installed_applications_application_id", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_installed_applications_status": { + "name": "idx_installed_applications_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "im_installed_applications_installed_by_im_users_id_fk": { + "name": "im_installed_applications_installed_by_im_users_id_fk", + "tableFrom": "im_installed_applications", + "tableTo": "im_users", + "columnsFrom": ["installed_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenants": { + "name": "tenants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan": { + "name": "plan", + "type": "tenant_plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "settings": { + "name": "settings", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tenants_slug_unique": { + "name": "tenants_slug_unique", + "nullsNotDistinct": false, + "columns": ["slug"] + }, + "tenants_domain_unique": { + "name": "tenants_domain_unique", + "nullsNotDistinct": false, + "columns": ["domain"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tenant_members": { + "name": "tenant_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "tenant_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "left_at": { + "name": "left_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_tenant_members_user_id": { + "name": "idx_tenant_members_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tenant_members_tenant_id_tenants_id_fk": { + "name": "tenant_members_tenant_id_tenants_id_fk", + "tableFrom": "tenant_members", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_members_user_id_im_users_id_fk": { + "name": "tenant_members_user_id_im_users_id_fk", + "tableFrom": "tenant_members", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tenant_members_invited_by_im_users_id_fk": { + "name": "tenant_members_invited_by_im_users_id_fk", + "tableFrom": "tenant_members", + "tableTo": "im_users", + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_tenant_user": { + "name": "unique_tenant_user", + "nullsNotDistinct": false, + "columns": ["tenant_id", "user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_usage": { + "name": "invitation_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "invitation_usage_invitation_id_workspace_invitations_id_fk": { + "name": "invitation_usage_invitation_id_workspace_invitations_id_fk", + "tableFrom": "invitation_usage", + "tableTo": "workspace_invitations", + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_usage_user_id_im_users_id_fk": { + "name": "invitation_usage_user_id_im_users_id_fk", + "tableFrom": "invitation_usage", + "tableTo": "im_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_invitations": { + "name": "workspace_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "tenant_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "used_count": { + "name": "used_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_workspace_invitations_tenant_id": { + "name": "idx_workspace_invitations_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_invitations_tenant_id_tenants_id_fk": { + "name": "workspace_invitations_tenant_id_tenants_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_invitations_created_by_im_users_id_fk": { + "name": "workspace_invitations_created_by_im_users_id_fk", + "tableFrom": "workspace_invitations", + "tableTo": "im_users", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_invitations_code_unique": { + "name": "workspace_invitations_code_unique", + "nullsNotDistinct": false, + "columns": ["code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task__tasks": { + "name": "agent_task__tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "bot_id": { + "name": "bot_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "agent_task__status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'upcoming'" + }, + "schedule_type": { + "name": "schedule_type", + "type": "agent_task__schedule_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'once'" + }, + "schedule_config": { + "name": "schedule_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "document_id": { + "name": "document_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "current_execution_id": { + "name": "current_execution_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_task__tasks_tenant_id": { + "name": "idx_agent_task__tasks_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_task__tasks_bot_id": { + "name": "idx_agent_task__tasks_bot_id", + "columns": [ + { + "expression": "bot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_task__tasks_creator_id": { + "name": "idx_agent_task__tasks_creator_id", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_task__tasks_status": { + "name": "idx_agent_task__tasks_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_task__tasks_next_run_at": { + "name": "idx_agent_task__tasks_next_run_at", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_task__tasks_tenant_status": { + "name": "idx_agent_task__tasks_tenant_status", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task__tasks_tenant_id_tenants_id_fk": { + "name": "agent_task__tasks_tenant_id_tenants_id_fk", + "tableFrom": "agent_task__tasks", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_task__tasks_bot_id_im_bots_id_fk": { + "name": "agent_task__tasks_bot_id_im_bots_id_fk", + "tableFrom": "agent_task__tasks", + "tableTo": "im_bots", + "columnsFrom": ["bot_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_task__tasks_creator_id_im_users_id_fk": { + "name": "agent_task__tasks_creator_id_im_users_id_fk", + "tableFrom": "agent_task__tasks", + "tableTo": "im_users", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task__tasks_document_id_documents_id_fk": { + "name": "agent_task__tasks_document_id_documents_id_fk", + "tableFrom": "agent_task__tasks", + "tableTo": "documents", + "columnsFrom": ["document_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task__executions": { + "name": "agent_task__executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_version": { + "name": "task_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "agent_task__status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'in_progress'" + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "taskcast_task_id": { + "name": "taskcast_task_id", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "token_usage": { + "name": "token_usage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "trigger_id": { + "name": "trigger_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "trigger_context": { + "name": "trigger_context", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "document_version_id": { + "name": "document_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source_execution_id": { + "name": "source_execution_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_task__executions_task_id": { + "name": "idx_agent_task__executions_task_id", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_task__executions_status": { + "name": "idx_agent_task__executions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_task__executions_task_version": { + "name": "idx_agent_task__executions_task_version", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "task_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task__executions_task_id_agent_task__tasks_id_fk": { + "name": "agent_task__executions_task_id_agent_task__tasks_id_fk", + "tableFrom": "agent_task__executions", + "tableTo": "agent_task__tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_task__executions_channel_id_im_channels_id_fk": { + "name": "agent_task__executions_channel_id_im_channels_id_fk", + "tableFrom": "agent_task__executions", + "tableTo": "im_channels", + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task__executions_trigger_id_agent_task__triggers_id_fk": { + "name": "agent_task__executions_trigger_id_agent_task__triggers_id_fk", + "tableFrom": "agent_task__executions", + "tableTo": "agent_task__triggers", + "columnsFrom": ["trigger_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task__executions_document_version_id_document_versions_id_fk": { + "name": "agent_task__executions_document_version_id_document_versions_id_fk", + "tableFrom": "agent_task__executions", + "tableTo": "document_versions", + "columnsFrom": ["document_version_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "uq_agent_task__executions_taskcast": { + "name": "uq_agent_task__executions_taskcast", + "nullsNotDistinct": false, + "columns": ["taskcast_task_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task__steps": { + "name": "agent_task__steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "order_index": { + "name": "order_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "agent_task__step_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token_usage": { + "name": "token_usage", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_task__steps_execution_id": { + "name": "idx_agent_task__steps_execution_id", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_task__steps_task_id": { + "name": "idx_agent_task__steps_task_id", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task__steps_execution_id_agent_task__executions_id_fk": { + "name": "agent_task__steps_execution_id_agent_task__executions_id_fk", + "tableFrom": "agent_task__steps", + "tableTo": "agent_task__executions", + "columnsFrom": ["execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_task__steps_task_id_agent_task__tasks_id_fk": { + "name": "agent_task__steps_task_id_agent_task__tasks_id_fk", + "tableFrom": "agent_task__steps", + "tableTo": "agent_task__tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task__deliverables": { + "name": "agent_task__deliverables", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "file_size": { + "name": "file_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_task__deliverables_execution_id": { + "name": "idx_agent_task__deliverables_execution_id", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_task__deliverables_task_id": { + "name": "idx_agent_task__deliverables_task_id", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task__deliverables_execution_id_agent_task__executions_id_fk": { + "name": "agent_task__deliverables_execution_id_agent_task__executions_id_fk", + "tableFrom": "agent_task__deliverables", + "tableTo": "agent_task__executions", + "columnsFrom": ["execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_task__deliverables_task_id_agent_task__tasks_id_fk": { + "name": "agent_task__deliverables_task_id_agent_task__tasks_id_fk", + "tableFrom": "agent_task__deliverables", + "tableTo": "agent_task__tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task__interventions": { + "name": "agent_task__interventions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "response": { + "name": "response", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "agent_task__intervention_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "resolved_by": { + "name": "resolved_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_task__interventions_execution_id": { + "name": "idx_agent_task__interventions_execution_id", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_task__interventions_task_id": { + "name": "idx_agent_task__interventions_task_id", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_task__interventions_status": { + "name": "idx_agent_task__interventions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task__interventions_execution_id_agent_task__executions_id_fk": { + "name": "agent_task__interventions_execution_id_agent_task__executions_id_fk", + "tableFrom": "agent_task__interventions", + "tableTo": "agent_task__executions", + "columnsFrom": ["execution_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_task__interventions_task_id_agent_task__tasks_id_fk": { + "name": "agent_task__interventions_task_id_agent_task__tasks_id_fk", + "tableFrom": "agent_task__interventions", + "tableTo": "agent_task__tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_task__interventions_step_id_agent_task__steps_id_fk": { + "name": "agent_task__interventions_step_id_agent_task__steps_id_fk", + "tableFrom": "agent_task__interventions", + "tableTo": "agent_task__steps", + "columnsFrom": ["step_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_task__interventions_resolved_by_im_users_id_fk": { + "name": "agent_task__interventions_resolved_by_im_users_id_fk", + "tableFrom": "agent_task__interventions", + "tableTo": "im_users", + "columnsFrom": ["resolved_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_task__triggers": { + "name": "agent_task__triggers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "agent_task__trigger_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_agent_task__triggers_task_id": { + "name": "idx_agent_task__triggers_task_id", + "columns": [ + { + "expression": "task_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_agent_task__triggers_scan": { + "name": "idx_agent_task__triggers_scan", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_task__triggers_task_id_agent_task__tasks_id_fk": { + "name": "agent_task__triggers_task_id_agent_task__tasks_id_fk", + "tableFrom": "agent_task__triggers", + "tableTo": "agent_task__tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resources": { + "name": "resources", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "resource__type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "resource__status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'offline'" + }, + "authorizations": { + "name": "authorizations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "last_heartbeat_at": { + "name": "last_heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_resources_tenant_id": { + "name": "idx_resources_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_resources_tenant_type": { + "name": "idx_resources_tenant_type", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_resources_status": { + "name": "idx_resources_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resources_tenant_id_tenants_id_fk": { + "name": "resources_tenant_id_tenants_id_fk", + "tableFrom": "resources", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resources_creator_id_im_users_id_fk": { + "name": "resources_creator_id_im_users_id_fk", + "tableFrom": "resources", + "tableTo": "im_users", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resource_usage_logs": { + "name": "resource_usage_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_type": { + "name": "actor_type", + "type": "resource__actor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_resource_usage_logs_resource_created": { + "name": "idx_resource_usage_logs_resource_created", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_resource_usage_logs_actor_created": { + "name": "idx_resource_usage_logs_actor_created", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resource_usage_logs_resource_id_resources_id_fk": { + "name": "resource_usage_logs_resource_id_resources_id_fk", + "tableFrom": "resource_usage_logs", + "tableTo": "resources", + "columnsFrom": ["resource_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "resource_usage_logs_task_id_agent_task__tasks_id_fk": { + "name": "resource_usage_logs_task_id_agent_task__tasks_id_fk", + "tableFrom": "resource_usage_logs", + "tableTo": "agent_task__tasks", + "columnsFrom": ["task_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "resource_usage_logs_execution_id_agent_task__executions_id_fk": { + "name": "resource_usage_logs_execution_id_agent_task__executions_id_fk", + "tableFrom": "resource_usage_logs", + "tableTo": "agent_task__executions", + "columnsFrom": ["execution_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skills": { + "name": "skills", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "skill__type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "icon": { + "name": "icon", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "current_version": { + "name": "current_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_skills_tenant_id": { + "name": "idx_skills_tenant_id", + "columns": [ + { + "expression": "tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skills_tenant_id_tenants_id_fk": { + "name": "skills_tenant_id_tenants_id_fk", + "tableFrom": "skills", + "tableTo": "tenants", + "columnsFrom": ["tenant_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skills_creator_id_im_users_id_fk": { + "name": "skills_creator_id_im_users_id_fk", + "tableFrom": "skills", + "tableTo": "im_users", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_versions": { + "name": "skill_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "skill_version__status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "file_manifest": { + "name": "file_manifest", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "suggested_by": { + "name": "suggested_by", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_skill_versions_skill_version": { + "name": "idx_skill_versions_skill_version", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_versions_skill_id_skills_id_fk": { + "name": "skill_versions_skill_id_skills_id_fk", + "tableFrom": "skill_versions", + "tableTo": "skills", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_versions_creator_id_im_users_id_fk": { + "name": "skill_versions_creator_id_im_users_id_fk", + "tableFrom": "skill_versions", + "tableTo": "im_users", + "columnsFrom": ["creator_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill_files": { + "name": "skill_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "skill_id": { + "name": "skill_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "path": { + "name": "path", + "type": "varchar(1024)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_skill_files_skill_id": { + "name": "idx_skill_files_skill_id", + "columns": [ + { + "expression": "skill_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_files_skill_id_skills_id_fk": { + "name": "skill_files_skill_id_skills_id_fk", + "tableFrom": "skill_files", + "tableTo": "skills", + "columnsFrom": ["skill_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.document_suggestion_status": { + "name": "document_suggestion_status", + "schema": "public", + "values": ["pending", "approved", "rejected"] + }, + "public.bot_type": { + "name": "bot_type", + "schema": "public", + "values": ["system", "custom", "webhook"] + }, + "public.member_role": { + "name": "member_role", + "schema": "public", + "values": ["owner", "admin", "member"] + }, + "public.channel_type": { + "name": "channel_type", + "schema": "public", + "values": ["direct", "public", "private", "task"] + }, + "public.file_visibility": { + "name": "file_visibility", + "schema": "public", + "values": ["private", "channel", "workspace", "public"] + }, + "public.user_status": { + "name": "user_status", + "schema": "public", + "values": ["online", "offline", "away", "busy"] + }, + "public.user_type": { + "name": "user_type", + "schema": "public", + "values": ["human", "bot", "system"] + }, + "public.message_type": { + "name": "message_type", + "schema": "public", + "values": ["text", "file", "image", "system"] + }, + "public.ack_status": { + "name": "ack_status", + "schema": "public", + "values": ["sent", "delivered", "read"] + }, + "public.outbox_status": { + "name": "outbox_status", + "schema": "public", + "values": ["pending", "processing", "completed", "failed"] + }, + "public.notification_category": { + "name": "notification_category", + "schema": "public", + "values": ["message", "system", "workspace"] + }, + "public.notification_priority": { + "name": "notification_priority", + "schema": "public", + "values": ["low", "normal", "high", "urgent"] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "mention", + "channel_mention", + "everyone_mention", + "here_mention", + "reply", + "thread_reply", + "dm_received", + "system_announcement", + "maintenance_notice", + "version_update", + "workspace_invitation", + "role_changed", + "member_joined", + "member_left", + "channel_invite" + ] + }, + "public.installed_application_status": { + "name": "installed_application_status", + "schema": "public", + "values": ["active", "inactive", "pending", "error"] + }, + "public.tenant_plan": { + "name": "tenant_plan", + "schema": "public", + "values": ["free", "pro", "enterprise"] + }, + "public.tenant_role": { + "name": "tenant_role", + "schema": "public", + "values": ["owner", "admin", "member", "guest"] + }, + "public.agent_task__schedule_type": { + "name": "agent_task__schedule_type", + "schema": "public", + "values": ["once", "recurring"] + }, + "public.agent_task__status": { + "name": "agent_task__status", + "schema": "public", + "values": [ + "upcoming", + "in_progress", + "paused", + "pending_action", + "completed", + "failed", + "stopped", + "timeout" + ] + }, + "public.agent_task__step_status": { + "name": "agent_task__step_status", + "schema": "public", + "values": ["pending", "in_progress", "completed", "failed"] + }, + "public.agent_task__intervention_status": { + "name": "agent_task__intervention_status", + "schema": "public", + "values": ["pending", "resolved", "expired"] + }, + "public.agent_task__trigger_type": { + "name": "agent_task__trigger_type", + "schema": "public", + "values": ["manual", "interval", "schedule", "channel_message"] + }, + "public.resource__status": { + "name": "resource__status", + "schema": "public", + "values": ["online", "offline", "error", "configuring"] + }, + "public.resource__type": { + "name": "resource__type", + "schema": "public", + "values": ["agent_computer", "api"] + }, + "public.resource__actor_type": { + "name": "resource__actor_type", + "schema": "public", + "values": ["agent", "user"] + }, + "public.skill__type": { + "name": "skill__type", + "schema": "public", + "values": ["claude_code_skill", "prompt_template", "general"] + }, + "public.skill_version__status": { + "name": "skill_version__status", + "schema": "public", + "values": ["draft", "published", "suggested", "rejected"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/libs/database/migrations/meta/_journal.json b/apps/server/libs/database/migrations/meta/_journal.json index ccf97a34..c606b99b 100644 --- a/apps/server/libs/database/migrations/meta/_journal.json +++ b/apps/server/libs/database/migrations/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1773079252292, "tag": "0023_nasty_swarm", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1773665975706, + "tag": "0024_ambitious_mentor", + "breakpoints": true } ] } diff --git a/apps/server/libs/database/src/schemas/task/task-executions.ts b/apps/server/libs/database/src/schemas/task/task-executions.ts index cc3ed05c..72e4c878 100644 --- a/apps/server/libs/database/src/schemas/task/task-executions.ts +++ b/apps/server/libs/database/src/schemas/task/task-executions.ts @@ -65,7 +65,7 @@ export const agentTaskExecutions = pgTable( .references(() => agentTasks.id, { onDelete: 'cascade' }) .notNull(), - version: integer('version').notNull(), + taskVersion: integer('task_version').notNull(), status: agentTaskStatusEnum('status').default('in_progress').notNull(), @@ -96,7 +96,7 @@ export const agentTaskExecutions = pgTable( index('idx_agent_task__executions_status').on(table.status), index('idx_agent_task__executions_task_version').on( table.taskId, - table.version, + table.taskVersion, ), unique('uq_agent_task__executions_taskcast').on(table.taskcastTaskId), ], diff --git a/apps/server/libs/database/src/schemas/task/tasks.ts b/apps/server/libs/database/src/schemas/task/tasks.ts index 1cc42265..16342d47 100644 --- a/apps/server/libs/database/src/schemas/task/tasks.ts +++ b/apps/server/libs/database/src/schemas/task/tasks.ts @@ -3,6 +3,7 @@ import { uuid, varchar, text, + integer, timestamp, jsonb, index, @@ -72,6 +73,8 @@ export const agentTasks = pgTable( /** @deprecated Use agent_task__triggers.next_run_at instead */ nextRunAt: timestamp('next_run_at'), + version: integer('version').default(1).notNull(), + documentId: uuid('document_id').references(() => documents.id), // Forward reference — FK to agent_task__executions added via relations From 8278095e41fe02aeaf1ba54fbc1d6711c1388dc5 Mon Sep 17 00:00:00 2001 From: Heathcliff Date: Mon, 16 Mar 2026 21:36:57 +0800 Subject: [PATCH 26/68] test(tasks): add unit tests for OpenclawStrategy and ExecutorService Cover secrets extraction fallback, bot lookup failures, version snapshot behavior, strategy delegation, and channel naming logic. --- .../src/executor/executor.service.spec.ts | 258 ++++++++++++++++++ .../strategies/openclaw.strategy.spec.ts | 231 ++++++++++++++++ 2 files changed, 489 insertions(+) create mode 100644 apps/server/apps/task-worker/src/executor/executor.service.spec.ts create mode 100644 apps/server/apps/task-worker/src/executor/strategies/openclaw.strategy.spec.ts diff --git a/apps/server/apps/task-worker/src/executor/executor.service.spec.ts b/apps/server/apps/task-worker/src/executor/executor.service.spec.ts new file mode 100644 index 00000000..0c24d500 --- /dev/null +++ b/apps/server/apps/task-worker/src/executor/executor.service.spec.ts @@ -0,0 +1,258 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import type { ExecutionStrategy } from './execution-strategy.interface.js'; + +// ── DB mock ──────────────────────────────────────────────────────────── + +/** + * We use a result queue: each call to db.select()...limit() pops the next + * result from the queue. This is order-dependent but matches the sequential + * queries in ExecutorService.triggerExecution(). + */ +let selectResultQueue: any[][]; + +function makeChainableSelect() { + const chain: any = {}; + chain.select = jest.fn().mockReturnValue(chain); + chain.from = jest.fn().mockReturnValue(chain); + chain.innerJoin = jest.fn().mockReturnValue(chain); + chain.where = jest.fn().mockReturnValue(chain); + chain.orderBy = jest.fn().mockReturnValue(chain); + chain.limit = jest.fn().mockImplementation(() => { + return Promise.resolve(selectResultQueue.shift() ?? []); + }); + return chain; +} + +const insertValues: any[] = []; +function makeChainableInsert() { + const chain: any = {}; + chain.values = jest.fn().mockImplementation((v: any) => { + insertValues.push(v); + return chain; + }); + chain.returning = jest.fn().mockResolvedValue([]); + return chain; +} + +const updateSets: any[] = []; +function makeChainableUpdate() { + const chain: any = {}; + chain.set = jest.fn().mockImplementation((v: any) => { + updateSets.push(v); + return chain; + }); + chain.where = jest.fn().mockResolvedValue([]); + return chain; +} + +let mockDb: any; + +function resetDb() { + const selectChain = makeChainableSelect(); + const insertChain = makeChainableInsert(); + const updateChain = makeChainableUpdate(); + + mockDb = { + select: jest.fn().mockReturnValue(selectChain), + insert: jest.fn().mockReturnValue(insertChain), + update: jest.fn().mockReturnValue(updateChain), + _selectChain: selectChain, + _insertChain: insertChain, + _updateChain: updateChain, + }; +} + +// ── TaskCast mock ────────────────────────────────────────────────────── + +const mockTaskCastClient = { + createTask: jest.fn().mockResolvedValue('tc-task-id'), +}; + +// ── UUID mock ────────────────────────────────────────────────────────── + +let uuidCounter = 0; +jest.unstable_mockModule('uuid', () => ({ + v7: jest.fn().mockImplementation(() => `mock-uuid-${++uuidCounter}`), +})); + +// ── Test data ────────────────────────────────────────────────────────── + +const sampleTask = { + id: 'task-001', + title: 'My Test Task', + tenantId: 'tenant-001', + creatorId: 'user-001', + botId: 'bot-001', + documentId: null, + version: 3, + status: 'idle', +}; + +const sampleBot = { + userId: 'bot-user-001', + type: 'system', +}; + +// ── Tests ────────────────────────────────────────────────────────────── + +describe('ExecutorService', () => { + let ExecutorService: any; + let service: any; + let mockStrategy: ExecutionStrategy; + + beforeEach(async () => { + jest.clearAllMocks(); + uuidCounter = 0; + selectResultQueue = []; + insertValues.length = 0; + updateSets.length = 0; + + resetDb(); + mockTaskCastClient.createTask.mockResolvedValue('tc-task-id'); + + ({ ExecutorService } = await import('./executor.service.js')); + service = new ExecutorService(mockDb, mockTaskCastClient); + + mockStrategy = { + execute: jest.fn().mockResolvedValue(undefined), + pause: jest.fn().mockResolvedValue(undefined), + resume: jest.fn().mockResolvedValue(undefined), + stop: jest.fn().mockResolvedValue(undefined), + }; + }); + + // ── Task not found ───────────────────────────────────────────────── + + it('should return early when task does not exist', async () => { + selectResultQueue = [[]]; // task query returns empty + + await service.triggerExecution('nonexistent-task'); + + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + + // ── Task has no bot ──────────────────────────────────────────────── + + it('should return early when task has no botId', async () => { + selectResultQueue = [[{ ...sampleTask, botId: null }]]; + + await service.triggerExecution('task-001'); + + expect(mockDb.insert).not.toHaveBeenCalled(); + }); + + // ── Version snapshot ─────────────────────────────────────────────── + + it('should snapshot the task version in execution record', async () => { + // Queue: task (version=5) → bot lookup + selectResultQueue = [[{ ...sampleTask, version: 5 }], [sampleBot]]; + service.registerStrategy('system', mockStrategy); + + await service.triggerExecution('task-001'); + + // insertValues: [channel, channelMember-creator, execution, task-status-update, channelMember-bot] + // The execution insert is the 3rd one (index 2) + const executionInsert = insertValues.find( + (v) => v.taskVersion !== undefined, + ); + expect(executionInsert).toBeDefined(); + expect(executionInsert.taskVersion).toBe(5); + }); + + // ── Bot not found ────────────────────────────────────────────────── + + it('should mark execution as failed when bot lookup returns empty', async () => { + selectResultQueue = [ + [sampleTask], // task found + [], // bot not found + ]; + + await service.triggerExecution('task-001'); + + // Should call update to mark status as failed + expect(mockDb.update).toHaveBeenCalled(); + const failedSet = updateSets.find((s) => s.status === 'failed'); + expect(failedSet).toBeDefined(); + }); + + // ── No strategy registered ──────────────────────────────────────── + + it('should mark execution as failed when no strategy is registered for bot type', async () => { + selectResultQueue = [ + [sampleTask], + [{ userId: 'bot-user-001', type: 'unknown_type' }], + ]; + // No strategy registered + + await service.triggerExecution('task-001'); + + expect(mockDb.update).toHaveBeenCalled(); + const failedSet = updateSets.find((s) => s.status === 'failed'); + expect(failedSet).toBeDefined(); + }); + + // ── Strategy failure ─────────────────────────────────────────────── + + it('should mark execution as failed when strategy.execute throws', async () => { + selectResultQueue = [[sampleTask], [sampleBot]]; + + const failingStrategy: ExecutionStrategy = { + execute: jest.fn().mockRejectedValue(new Error('agent crashed')), + pause: jest.fn(), + resume: jest.fn(), + stop: jest.fn(), + }; + service.registerStrategy('system', failingStrategy); + + await service.triggerExecution('task-001'); + + expect(failingStrategy.execute).toHaveBeenCalled(); + const failedSet = updateSets.find((s) => s.status === 'failed'); + expect(failedSet).toBeDefined(); + }); + + // ── Happy path ───────────────────────────────────────────────────── + + it('should delegate to the registered strategy on success', async () => { + selectResultQueue = [[sampleTask], [sampleBot]]; + service.registerStrategy('system', mockStrategy); + + await service.triggerExecution('task-001'); + + expect(mockStrategy.execute).toHaveBeenCalledWith( + expect.objectContaining({ + taskId: 'task-001', + botId: 'bot-001', + }), + ); + }); + + // ── Channel naming ──────────────────────────────────────────────── + + it('should create task channel with sanitized name and channelId suffix', async () => { + selectResultQueue = [ + [{ ...sampleTask, title: 'My Multi Space Task' }], + [sampleBot], + ]; + service.registerStrategy('system', mockStrategy); + + await service.triggerExecution('task-001'); + + const channelInsert = insertValues.find((v) => v.type === 'task'); + expect(channelInsert).toBeDefined(); + expect(channelInsert.name).not.toMatch(/\s/); + expect(channelInsert.name).toMatch(/^task-my-multi-space-task-/); + }); + + // ── Task status update ──────────────────────────────────────────── + + it('should update task status to in_progress on execution start', async () => { + selectResultQueue = [[sampleTask], [sampleBot]]; + service.registerStrategy('system', mockStrategy); + + await service.triggerExecution('task-001'); + + const inProgressSet = updateSets.find((s) => s.status === 'in_progress'); + expect(inProgressSet).toBeDefined(); + }); +}); diff --git a/apps/server/apps/task-worker/src/executor/strategies/openclaw.strategy.spec.ts b/apps/server/apps/task-worker/src/executor/strategies/openclaw.strategy.spec.ts new file mode 100644 index 00000000..9d9d8376 --- /dev/null +++ b/apps/server/apps/task-worker/src/executor/strategies/openclaw.strategy.spec.ts @@ -0,0 +1,231 @@ +import { jest, describe, it, expect, beforeEach } from '@jest/globals'; +import type { ExecutionContext } from '../execution-strategy.interface.js'; + +// ── Mocks ────────────────────────────────────────────────────────────── + +const mockDb = { + select: jest.fn(), + from: jest.fn(), + leftJoin: jest.fn(), + where: jest.fn(), + limit: jest.fn(), +}; + +// Chain: db.select().from().leftJoin().where().limit() +mockDb.select.mockReturnValue(mockDb); +mockDb.from.mockReturnValue(mockDb); +mockDb.leftJoin.mockReturnValue(mockDb); +mockDb.where.mockReturnValue(mockDb); +mockDb.limit.mockReturnValue(Promise.resolve([])); + +const mockFetch = jest.fn(); + +const baseContext: ExecutionContext = { + taskId: 'task-001', + executionId: 'exec-001', + botId: 'bot-001', + channelId: 'ch-001', + taskcastTaskId: 'tc-001', +}; + +// ── Helpers ──────────────────────────────────────────────────────────── + +function resetDbChain(result: any[] = []) { + mockDb.select.mockReturnValue(mockDb); + mockDb.from.mockReturnValue(mockDb); + mockDb.leftJoin.mockReturnValue(mockDb); + mockDb.where.mockReturnValue(mockDb); + mockDb.limit.mockReturnValue(Promise.resolve(result)); +} + +function makeBot(opts: { + agentId?: string; + accessUrl?: string; + nestedAccessUrl?: string; +}) { + const extra = opts.agentId ? { openclaw: { agentId: opts.agentId } } : {}; + const secrets: Record = {}; + if (opts.accessUrl) { + secrets.instanceResult = { access_url: opts.accessUrl }; + } else if (opts.nestedAccessUrl) { + secrets.instanceResult = { + instance: { access_url: opts.nestedAccessUrl }, + }; + } + return { extra, secrets }; +} + +// ── Tests ────────────────────────────────────────────────────────────── + +describe('OpenclawStrategy', () => { + let OpenclawStrategy: any; + let strategy: any; + + beforeEach(async () => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + + // Reset fetch mock + globalThis.fetch = mockFetch as any; + mockFetch.mockReset(); + + // Dynamic import to pick up mocks + ({ OpenclawStrategy } = await import('./openclaw.strategy.js')); + strategy = new OpenclawStrategy(mockDb); + }); + + // ── Bot not found ────────────────────────────────────────────────── + + it('should throw when bot is not found in database', async () => { + resetDbChain([]); + + await expect(strategy.execute(baseContext)).rejects.toThrow( + 'OpenClaw bot not found: bot-001', + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + // ── URL extraction ───────────────────────────────────────────────── + + it('should throw when secrets have no access_url at any level', async () => { + resetDbChain([{ extra: {}, secrets: {} }]); + + await expect(strategy.execute(baseContext)).rejects.toThrow( + 'OpenClaw URL not configured for bot bot-001', + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should throw when secrets is null (leftJoin returned no installed app)', async () => { + resetDbChain([{ extra: {}, secrets: null }]); + + await expect(strategy.execute(baseContext)).rejects.toThrow( + 'OpenClaw URL not configured for bot bot-001', + ); + }); + + it('should use top-level access_url from secrets', async () => { + const bot = makeBot({ accessUrl: 'https://claw.example.com' }); + resetDbChain([bot]); + mockFetch.mockResolvedValue({ ok: true } as Response); + + await strategy.execute(baseContext); + + const calledUrl = (mockFetch.mock.calls[0]![0] as URL).toString(); + expect(calledUrl).toBe( + 'https://claw.example.com/api/agents/default/execute', + ); + }); + + it('should fall back to nested instance.access_url when top-level is missing', async () => { + const bot = makeBot({ nestedAccessUrl: 'https://nested.example.com' }); + resetDbChain([bot]); + mockFetch.mockResolvedValue({ ok: true } as Response); + + await strategy.execute(baseContext); + + const calledUrl = (mockFetch.mock.calls[0]![0] as URL).toString(); + expect(calledUrl).toBe( + 'https://nested.example.com/api/agents/default/execute', + ); + }); + + // ── Agent ID extraction ──────────────────────────────────────────── + + it('should default agentId to "default" when extra.openclaw is missing', async () => { + resetDbChain([ + { + extra: {}, + secrets: { instanceResult: { access_url: 'https://x.com' } }, + }, + ]); + mockFetch.mockResolvedValue({ ok: true } as Response); + + await strategy.execute(baseContext); + + const calledUrl = (mockFetch.mock.calls[0]![0] as URL).toString(); + expect(calledUrl).toContain('/api/agents/default/execute'); + }); + + it('should use custom agentId from extra.openclaw.agentId', async () => { + const bot = makeBot({ + agentId: 'my-agent', + accessUrl: 'https://claw.example.com', + }); + resetDbChain([bot]); + mockFetch.mockResolvedValue({ ok: true } as Response); + + await strategy.execute(baseContext); + + const calledUrl = (mockFetch.mock.calls[0]![0] as URL).toString(); + expect(calledUrl).toBe( + 'https://claw.example.com/api/agents/my-agent/execute', + ); + }); + + it('should URL-encode agentId with special characters', async () => { + const bot = makeBot({ + agentId: 'agent/with spaces', + accessUrl: 'https://claw.example.com', + }); + resetDbChain([bot]); + mockFetch.mockResolvedValue({ ok: true } as Response); + + await strategy.execute(baseContext); + + const calledUrl = (mockFetch.mock.calls[0]![0] as URL).toString(); + expect(calledUrl).toContain('/api/agents/agent%2Fwith%20spaces/execute'); + }); + + // ── HTTP response handling ───────────────────────────────────────── + + it('should not throw when fetch returns ok', async () => { + const bot = makeBot({ accessUrl: 'https://claw.example.com' }); + resetDbChain([bot]); + mockFetch.mockResolvedValue({ ok: true } as Response); + + await expect(strategy.execute(baseContext)).resolves.toBeUndefined(); + }); + + it('should throw with status and error text when fetch returns non-ok', async () => { + const bot = makeBot({ accessUrl: 'https://claw.example.com' }); + resetDbChain([bot]); + mockFetch.mockResolvedValue({ + ok: false, + status: 502, + statusText: 'Bad Gateway', + text: () => Promise.resolve('upstream timeout'), + } as unknown as Response); + + await expect(strategy.execute(baseContext)).rejects.toThrow( + 'OpenClaw execute failed for agent default (502): upstream timeout', + ); + }); + + it('should use statusText as fallback when error body is empty', async () => { + const bot = makeBot({ accessUrl: 'https://claw.example.com' }); + resetDbChain([bot]); + mockFetch.mockResolvedValue({ + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: () => Promise.resolve(''), + } as unknown as Response); + + await expect(strategy.execute(baseContext)).rejects.toThrow( + 'OpenClaw execute failed for agent default (500): Internal Server Error', + ); + }); + + it('should use POST method for the execute call', async () => { + const bot = makeBot({ accessUrl: 'https://claw.example.com' }); + resetDbChain([bot]); + mockFetch.mockResolvedValue({ ok: true } as Response); + + await strategy.execute(baseContext); + + expect(mockFetch).toHaveBeenCalledWith(expect.anything(), { + method: 'POST', + }); + }); +}); From 28cc8e783452e675757d65dcb5e09fb0d1cd8b84 Mon Sep 17 00:00:00 2001 From: Winrey Date: Mon, 16 Mar 2026 15:36:01 +0800 Subject: [PATCH 27/68] docs: add bot debugger design spec Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-16-bot-debugger-design.md | 257 ++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-16-bot-debugger-design.md diff --git a/docs/superpowers/specs/2026-03-16-bot-debugger-design.md b/docs/superpowers/specs/2026-03-16-bot-debugger-design.md new file mode 100644 index 00000000..f1cf1a5f --- /dev/null +++ b/docs/superpowers/specs/2026-03-16-bot-debugger-design.md @@ -0,0 +1,257 @@ +# Bot Debugger — Design Spec + +## Overview + +A standalone web-based debugger tool that connects to the Team9 gateway as a bot/AI Staff identity, providing full bot simulation capabilities with real-time visibility into all WebSocket communication. Lives at `apps/debugger/` in the monorepo. + +## Goals + +- Enable developers to test the internal messaging system without depending on external bot services +- Simulate all bot behaviors: messaging, streaming, typing, reactions, channel operations +- Provide clear visibility into all sent and received WebSocket events with business-semantic rendering +- Support both manual token input and in-app bot creation + +## Non-Goals + +- REST API debugging (use Postman/Hoppscotch) +- Multi-bot simultaneous connections +- Message persistence to database (memory-only + export) +- Automated test script recording/playback + +## Target Users + +Developers and team members familiar with the internal protocol. UI optimized for information density over onboarding. + +## Technical Stack + +- **Framework:** Vite + React + TypeScript + Tailwind CSS (consistent with `apps/client/`) +- **WebSocket:** `socket.io-client` direct connection to gateway +- **State:** Zustand for connection state, event store, filters +- **Virtual scroll:** `@tanstack/react-virtual` for event stream +- **JSON editing/viewing:** Monaco Editor or `@uiw/react-json-view` +- **Run command:** `pnpm dev:debugger` (Vite dev server) + +## Architecture + +### Layout + +Classic three-column layout optimized for desktop: + +| Column | Width | Content | +| ------ | ----------- | ---------------------------------------------- | +| Left | 240px fixed | Connection config, channel list, bot info | +| Center | flex | Real-time event stream with semantic rendering | +| Right | 320px fixed | Quick Actions / JSON Editor / Inspector tabs | + +Top bar: connection status, bot identity, Clear/Export/Disconnect actions. +Bottom bar: event counts (total/received/sent), latency, transport info. + +### Core Modules + +#### 1. DebugSocket — WebSocket Management + +Wraps `socket.io-client` with full event interception: + +- Supports `t9bot_` token authentication (passed via `handshake.auth.token`) +- Intercepts all incoming events via `socket.onAny()` +- Wraps all outgoing events via custom `emit()` that records before sending +- Each event recorded as: + +```typescript +interface DebugEvent { + id: string; // unique ID (nanoid) + timestamp: number; // Date.now() + direction: "in" | "out"; + eventName: string; // e.g. 'new_message', 'streaming_content' + payload: unknown; // raw event payload + channelId?: string; // extracted from payload if present + meta?: { + streamId?: string; // for streaming events + userId?: string; // sender for incoming messages + size: number; // JSON.stringify(payload).length + }; +} +``` + +#### 2. EventStore — Event Storage (Zustand) + +```typescript +interface EventStore { + events: DebugEvent[]; + filters: { + direction: "all" | "in" | "out"; + eventTypes: string[]; // empty = all + channelId: string | null; + search: string; + }; + + // Computed + filteredEvents: DebugEvent[]; + + // Actions + addEvent(event: DebugEvent): void; + clearEvents(): void; + exportEvents(): void; // download as JSON file + setFilter(filter: Partial): void; +} +``` + +#### 3. SemanticRenderer — Business-Level Event Rendering + +Maps event names to preview components: + +| Event | Rendering | +| ---------------------------- | ------------------------------------------------------- | +| `new_message` | Message bubble: avatar + username + content + timestamp | +| `message_updated` | Updated message with diff indicator | +| `message_deleted` | Strikethrough message preview | +| `streaming_start` | Stream info: ID, target message, channel | +| `streaming_content` | Live text accumulation, merged by streamId | +| `streaming_thinking_content` | Thinking text in distinct style | +| `streaming_end` | Final stream summary with duration and chunk count | +| `streaming_abort` | Abort reason display | +| `user_typing` | "User is typing..." indicator | +| `reaction_added/removed` | Emoji + user + message reference | +| `read_status_updated` | Channel + last read message | +| `user_online/offline` | User presence change | +| `channel_joined/left` | Channel membership change | +| Other | Generic: event name + truncated JSON summary | + +Every event has a collapsible "Raw JSON" section showing the full payload. + +Streaming events with the same `streamId` are visually grouped and the content chunks are accumulated into a single live-updating preview. + +#### 4. ActionPanel — Operation Panel + +Three tabs: + +**Tab 1: Quick Actions** + +Pre-built forms for common bot operations: + +- **Send Message:** channel selector + content textarea + thread toggle (parentId) +- **Streaming:** content textarea + Start/End/Abort controls + auto-chunk toggle (interval configurable) + thinking content toggle +- **Quick buttons grid:** Typing Start, Typing Stop, Mark Read, Add Reaction, Join Channel, Leave Channel + +**Tab 2: JSON Editor** + +- Event name input field +- Monaco/CodeMirror JSON editor for payload +- Send button +- Preset templates dropdown (common event payloads) + +**Tab 3: Inspector** + +- Click any event in the center stream → full JSON displayed here +- Timestamps, size, related events (e.g., streaming_start → content → end chain) +- Copy payload button + +#### 5. BotManager — Bot Creation & Management + +Two connection modes: + +**Manual:** Paste existing `t9bot_` token + server URL → Connect + +**Create New:** + +1. User provides admin JWT (or logs in with email/password to get one) +2. Calls REST API to create a bot in a workspace +3. Receives `t9bot_` token +4. Auto-connects with the new token + +**Persistence:** + +- localStorage stores last 5 connection profiles: `{ alias, serverUrl, token, lastUsed }` +- Quick-switch between profiles +- Import/export config as JSON + +### Streaming Simulation + +Two modes for simulating bot streaming responses: + +**Manual Mode:** + +1. User writes full response text in textarea +2. Clicks "Start Stream" +3. Debugger sends `streaming_start` +4. Auto-splits text into chunks (configurable size) +5. Sends `streaming_content` at configurable interval (default 500ms) +6. Sends `streaming_end` when complete +7. Optional: sends `streaming_thinking_content` before main content + +**Interactive Mode:** + +1. User clicks "Start" → sends `streaming_start` +2. User types in textarea → each keystroke/pause sends `streaming_content` chunk +3. User clicks "End" → sends `streaming_end` +4. User clicks "Abort" → sends `streaming_abort` with reason + +### Data Flow + +``` +User Action (Quick Form / JSON Editor) + → DebugSocket.emit(eventName, payload) + → EventStore.add({ direction: 'out', ... }) + → socket.io sends to gateway + +Gateway Event + → socket.io receives + → DebugSocket.onAny() interceptor + → EventStore.add({ direction: 'in', ... }) + → SemanticRenderer picks component + → Event Stream UI updates (virtual scroll) +``` + +## File Structure + +``` +apps/debugger/ +├── index.html +├── vite.config.ts +├── tailwind.config.ts +├── tsconfig.json +├── package.json +├── src/ +│ ├── main.tsx +│ ├── App.tsx +│ ├── stores/ +│ │ ├── connection.ts # Connection state, profiles +│ │ └── events.ts # EventStore +│ ├── services/ +│ │ └── debug-socket.ts # DebugSocket wrapper +│ ├── components/ +│ │ ├── Layout.tsx # Three-column layout shell +│ │ ├── TopBar.tsx +│ │ ├── BottomBar.tsx +│ │ ├── left/ +│ │ │ ├── ConnectionPanel.tsx +│ │ │ ├── ChannelList.tsx +│ │ │ └── BotInfo.tsx +│ │ ├── center/ +│ │ │ ├── EventStream.tsx # Virtual scrolling list +│ │ │ ├── EventCard.tsx # Single event container +│ │ │ ├── EventFilter.tsx # Filter bar +│ │ │ └── renderers/ +│ │ │ ├── MessageRenderer.tsx +│ │ │ ├── StreamingRenderer.tsx +│ │ │ ├── TypingRenderer.tsx +│ │ │ ├── PresenceRenderer.tsx +│ │ │ ├── ReactionRenderer.tsx +│ │ │ └── GenericRenderer.tsx +│ │ └── right/ +│ │ ├── QuickActions.tsx +│ │ ├── JsonEditor.tsx +│ │ └── Inspector.tsx +│ ├── lib/ +│ │ ├── event-types.ts # Event name constants, type guards +│ │ └── utils.ts # Formatting, ID generation +│ └── styles/ +│ └── globals.css +``` + +## Integration with Monorepo + +- Add `"debugger": "workspace:*"` to root `pnpm-workspace.yaml` if needed +- Add `dev:debugger` script to root `package.json`: `"pnpm --filter debugger dev"` +- Debugger connects to existing gateway — no server-side changes needed +- Shares no code with `apps/client/` (standalone, no shared libs) From 7aa3154c97448bc3cc7bdca380fd1ef32b840f85 Mon Sep 17 00:00:00 2001 From: Winrey Date: Mon, 16 Mar 2026 15:46:44 +0800 Subject: [PATCH 28/68] docs: address spec review feedback for bot debugger - Add Prerequisites section (namespace /im, CORS, bot token) - Clarify messages sent via REST API, not WebSocket - Document authenticated/auth_error connection lifecycle - Note bot controller endpoints need uncommenting - Add streaming channelId/streamId requirements - Import types from @team9/shared instead of duplicating - Fix pnpm workspace integration notes Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-16-bot-debugger-design.md | 71 +++++++++++++------ 1 file changed, 51 insertions(+), 20 deletions(-) diff --git a/docs/superpowers/specs/2026-03-16-bot-debugger-design.md b/docs/superpowers/specs/2026-03-16-bot-debugger-design.md index f1cf1a5f..71ebe299 100644 --- a/docs/superpowers/specs/2026-03-16-bot-debugger-design.md +++ b/docs/superpowers/specs/2026-03-16-bot-debugger-design.md @@ -13,11 +13,18 @@ A standalone web-based debugger tool that connects to the Team9 gateway as a bot ## Non-Goals -- REST API debugging (use Postman/Hoppscotch) +- General REST API debugging (use Postman/Hoppscotch) — the debugger only calls REST endpoints required for bot operations (sending messages, bot creation) - Multi-bot simultaneous connections - Message persistence to database (memory-only + export) - Automated test script recording/playback +## Prerequisites / Setup + +- **WebSocket namespace:** The gateway WebSocket runs on the `/im` namespace. The debugger must connect to `http://:/im`. +- **CORS:** The gateway's `CORS_ORIGIN` env var must include the debugger's dev origin (e.g., `http://localhost:5174`). Alternatively, the debugger's Vite config can proxy WebSocket connections through the dev server. +- **Bot token:** For initial use, a pre-existing `t9bot_` token is needed. The in-app bot creation feature requires uncommenting the bot controller REST endpoints (see Bot Creation section). +- **Gateway running:** The gateway service must be running (`pnpm dev:server`). + ## Target Users Developers and team members familiar with the internal protocol. UI optimized for information density over onboarding. @@ -25,7 +32,8 @@ Developers and team members familiar with the internal protocol. UI optimized fo ## Technical Stack - **Framework:** Vite + React + TypeScript + Tailwind CSS (consistent with `apps/client/`) -- **WebSocket:** `socket.io-client` direct connection to gateway +- **WebSocket:** `socket.io-client` connecting to gateway `/im` namespace +- **HTTP:** `fetch` for REST API calls (sending messages, bot creation) - **State:** Zustand for connection state, event store, filters - **Virtual scroll:** `@tanstack/react-virtual` for event stream - **JSON editing/viewing:** Monaco Editor or `@uiw/react-json-view` @@ -52,9 +60,14 @@ Bottom bar: event counts (total/received/sent), latency, transport info. Wraps `socket.io-client` with full event interception: +- Connects to the `/im` namespace (e.g., `http://localhost:3000/im`) - Supports `t9bot_` token authentication (passed via `handshake.auth.token`) +- Waits for `authenticated` event from server before marking connection as "ready" +- Handles `auth_error` event to surface connection failures - Intercepts all incoming events via `socket.onAny()` - Wraps all outgoing events via custom `emit()` that records before sending +- Measures latency via `ping`/`pong` events (gateway returns `serverTime`) +- No auto-reconnect — manual reconnect only (intentional for debugging) - Each event recorded as: ```typescript @@ -115,6 +128,9 @@ Maps event names to preview components: | `read_status_updated` | Channel + last read message | | `user_online/offline` | User presence change | | `channel_joined/left` | Channel membership change | +| `authenticated` | Connection success with userId | +| `auth_error` | Authentication failure reason | +| `task:status_changed` | Task ID + old/new status | | Other | Generic: event name + truncated JSON summary | Every event has a collapsible "Raw JSON" section showing the full payload. @@ -129,7 +145,7 @@ Three tabs: Pre-built forms for common bot operations: -- **Send Message:** channel selector + content textarea + thread toggle (parentId) +- **Send Message:** channel selector + content textarea + thread toggle (parentId). Sends via REST API (`POST /v1/im/channels/:channelId/messages`) using the bot token as Bearer auth — messages are persisted and broadcast through the normal flow. - **Streaming:** content textarea + Start/End/Abort controls + auto-chunk toggle (interval configurable) + thinking content toggle - **Quick buttons grid:** Typing Start, Typing Stop, Mark Read, Add Reaction, Join Channel, Leave Channel @@ -154,6 +170,8 @@ Two connection modes: **Create New:** +> **Note:** The bot creation REST endpoints in `bot.controller.ts` are currently commented out. As a prerequisite, these endpoints need to be restored: `POST /v1/bots` (create bot), `POST /v1/bots/:id/regenerate-token` (generate token), `DELETE /v1/bots/:id/revoke-token` (revoke token). + 1. User provides admin JWT (or logs in with email/password to get one) 2. Calls REST API to create a bot in a workspace 3. Receives `t9bot_` token @@ -169,11 +187,13 @@ Two connection modes: Two modes for simulating bot streaming responses: +Both modes require selecting a target channel (bot must be a member — gateway validates membership in `handleStreamingStart`). The debugger generates a `streamId` (nanoid) and a placeholder `messageId` for `streaming_start`. + **Manual Mode:** 1. User writes full response text in textarea 2. Clicks "Start Stream" -3. Debugger sends `streaming_start` +3. Debugger sends `streaming_start` with `{ channelId, streamId, messageId }` 4. Auto-splits text into chunks (configurable size) 5. Sends `streaming_content` at configurable interval (default 500ms) 6. Sends `streaming_end` when complete @@ -181,7 +201,7 @@ Two modes for simulating bot streaming responses: **Interactive Mode:** -1. User clicks "Start" → sends `streaming_start` +1. User clicks "Start" → sends `streaming_start` with `{ channelId, streamId, messageId }` 2. User types in textarea → each keystroke/pause sends `streaming_content` chunk 3. User clicks "End" → sends `streaming_end` 4. User clicks "Abort" → sends `streaming_abort` with reason @@ -189,17 +209,26 @@ Two modes for simulating bot streaming responses: ### Data Flow ``` -User Action (Quick Form / JSON Editor) - → DebugSocket.emit(eventName, payload) - → EventStore.add({ direction: 'out', ... }) - → socket.io sends to gateway - -Gateway Event - → socket.io receives - → DebugSocket.onAny() interceptor - → EventStore.add({ direction: 'in', ... }) - → SemanticRenderer picks component - → Event Stream UI updates (virtual scroll) +WebSocket Events (streaming, typing, reactions, etc.): + User Action (Quick Form / JSON Editor) + → DebugSocket.emit(eventName, payload) + → EventStore.add({ direction: 'out', ... }) + → socket.io sends to gateway + +REST API Calls (sending messages, bot creation): + User Action (Send Message form) + → api.sendMessage(channelId, content) + → EventStore.add({ direction: 'out', eventName: 'REST:POST /messages', ... }) + → fetch POST to gateway REST endpoint + → Response logged to EventStore + +Incoming Events: + Gateway broadcasts event + → socket.io receives + → DebugSocket.onAny() interceptor + → EventStore.add({ direction: 'in', ... }) + → SemanticRenderer picks component + → Event Stream UI updates (virtual scroll) ``` ## File Structure @@ -218,7 +247,8 @@ apps/debugger/ │ │ ├── connection.ts # Connection state, profiles │ │ └── events.ts # EventStore │ ├── services/ -│ │ └── debug-socket.ts # DebugSocket wrapper +│ │ ├── debug-socket.ts # DebugSocket wrapper +│ │ └── api.ts # REST API client (messages, bot creation) │ ├── components/ │ │ ├── Layout.tsx # Three-column layout shell │ │ ├── TopBar.tsx @@ -251,7 +281,8 @@ apps/debugger/ ## Integration with Monorepo -- Add `"debugger": "workspace:*"` to root `pnpm-workspace.yaml` if needed +- `apps/debugger/` is auto-discovered by pnpm workspace (root `pnpm-workspace.yaml` already includes `apps/*`) - Add `dev:debugger` script to root `package.json`: `"pnpm --filter debugger dev"` -- Debugger connects to existing gateway — no server-side changes needed -- Shares no code with `apps/client/` (standalone, no shared libs) +- Import event types from `@team9/shared` (`apps/server/libs/shared`) to stay in sync with actual event schemas +- **Server-side prerequisite:** Uncomment bot creation/token endpoints in `bot.controller.ts` for full bot management support +- **CORS prerequisite:** Add debugger dev origin to `CORS_ORIGIN` env var, or configure Vite proxy From 06a362bad20d3c5481c717a260e55606e7d28f07 Mon Sep 17 00:00:00 2001 From: Winrey Date: Mon, 16 Mar 2026 16:22:39 +0800 Subject: [PATCH 29/68] docs: add bot debugger implementation plan 17 tasks covering project scaffolding, core services (DebugSocket, EventStore, API client), three-column layout, semantic event renderers, action panel with streaming simulation, and integration. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-16-bot-debugger.md | 2733 +++++++++++++++++ 1 file changed, 2733 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-16-bot-debugger.md diff --git a/docs/superpowers/plans/2026-03-16-bot-debugger.md b/docs/superpowers/plans/2026-03-16-bot-debugger.md new file mode 100644 index 00000000..e4ddcaea --- /dev/null +++ b/docs/superpowers/plans/2026-03-16-bot-debugger.md @@ -0,0 +1,2733 @@ +# Bot Debugger Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a standalone web debugger that connects as a bot identity to test the Team9 IM system, with full visibility into WebSocket events and bot simulation capabilities. + +**Architecture:** Three-column Vite+React app at `apps/debugger/`. Left panel for connection/channels, center for real-time event stream with semantic rendering, right for action forms and JSON editor. Connects to gateway's `/im` WebSocket namespace using `t9bot_` tokens, sends messages via REST API. + +**Tech Stack:** Vite 7, React 19, TypeScript, Tailwind CSS 4, Zustand 5, socket.io-client, @tanstack/react-virtual, nanoid + +**Spec:** `docs/superpowers/specs/2026-03-16-bot-debugger-design.md` + +--- + +## Chunk 1: Project Scaffolding + +### Task 1: Initialize Vite project + +**Files:** + +- Create: `apps/debugger/package.json` +- Create: `apps/debugger/index.html` +- Create: `apps/debugger/vite.config.ts` +- Create: `apps/debugger/tsconfig.json` +- Create: `apps/debugger/src/main.tsx` +- Create: `apps/debugger/src/App.tsx` +- Create: `apps/debugger/src/styles/globals.css` +- Modify: `package.json` (root — add `dev:debugger` script) + +- [ ] **Step 1: Create `apps/debugger/package.json`** + +```json +{ + "name": "@team9/debugger", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0", + "socket.io-client": "^4.8.3", + "zustand": "^5.0.0", + "@tanstack/react-virtual": "^3.13.0", + "nanoid": "^5.1.0" + }, + "devDependencies": { + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.4.0", + "typescript": "^5.8.0", + "vite": "^7.0.0", + "@tailwindcss/vite": "^4.1.0", + "tailwindcss": "^4.1.0" + } +} +``` + +- [ ] **Step 2: Create `apps/debugger/vite.config.ts`** + +```typescript +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import path from "path"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, + server: { + port: 5174, + }, +}); +``` + +- [ ] **Step 3: Create `apps/debugger/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} +``` + +- [ ] **Step 4: Create `apps/debugger/index.html`** + +```html + + + + + + Team9 Bot Debugger + + +
+ + + +``` + +- [ ] **Step 5: Create `apps/debugger/src/styles/globals.css`** + +```css +@import "tailwindcss"; + +@theme { + --color-slate-925: oklch(0.14 0.01 260); +} +``` + +- [ ] **Step 6: Create `apps/debugger/src/main.tsx`** + +```tsx +import React from "react"; +import ReactDOM from "react-dom/client"; +import { App } from "./App"; +import "./styles/globals.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); +``` + +- [ ] **Step 7: Create `apps/debugger/src/App.tsx`** (placeholder) + +```tsx +export function App() { + return ( +
+
+ Bot Debugger — loading... +
+
+ ); +} +``` + +- [ ] **Step 8: Add `dev:debugger` script to root `package.json`** + +Add to the `"scripts"` section: + +```json +"dev:debugger": "pnpm -C apps/debugger dev" +``` + +- [ ] **Step 9: Install dependencies and verify** + +```bash +cd apps/debugger && pnpm install +cd ../.. && pnpm dev:debugger +``` + +Open `http://localhost:5174` — should show "Bot Debugger — loading..." + +- [ ] **Step 10: Commit** + +```bash +git add apps/debugger/ package.json pnpm-lock.yaml +git commit -m "feat(debugger): scaffold Vite + React project" +``` + +--- + +### Task 2: Define types and event constants + +**Files:** + +- Create: `apps/debugger/src/lib/events.ts` +- Create: `apps/debugger/src/lib/types.ts` + +- [ ] **Step 1: Create `apps/debugger/src/lib/events.ts`** + +Mirror the event names from `apps/server/libs/shared/src/events/event-names.ts`: + +```typescript +/** + * WebSocket event names — mirrored from @team9/shared. + * Keep in sync with apps/server/libs/shared/src/events/event-names.ts + */ +export const WS_EVENTS = { + AUTH: { + AUTHENTICATED: "authenticated", + AUTH_ERROR: "auth_error", + }, + CHANNEL: { + JOIN: "join_channel", + LEAVE: "leave_channel", + JOINED: "channel_joined", + LEFT: "channel_left", + CREATED: "channel_created", + UPDATED: "channel_updated", + DELETED: "channel_deleted", + }, + MESSAGE: { + NEW: "new_message", + UPDATED: "message_updated", + DELETED: "message_deleted", + }, + READ_STATUS: { + MARK_AS_READ: "mark_as_read", + UPDATED: "read_status_updated", + }, + TYPING: { + START: "typing_start", + STOP: "typing_stop", + USER_TYPING: "user_typing", + }, + USER: { + ONLINE: "user_online", + OFFLINE: "user_offline", + STATUS_CHANGED: "user_status_changed", + }, + REACTION: { + ADD: "add_reaction", + REMOVE: "remove_reaction", + ADDED: "reaction_added", + REMOVED: "reaction_removed", + }, + WORKSPACE: { + MEMBER_JOINED: "workspace_member_joined", + MEMBER_LEFT: "workspace_member_left", + }, + SYSTEM: { + PING: "ping", + PONG: "pong", + }, + STREAMING: { + START: "streaming_start", + CONTENT: "streaming_content", + THINKING_CONTENT: "streaming_thinking_content", + END: "streaming_end", + ABORT: "streaming_abort", + }, + TASK: { + STATUS_CHANGED: "task:status_changed", + EXECUTION_CREATED: "task:execution_created", + }, +} as const; + +/** Categorize events for filtering and coloring */ +export type EventCategory = + | "auth" + | "channel" + | "message" + | "streaming" + | "typing" + | "presence" + | "reaction" + | "system" + | "task" + | "other"; + +export function getEventCategory(eventName: string): EventCategory { + if (eventName.startsWith("streaming_")) return "streaming"; + if ( + eventName === "new_message" || + eventName === "message_updated" || + eventName === "message_deleted" + ) + return "message"; + if ( + eventName === "typing_start" || + eventName === "typing_stop" || + eventName === "user_typing" + ) + return "typing"; + if ( + eventName === "user_online" || + eventName === "user_offline" || + eventName === "user_status_changed" + ) + return "presence"; + if ( + eventName.startsWith("reaction_") || + eventName.startsWith("add_reaction") || + eventName.startsWith("remove_reaction") + ) + return "reaction"; + if (eventName === "authenticated" || eventName === "auth_error") + return "auth"; + if ( + eventName.startsWith("channel_") || + eventName === "join_channel" || + eventName === "leave_channel" + ) + return "channel"; + if (eventName === "ping" || eventName === "pong") return "system"; + if (eventName.startsWith("task:")) return "task"; + return "other"; +} + +/** Color mapping per category */ +export const CATEGORY_COLORS: Record = { + auth: "#22c55e", + channel: "#06b6d4", + message: "#38bdf8", + streaming: "#f59e0b", + typing: "#8b5cf6", + presence: "#a78bfa", + reaction: "#ec4899", + system: "#64748b", + task: "#14b8a6", + other: "#94a3b8", +}; +``` + +- [ ] **Step 2: Create `apps/debugger/src/lib/types.ts`** + +```typescript +export interface DebugEvent { + id: string; + timestamp: number; + direction: "in" | "out"; + eventName: string; + payload: unknown; + channelId?: string; + meta?: { + streamId?: string; + userId?: string; + size: number; + }; +} + +export interface ConnectionProfile { + id: string; + alias: string; + serverUrl: string; + token: string; + lastUsed: number; +} + +export interface StreamingSession { + streamId: string; + channelId: string; + startedAt: number; + chunks: string[]; + status: "active" | "ended" | "aborted"; +} + +/** Matches the server's StreamingStartEvent shape */ +export interface StreamingStartPayload { + streamId: string; + channelId: string; + parentId?: string; +} + +export interface StreamingContentPayload { + streamId: string; + channelId: string; + content: string; +} + +export interface StreamingEndPayload { + streamId: string; + channelId: string; +} + +export interface StreamingAbortPayload { + streamId: string; + channelId: string; + reason: "error" | "cancelled" | "timeout" | "disconnect"; + error?: string; +} + +export interface ChannelInfo { + id: string; + name: string; + type: "direct" | "public" | "private"; + memberCount?: number; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/debugger/src/lib/ +git commit -m "feat(debugger): add event constants and type definitions" +``` + +--- + +## Chunk 2: Core Services + +### Task 3: Implement EventStore (Zustand) + +**Files:** + +- Create: `apps/debugger/src/stores/events.ts` + +- [ ] **Step 1: Create `apps/debugger/src/stores/events.ts`** + +```typescript +import { create } from "zustand"; +import type { DebugEvent } from "@/lib/types"; +import { getEventCategory, type EventCategory } from "@/lib/events"; + +interface EventFilters { + direction: "all" | "in" | "out"; + categories: EventCategory[]; + channelId: string | null; + search: string; +} + +interface EventStore { + events: DebugEvent[]; + filters: EventFilters; + selectedEventId: string | null; + + addEvent: (event: DebugEvent) => void; + clearEvents: () => void; + setFilter: (filter: Partial) => void; + setSelectedEvent: (id: string | null) => void; + getFilteredEvents: () => DebugEvent[]; + exportEvents: () => void; +} + +export const useEventStore = create((set, get) => ({ + events: [], + filters: { + direction: "all", + categories: [], + channelId: null, + search: "", + }, + selectedEventId: null, + + addEvent: (event) => set((state) => ({ events: [...state.events, event] })), + + clearEvents: () => set({ events: [], selectedEventId: null }), + + setFilter: (filter) => + set((state) => ({ filters: { ...state.filters, ...filter } })), + + setSelectedEvent: (id) => set({ selectedEventId: id }), + + getFilteredEvents: () => { + const { events, filters } = get(); + return events.filter((e) => { + if (filters.direction !== "all" && e.direction !== filters.direction) + return false; + if ( + filters.categories.length > 0 && + !filters.categories.includes(getEventCategory(e.eventName)) + ) + return false; + if (filters.channelId && e.channelId !== filters.channelId) return false; + if (filters.search) { + const searchLower = filters.search.toLowerCase(); + const matchesName = e.eventName.toLowerCase().includes(searchLower); + const matchesPayload = JSON.stringify(e.payload) + .toLowerCase() + .includes(searchLower); + if (!matchesName && !matchesPayload) return false; + } + return true; + }); + }, + + exportEvents: () => { + const { events } = get(); + const blob = new Blob([JSON.stringify(events, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `debugger-events-${new Date().toISOString().slice(0, 19)}.json`; + a.click(); + URL.revokeObjectURL(url); + }, +})); +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/debugger/src/stores/events.ts +git commit -m "feat(debugger): add EventStore with filtering and export" +``` + +--- + +### Task 4: Implement ConnectionStore (Zustand) + +**Files:** + +- Create: `apps/debugger/src/stores/connection.ts` + +- [ ] **Step 1: Create `apps/debugger/src/stores/connection.ts`** + +```typescript +import { create } from "zustand"; +import type { ChannelInfo, ConnectionProfile } from "@/lib/types"; + +type ConnectionStatus = + | "disconnected" + | "connecting" + | "authenticating" + | "connected" + | "error"; + +interface ConnectionStore { + status: ConnectionStatus; + errorMessage: string | null; + serverUrl: string; + token: string; + botUserId: string | null; + botUsername: string | null; + channels: ChannelInfo[]; + latencyMs: number | null; + profiles: ConnectionProfile[]; + activeProfileId: string | null; + + setStatus: (status: ConnectionStatus, error?: string) => void; + setServerUrl: (url: string) => void; + setToken: (token: string) => void; + setBotIdentity: (userId: string, username: string) => void; + setChannels: (channels: ChannelInfo[]) => void; + setLatency: (ms: number) => void; + reset: () => void; + + // Profile management + loadProfiles: () => void; + saveProfile: (profile: Omit) => void; + deleteProfile: (id: string) => void; + applyProfile: (id: string) => void; +} + +const PROFILES_KEY = "debugger_profiles"; +const MAX_PROFILES = 5; + +export const useConnectionStore = create((set, get) => ({ + status: "disconnected", + errorMessage: null, + serverUrl: "http://localhost:3000", + token: "", + botUserId: null, + botUsername: null, + channels: [], + latencyMs: null, + profiles: [], + activeProfileId: null, + + setStatus: (status, error) => set({ status, errorMessage: error ?? null }), + + setServerUrl: (serverUrl) => set({ serverUrl }), + setToken: (token) => set({ token }), + + setBotIdentity: (userId, username) => + set({ botUserId: userId, botUsername: username }), + + setChannels: (channels) => set({ channels }), + setLatency: (ms) => set({ latencyMs: ms }), + + reset: () => + set({ + status: "disconnected", + errorMessage: null, + botUserId: null, + botUsername: null, + channels: [], + latencyMs: null, + }), + + loadProfiles: () => { + try { + const raw = localStorage.getItem(PROFILES_KEY); + if (raw) set({ profiles: JSON.parse(raw) }); + } catch { + // ignore corrupted localStorage + } + }, + + saveProfile: (profile) => { + const { profiles } = get(); + const id = crypto.randomUUID(); + const newProfile: ConnectionProfile = { + ...profile, + id, + lastUsed: Date.now(), + }; + const updated = [newProfile, ...profiles].slice(0, MAX_PROFILES); + localStorage.setItem(PROFILES_KEY, JSON.stringify(updated)); + set({ profiles: updated, activeProfileId: id }); + }, + + deleteProfile: (id) => { + const { profiles } = get(); + const updated = profiles.filter((p) => p.id !== id); + localStorage.setItem(PROFILES_KEY, JSON.stringify(updated)); + set({ profiles: updated }); + }, + + applyProfile: (id) => { + const { profiles } = get(); + const profile = profiles.find((p) => p.id === id); + if (profile) { + set({ + serverUrl: profile.serverUrl, + token: profile.token, + activeProfileId: id, + }); + // Update lastUsed + const updated = profiles.map((p) => + p.id === id ? { ...p, lastUsed: Date.now() } : p, + ); + localStorage.setItem(PROFILES_KEY, JSON.stringify(updated)); + set({ profiles: updated }); + } + }, +})); +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/debugger/src/stores/connection.ts +git commit -m "feat(debugger): add ConnectionStore with profile management" +``` + +--- + +### Task 5: Implement DebugSocket service + +**Files:** + +- Create: `apps/debugger/src/services/debug-socket.ts` +- Create: `apps/debugger/src/lib/utils.ts` + +- [ ] **Step 1: Create `apps/debugger/src/lib/utils.ts`** + +```typescript +import { nanoid } from "nanoid"; + +export function generateId(): string { + return nanoid(12); +} + +export function formatTimestamp(ts: number): string { + const d = new Date(ts); + return d.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + fractionalSecondDigits: 3, + }); +} + +export function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +export function extractChannelId(payload: unknown): string | undefined { + if ( + typeof payload === "object" && + payload !== null && + "channelId" in payload + ) { + return (payload as Record).channelId as string; + } + return undefined; +} + +export function extractStreamId(payload: unknown): string | undefined { + if ( + typeof payload === "object" && + payload !== null && + "streamId" in payload + ) { + return (payload as Record).streamId as string; + } + return undefined; +} + +export function extractUserId(payload: unknown): string | undefined { + if (typeof payload === "object" && payload !== null) { + const p = payload as Record; + return (p.senderId ?? p.userId) as string | undefined; + } + return undefined; +} +``` + +- [ ] **Step 2: Create `apps/debugger/src/services/debug-socket.ts`** + +```typescript +import { io, type Socket } from "socket.io-client"; +import { useEventStore } from "@/stores/events"; +import { useConnectionStore } from "@/stores/connection"; +import { WS_EVENTS } from "@/lib/events"; +import type { DebugEvent } from "@/lib/types"; +import { + generateId, + extractChannelId, + extractStreamId, + extractUserId, +} from "@/lib/utils"; + +let socket: Socket | null = null; + +function recordEvent( + direction: "in" | "out", + eventName: string, + payload: unknown, +): DebugEvent { + const event: DebugEvent = { + id: generateId(), + timestamp: Date.now(), + direction, + eventName, + payload, + channelId: extractChannelId(payload), + meta: { + streamId: extractStreamId(payload), + userId: extractUserId(payload), + size: JSON.stringify(payload ?? "").length, + }, + }; + useEventStore.getState().addEvent(event); + return event; +} + +export function connect(serverUrl: string, token: string): void { + if (socket?.connected) { + socket.disconnect(); + } + + const connStore = useConnectionStore.getState(); + connStore.setStatus("connecting"); + + // Connect to /im namespace + const url = serverUrl.replace(/\/$/, "") + "/im"; + socket = io(url, { + auth: { token }, + transports: ["websocket"], + reconnection: false, // manual reconnect only for debugging + }); + + socket.on("connect", () => { + connStore.setStatus("authenticating"); + recordEvent("in", "connect", { socketId: socket?.id }); + }); + + socket.on(WS_EVENTS.AUTH.AUTHENTICATED, (data: unknown) => { + recordEvent("in", WS_EVENTS.AUTH.AUTHENTICATED, data); + const payload = data as { userId?: string; username?: string }; + if (payload.userId) { + connStore.setBotIdentity(payload.userId, payload.username ?? "unknown"); + } + connStore.setStatus("connected"); + }); + + socket.on(WS_EVENTS.AUTH.AUTH_ERROR, (data: unknown) => { + recordEvent("in", WS_EVENTS.AUTH.AUTH_ERROR, data); + const msg = + typeof data === "object" && data !== null && "message" in data + ? String((data as Record).message) + : "Authentication failed"; + connStore.setStatus("error", msg); + }); + + socket.on("connect_error", (err: Error) => { + recordEvent("in", "connect_error", { message: err.message }); + connStore.setStatus("error", err.message); + }); + + socket.on("disconnect", (reason: string) => { + recordEvent("in", "disconnect", { reason }); + connStore.setStatus("disconnected"); + }); + + // Intercept ALL incoming events + socket.onAny((eventName: string, ...args: unknown[]) => { + // Skip events already handled above + if ( + eventName === WS_EVENTS.AUTH.AUTHENTICATED || + eventName === WS_EVENTS.AUTH.AUTH_ERROR + ) { + return; + } + recordEvent("in", eventName, args.length === 1 ? args[0] : args); + }); + + // Latency measurement via ping with ack callback + // The gateway's handlePing returns pong as a socket.io acknowledgement, not a separate event. + setInterval(() => { + if (socket?.connected) { + const start = Date.now(); + socket.emit(WS_EVENTS.SYSTEM.PING, { timestamp: start }, () => { + connStore.setLatency(Date.now() - start); + }); + } + }, 30000); +} + +export function disconnect(): void { + if (socket) { + socket.disconnect(); + socket = null; + useConnectionStore.getState().reset(); + } +} + +export function emit( + eventName: string, + payload: unknown, + ack?: (...args: unknown[]) => void, +): void { + if (!socket?.connected) { + console.warn("Socket not connected"); + return; + } + recordEvent("out", eventName, payload); + if (ack) { + socket.emit(eventName, payload, ack); + } else { + socket.emit(eventName, payload); + } +} + +export function getSocket(): Socket | null { + return socket; +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add apps/debugger/src/lib/utils.ts apps/debugger/src/services/debug-socket.ts +git commit -m "feat(debugger): add DebugSocket service with event interception" +``` + +--- + +### Task 6: Implement REST API client + +**Files:** + +- Create: `apps/debugger/src/services/api.ts` + +- [ ] **Step 1: Create `apps/debugger/src/services/api.ts`** + +```typescript +import { useConnectionStore } from "@/stores/connection"; +import { useEventStore } from "@/stores/events"; +import { generateId } from "@/lib/utils"; +import type { DebugEvent } from "@/lib/types"; + +function getBaseUrl(): string { + return useConnectionStore.getState().serverUrl.replace(/\/$/, "") + "/api"; +} + +function getAuthHeaders(): HeadersInit { + const token = useConnectionStore.getState().token; + return { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }; +} + +function recordRestEvent( + method: string, + path: string, + body: unknown, + response: unknown, + status: number, +): void { + const event: DebugEvent = { + id: generateId(), + timestamp: Date.now(), + direction: "out", + eventName: `REST:${method} ${path}`, + payload: { request: body, response, status }, + meta: { + size: JSON.stringify(body ?? "").length, + }, + }; + useEventStore.getState().addEvent(event); +} + +export async function sendMessage( + channelId: string, + content: string, + parentId?: string, +): Promise { + const path = `/v1/im/channels/${channelId}/messages`; + const body = { content, parentId }; + + const res = await fetch(`${getBaseUrl()}${path}`, { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify(body), + }); + + const data = await res.json(); + recordRestEvent("POST", path, body, data, res.status); + return data; +} + +export async function getChannels(): Promise { + const path = "/v1/im/channels"; + const res = await fetch(`${getBaseUrl()}${path}`, { + headers: getAuthHeaders(), + }); + const data = await res.json(); + return data; +} + +export async function getUser(userId: string): Promise { + const path = `/v1/im/users/${userId}`; + const res = await fetch(`${getBaseUrl()}${path}`, { + headers: getAuthHeaders(), + }); + const data = await res.json(); + return data; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/debugger/src/services/api.ts +git commit -m "feat(debugger): add REST API client for messages and bot info" +``` + +--- + +## Chunk 3: Layout Shell & Left Panel + +### Task 7: Create three-column layout shell + +**Files:** + +- Create: `apps/debugger/src/components/Layout.tsx` +- Create: `apps/debugger/src/components/TopBar.tsx` +- Create: `apps/debugger/src/components/BottomBar.tsx` +- Modify: `apps/debugger/src/App.tsx` + +- [ ] **Step 1: Create `apps/debugger/src/components/TopBar.tsx`** + +```tsx +import { useConnectionStore } from "@/stores/connection"; +import { useEventStore } from "@/stores/events"; +import { disconnect } from "@/services/debug-socket"; + +const STATUS_STYLES: Record = { + connected: "bg-emerald-900/50 text-emerald-400", + connecting: "bg-yellow-900/50 text-yellow-400", + authenticating: "bg-yellow-900/50 text-yellow-400", + disconnected: "bg-slate-700/50 text-slate-400", + error: "bg-red-900/50 text-red-400", +}; + +const STATUS_DOT: Record = { + connected: "text-emerald-400", + connecting: "text-yellow-400 animate-pulse", + authenticating: "text-yellow-400 animate-pulse", + disconnected: "text-slate-500", + error: "text-red-400", +}; + +export function TopBar() { + const { status, botUsername, serverUrl, errorMessage } = useConnectionStore(); + const { clearEvents, exportEvents } = useEventStore(); + + return ( +
+
+ Bot Debugger + + {status} + + {botUsername && ( + + bot: {botUsername} | {serverUrl} + + )} + {errorMessage && ( + {errorMessage} + )} +
+
+ + + {status === "connected" && ( + + )} +
+
+ ); +} +``` + +- [ ] **Step 2: Create `apps/debugger/src/components/BottomBar.tsx`** + +```tsx +import { useConnectionStore } from "@/stores/connection"; +import { useEventStore } from "@/stores/events"; + +export function BottomBar() { + const { latencyMs } = useConnectionStore(); + const events = useEventStore((s) => s.events); + + const total = events.length; + const received = events.filter((e) => e.direction === "in").length; + const sent = events.filter((e) => e.direction === "out").length; + + return ( +
+
+ + Events: {total} + + + Received: {received} + + + Sent: {sent} + + {latencyMs !== null && ( + + Latency:{" "} + + {latencyMs}ms + + + )} +
+ Socket.io | Transport: websocket +
+ ); +} +``` + +- [ ] **Step 3: Create `apps/debugger/src/components/Layout.tsx`** + +```tsx +import { TopBar } from "./TopBar"; +import { BottomBar } from "./BottomBar"; +import type { ReactNode } from "react"; + +interface LayoutProps { + left: ReactNode; + center: ReactNode; + right: ReactNode; +} + +export function Layout({ left, center, right }: LayoutProps) { + return ( +
+ +
+
+ {left} +
+
{center}
+
+ {right} +
+
+ +
+ ); +} +``` + +- [ ] **Step 4: Update `apps/debugger/src/App.tsx`** + +```tsx +import { useEffect } from "react"; +import { Layout } from "@/components/Layout"; +import { useConnectionStore } from "@/stores/connection"; + +function LeftPlaceholder() { + return
Left panel
; +} +function CenterPlaceholder() { + return ( +
+ Connect to a server to see events +
+ ); +} +function RightPlaceholder() { + return
Right panel
; +} + +export function App() { + const loadProfiles = useConnectionStore((s) => s.loadProfiles); + + useEffect(() => { + loadProfiles(); + }, [loadProfiles]); + + return ( + } + center={} + right={} + /> + ); +} +``` + +- [ ] **Step 5: Verify** — run `pnpm dev:debugger`, should see three-column layout with top/bottom bars. + +- [ ] **Step 6: Commit** + +```bash +git add apps/debugger/src/components/ apps/debugger/src/App.tsx +git commit -m "feat(debugger): add three-column layout with TopBar and BottomBar" +``` + +--- + +### Task 8: Build ConnectionPanel (left panel) + +**Files:** + +- Create: `apps/debugger/src/components/left/ConnectionPanel.tsx` + +- [ ] **Step 1: Create `apps/debugger/src/components/left/ConnectionPanel.tsx`** + +```tsx +import { useState } from "react"; +import { useConnectionStore } from "@/stores/connection"; +import { connect } from "@/services/debug-socket"; + +export function ConnectionPanel() { + const { + status, + serverUrl, + token, + setServerUrl, + setToken, + profiles, + saveProfile, + deleteProfile, + applyProfile, + } = useConnectionStore(); + + const [showProfiles, setShowProfiles] = useState(false); + + const isConnected = status === "connected"; + const canConnect = !isConnected && token.length > 0 && serverUrl.length > 0; + + const handleConnect = () => { + if (canConnect) { + connect(serverUrl, token); + } + }; + + const handleSaveProfile = () => { + const alias = prompt("Profile name:"); + if (alias) { + saveProfile({ alias, serverUrl, token }); + } + }; + + return ( +
+
+ Connection +
+
+
+ + setServerUrl(e.target.value)} + placeholder="http://localhost:3000" + disabled={isConnected} + /> +
+
+ + setToken(e.target.value)} + placeholder="t9bot_..." + disabled={isConnected} + /> +
+
+ + +
+
+ + {/* Saved Profiles */} + {profiles.length > 0 && ( + <> + + {showProfiles && + profiles.map((p) => ( +
applyProfile(p.id)} + > +
+
{p.alias}
+
+ {p.serverUrl} +
+
+ +
+ ))} + + )} +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/debugger/src/components/left/ConnectionPanel.tsx +git commit -m "feat(debugger): add ConnectionPanel with profile management" +``` + +--- + +### Task 9: Build ChannelList and BotInfo (left panel) + +**Files:** + +- Create: `apps/debugger/src/components/left/ChannelList.tsx` +- Create: `apps/debugger/src/components/left/BotInfo.tsx` + +- [ ] **Step 1: Create `apps/debugger/src/components/left/ChannelList.tsx`** + +```tsx +import { useConnectionStore } from "@/stores/connection"; +import { useEventStore } from "@/stores/events"; + +export function ChannelList() { + const channels = useConnectionStore((s) => s.channels); + const { filters, setFilter } = useEventStore(); + + const selectedChannelId = filters.channelId; + + if (channels.length === 0) { + return ( +
+ No channels — connect first +
+ ); + } + + return ( + <> +
+ Channels ({channels.length}) +
+ {channels.map((ch) => ( +
+ setFilter({ + channelId: selectedChannelId === ch.id ? null : ch.id, + }) + } + > +
+ {ch.type === "direct" ? "DM" : "#"} {ch.name} +
+ {ch.memberCount !== undefined && ( +
+ {ch.memberCount} members +
+ )} +
+ ))} + + ); +} +``` + +- [ ] **Step 2: Create `apps/debugger/src/components/left/BotInfo.tsx`** + +```tsx +import { useConnectionStore } from "@/stores/connection"; + +export function BotInfo() { + const { botUserId, botUsername, status } = useConnectionStore(); + + if (status !== "connected" || !botUserId) return null; + + return ( + <> +
+ Bot Info +
+
+
+ ID: + {botUserId.slice(0, 8)}... +
+
+ User: + {botUsername} +
+
+ + ); +} +``` + +- [ ] **Step 3: Wire left panel into App.tsx** + +Update `App.tsx` to replace `LeftPlaceholder`: + +```tsx +import { ConnectionPanel } from "@/components/left/ConnectionPanel"; +import { ChannelList } from "@/components/left/ChannelList"; +import { BotInfo } from "@/components/left/BotInfo"; + +function LeftPanel() { + return ( + <> + + +
+ + + ); +} +``` + +Replace `left={}` with `left={}`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/debugger/src/components/left/ apps/debugger/src/App.tsx +git commit -m "feat(debugger): add ChannelList, BotInfo, and wire left panel" +``` + +--- + +## Chunk 4: Event Stream (Center Panel) + +### Task 10: Build EventFilter bar + +**Files:** + +- Create: `apps/debugger/src/components/center/EventFilter.tsx` + +- [ ] **Step 1: Create `apps/debugger/src/components/center/EventFilter.tsx`** + +```tsx +import { useEventStore } from "@/stores/events"; +import { CATEGORY_COLORS, type EventCategory } from "@/lib/events"; + +const DIRECTION_OPTIONS = [ + { value: "all", label: "All" }, + { value: "in", label: "↓ Received" }, + { value: "out", label: "↑ Sent" }, +] as const; + +const CATEGORY_OPTIONS: { value: EventCategory; label: string }[] = [ + { value: "message", label: "Messages" }, + { value: "streaming", label: "Streaming" }, + { value: "typing", label: "Typing" }, + { value: "presence", label: "Presence" }, + { value: "channel", label: "Channel" }, + { value: "reaction", label: "Reaction" }, + { value: "auth", label: "Auth" }, + { value: "task", label: "Task" }, + { value: "system", label: "System" }, +]; + +export function EventFilter() { + const { filters, setFilter } = useEventStore(); + + const toggleCategory = (cat: EventCategory) => { + const current = filters.categories; + const next = current.includes(cat) + ? current.filter((c) => c !== cat) + : [...current, cat]; + setFilter({ categories: next }); + }; + + return ( +
+ + Events + + | + + {/* Direction filter */} + {DIRECTION_OPTIONS.map((opt) => ( + + ))} + + | + + {/* Category filter chips */} + {CATEGORY_OPTIONS.map((opt) => ( + + ))} + +
+ + {/* Search */} + setFilter({ search: e.target.value })} + /> +
+ ); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add apps/debugger/src/components/center/EventFilter.tsx +git commit -m "feat(debugger): add EventFilter bar with direction, category, and search" +``` + +--- + +### Task 11: Build semantic event renderers + +**Files:** + +- Create: `apps/debugger/src/components/center/renderers/MessageRenderer.tsx` +- Create: `apps/debugger/src/components/center/renderers/StreamingRenderer.tsx` +- Create: `apps/debugger/src/components/center/renderers/PresenceRenderer.tsx` +- Create: `apps/debugger/src/components/center/renderers/GenericRenderer.tsx` +- Create: `apps/debugger/src/components/center/renderers/index.tsx` + +- [ ] **Step 1: Create `MessageRenderer.tsx`** + +```tsx +import type { DebugEvent } from "@/lib/types"; + +export function MessageRenderer({ event }: { event: DebugEvent }) { + const p = event.payload as Record | undefined; + if (!p) return null; + + const sender = + (p.sender as Record)?.displayName ?? + (p.sender as Record)?.username ?? + p.senderId ?? + "unknown"; + const content = (p.content as string) ?? ""; + const parentId = p.parentId as string | undefined; + + return ( +
+
+
+ {String(sender).charAt(0).toUpperCase()} +
+ + {String(sender)} + + {parentId && ( + + (thread: {String(parentId).slice(0, 8)}...) + + )} +
+
{content}
+
+ ); +} +``` + +- [ ] **Step 2: Create `StreamingRenderer.tsx`** + +```tsx +import type { DebugEvent } from "@/lib/types"; +import { useEventStore } from "@/stores/events"; + +export function StreamingRenderer({ event }: { event: DebugEvent }) { + const p = event.payload as Record | undefined; + if (!p) return null; + + const streamId = p.streamId as string | undefined; + const content = p.content as string | undefined; + const reason = p.reason as string | undefined; + + if (event.eventName === "streaming_start") { + return ( +
+ Stream{" "} + + {streamId?.slice(0, 8)}... + {" "} + started +
+ ); + } + + if ( + event.eventName === "streaming_content" || + event.eventName === "streaming_thinking_content" + ) { + const isThinking = event.eventName === "streaming_thinking_content"; + return ( +
+
+ {isThinking && ( + [thinking] + )} + {content} +
+
+ ); + } + + if (event.eventName === "streaming_end") { + return ( +
+ Stream {streamId?.slice(0, 8)}...{" "} + ended +
+ ); + } + + if (event.eventName === "streaming_abort") { + return ( +
+ Stream {streamId?.slice(0, 8)}...{" "} + aborted: {reason} +
+ ); + } + + return null; +} + +/** Aggregated streaming view — shows accumulated content for a streamId */ +export function StreamingAggregateRenderer({ streamId }: { streamId: string }) { + const events = useEventStore((s) => s.events); + const streamEvents = events.filter( + (e) => e.meta?.streamId === streamId && e.eventName === "streaming_content", + ); + + if (streamEvents.length === 0) return null; + + // The last streaming_content has the full accumulated text + const lastContent = streamEvents[streamEvents.length - 1]; + const content = (lastContent.payload as Record) + ?.content as string; + + return ( +
+
+ Streaming ({streamEvents.length} chunks) +
+
+ {content} +
+
+ ); +} +``` + +- [ ] **Step 3: Create `PresenceRenderer.tsx`** + +```tsx +import type { DebugEvent } from "@/lib/types"; + +export function PresenceRenderer({ event }: { event: DebugEvent }) { + const p = event.payload as Record | undefined; + const username = (p?.username ?? p?.userId ?? "unknown") as string; + + if (event.eventName === "user_online") { + return ( + {username} came online + ); + } + if (event.eventName === "user_offline") { + return ( + {username} went offline + ); + } + if (event.eventName === "user_typing") { + return ( + {username} is typing... + ); + } + return null; +} +``` + +- [ ] **Step 4: Create `GenericRenderer.tsx`** + +```tsx +import type { DebugEvent } from "@/lib/types"; + +export function GenericRenderer({ event }: { event: DebugEvent }) { + const summary = + typeof event.payload === "object" && event.payload !== null + ? JSON.stringify(event.payload).slice(0, 120) + : String(event.payload ?? ""); + + return ( +
+ {summary} + {summary.length >= 120 && "..."} +
+ ); +} +``` + +- [ ] **Step 5: Create `renderers/index.tsx`** — renderer selector + +```tsx +import type { DebugEvent } from "@/lib/types"; +import { getEventCategory } from "@/lib/events"; +import { MessageRenderer } from "./MessageRenderer"; +import { StreamingRenderer } from "./StreamingRenderer"; +import { PresenceRenderer } from "./PresenceRenderer"; +import { GenericRenderer } from "./GenericRenderer"; + +export function renderEventPreview(event: DebugEvent) { + const category = getEventCategory(event.eventName); + + switch (category) { + case "message": + return ; + case "streaming": + return ; + case "presence": + case "typing": + return ; + default: + return ; + } +} +``` + +- [ ] **Step 6: Commit** + +```bash +git add apps/debugger/src/components/center/renderers/ +git commit -m "feat(debugger): add semantic event renderers for messages, streaming, presence" +``` + +--- + +### Task 12: Build EventCard and EventStream + +**Files:** + +- Create: `apps/debugger/src/components/center/EventCard.tsx` +- Create: `apps/debugger/src/components/center/EventStream.tsx` + +- [ ] **Step 1: Create `apps/debugger/src/components/center/EventCard.tsx`** + +```tsx +import { useState } from "react"; +import type { DebugEvent } from "@/lib/types"; +import { getEventCategory, CATEGORY_COLORS } from "@/lib/events"; +import { formatTimestamp, formatBytes } from "@/lib/utils"; +import { renderEventPreview } from "./renderers"; +import { useEventStore } from "@/stores/events"; + +export function EventCard({ event }: { event: DebugEvent }) { + const [showJson, setShowJson] = useState(false); + const setSelectedEvent = useEventStore((s) => s.setSelectedEvent); + const selectedEventId = useEventStore((s) => s.selectedEventId); + + const category = getEventCategory(event.eventName); + const color = CATEGORY_COLORS[category]; + const isSelected = selectedEventId === event.id; + const dirArrow = event.direction === "in" ? "↓" : "↑"; + + return ( +
setSelectedEvent(isSelected ? null : event.id)} + > + {/* Header */} +
+ + {dirArrow} {event.eventName} + +
+ {event.channelId && ( + {event.channelId.slice(0, 8)}... + )} + {formatTimestamp(event.timestamp)} +
+
+ + {/* Semantic preview */} + {renderEventPreview(event)} + + {/* Raw JSON toggle */} +
+ +
+ + {showJson && ( +
+          {JSON.stringify(event.payload, null, 2)}
+        
+ )} +
+ ); +} +``` + +- [ ] **Step 2: Create `apps/debugger/src/components/center/EventStream.tsx`** + +```tsx +import { useRef, useEffect } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useEventStore } from "@/stores/events"; +import { EventFilter } from "./EventFilter"; +import { EventCard } from "./EventCard"; + +export function EventStream() { + const filteredEvents = useEventStore((s) => s.getFilteredEvents()); + const parentRef = useRef(null); + const autoScrollRef = useRef(true); + + const virtualizer = useVirtualizer({ + count: filteredEvents.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 80, + overscan: 10, + }); + + // Auto-scroll to bottom when new events arrive + useEffect(() => { + if (autoScrollRef.current && filteredEvents.length > 0) { + virtualizer.scrollToIndex(filteredEvents.length - 1, { + align: "end", + }); + } + }, [filteredEvents.length, virtualizer]); + + // Detect user scroll to disable auto-scroll + const handleScroll = () => { + const el = parentRef.current; + if (!el) return; + const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 50; + autoScrollRef.current = atBottom; + }; + + return ( +
+ +
+ {filteredEvents.length === 0 ? ( +
+ No events yet +
+ ) : ( +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const event = filteredEvents[virtualItem.index]; + return ( +
+ +
+ ); + })} +
+ )} +
+
+ ); +} +``` + +- [ ] **Step 3: Wire center panel into App.tsx** + +Replace `CenterPlaceholder` usage with: + +```tsx +import { EventStream } from "@/components/center/EventStream"; +``` + +And use `center={}`. + +- [ ] **Step 4: Commit** + +```bash +git add apps/debugger/src/components/center/ apps/debugger/src/App.tsx +git commit -m "feat(debugger): add EventStream with virtual scrolling and EventCard" +``` + +--- + +## Chunk 5: Right Panel (Actions) + +### Task 13: Build QuickActions tab + +**Files:** + +- Create: `apps/debugger/src/components/right/QuickActions.tsx` + +- [ ] **Step 1: Create `apps/debugger/src/components/right/QuickActions.tsx`** + +```tsx +import { useState } from "react"; +import { useConnectionStore } from "@/stores/connection"; +import { emit } from "@/services/debug-socket"; +import * as api from "@/services/api"; +import { WS_EVENTS } from "@/lib/events"; +import { nanoid } from "nanoid"; + +export function QuickActions() { + const channels = useConnectionStore((s) => s.channels); + const status = useConnectionStore((s) => s.status); + const disabled = status !== "connected"; + + // Send message state + const [msgChannel, setMsgChannel] = useState(""); + const [msgContent, setMsgContent] = useState(""); + const [msgParentId, setMsgParentId] = useState(""); + const [sending, setSending] = useState(false); + + // Streaming state + const [streamChannel, setStreamChannel] = useState(""); + const [streamContent, setStreamContent] = useState(""); + const [streamActive, setStreamActive] = useState(false); + const [currentStreamId, setCurrentStreamId] = useState(null); + const [autoChunk, setAutoChunk] = useState(true); + const [chunkInterval, setChunkInterval] = useState(500); + const [includeThinking, setIncludeThinking] = useState(false); + const [thinkingContent, setThinkingContent] = useState(""); + + const handleSendMessage = async () => { + if (!msgChannel || !msgContent) return; + setSending(true); + try { + await api.sendMessage(msgChannel, msgContent, msgParentId || undefined); + setMsgContent(""); + setMsgParentId(""); + } finally { + setSending(false); + } + }; + + const handleStartStream = async () => { + if (!streamChannel) return; + const streamId = nanoid(); + setCurrentStreamId(streamId); + setStreamActive(true); + + emit(WS_EVENTS.STREAMING.START, { + streamId, + channelId: streamChannel, + }); + + if (autoChunk && streamContent) { + // Auto-chunk mode: send thinking first if enabled, then split content + if (includeThinking && thinkingContent) { + emit(WS_EVENTS.STREAMING.THINKING_CONTENT, { + streamId, + channelId: streamChannel, + content: thinkingContent, + }); + } + + const words = streamContent.split(" "); + let accumulated = ""; + for (let i = 0; i < words.length; i++) { + accumulated += (i > 0 ? " " : "") + words[i]; + await new Promise((r) => setTimeout(r, chunkInterval)); + emit(WS_EVENTS.STREAMING.CONTENT, { + streamId, + channelId: streamChannel, + content: accumulated, + }); + } + + emit(WS_EVENTS.STREAMING.END, { + streamId, + channelId: streamChannel, + }); + setStreamActive(false); + setCurrentStreamId(null); + } + // In manual mode, user controls via End/Abort buttons + }; + + const handleEndStream = () => { + if (!currentStreamId || !streamChannel) return; + emit(WS_EVENTS.STREAMING.END, { + streamId: currentStreamId, + channelId: streamChannel, + }); + setStreamActive(false); + setCurrentStreamId(null); + }; + + const handleAbortStream = () => { + if (!currentStreamId || !streamChannel) return; + emit(WS_EVENTS.STREAMING.ABORT, { + streamId: currentStreamId, + channelId: streamChannel, + reason: "cancelled", + }); + setStreamActive(false); + setCurrentStreamId(null); + }; + + const handleSendStreamChunk = () => { + if (!currentStreamId || !streamChannel || !streamContent) return; + emit(WS_EVENTS.STREAMING.CONTENT, { + streamId: currentStreamId, + channelId: streamChannel, + content: streamContent, + }); + }; + + const ChannelSelect = ({ + value, + onChange, + }: { + value: string; + onChange: (v: string) => void; + }) => ( + + ); + + return ( +
+ {/* Send Message */} +
+
+ Send Message (REST) +
+
+ +