From 9002e6165b1d195fec9fc25935b56e195df156b7 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Fri, 23 Jan 2026 21:16:32 -0800 Subject: [PATCH] feat(agent): add MCP server and desktop command system --- .env.example | 3 + apps/api/MCP_TOOLS.md | 309 ++ apps/api/package.json | 1 + .../src/app/api/electric/[...path]/utils.ts | 16 +- apps/api/src/app/api/mcp/route.ts | 145 + apps/api/src/lib/mcp/auth.ts | 96 + apps/api/src/lib/mcp/tools.ts | 1085 ++++++ apps/desktop/VOICE_AGENT_PLAN.md | 336 ++ .../plans/20260123-mcp-layout-tools.md | 188 ++ .../src/lib/trpc/routers/auth/index.ts | 6 + .../trpc/routers/auth/utils/crypto-storage.ts | 57 +- .../routers/workspaces/procedures/status.ts | 21 +- apps/desktop/src/main/lib/device-info.ts | 96 + .../workspaces/useCreateWorkspace.ts | 19 +- .../components/AgentHooks/AgentHooks.tsx | 12 + .../hooks/useCommandWatcher/index.ts | 1 + .../tools/create-worktree.ts | 69 + .../tools/delete-workspace.ts | 35 + .../tools/get-app-context.ts | 40 + .../hooks/useCommandWatcher/tools/index.ts | 52 + .../useCommandWatcher/tools/list-projects.ts | 36 + .../tools/list-workspaces.ts | 26 + .../tools/navigate-to-workspace.ts | 67 + .../tools/switch-workspace.ts | 65 + .../hooks/useCommandWatcher/tools/types.ts | 33 + .../useCommandWatcher/useCommandWatcher.ts | 185 ++ .../hooks/useDevicePresence/index.ts | 1 + .../useDevicePresence/useDevicePresence.ts | 45 + .../components/AgentHooks/index.ts | 1 + .../renderer/routes/_authenticated/layout.tsx | 4 +- .../CollectionsProvider/collections.ts | 63 +- .../ApiKeysSettings/ApiKeysSettings.tsx | 323 ++ .../components/ApiKeysSettings/index.ts | 1 + .../_authenticated/settings/api-keys/page.tsx | 22 + .../SettingsSidebar/GeneralSettings.tsx | 44 +- .../DevicesSettings/DevicesSettings.tsx | 120 + .../components/DevicesSettings/index.ts | 1 + .../_authenticated/settings/devices/page.tsx | 6 + .../utils/settings-search/settings-search.ts | 40 + .../src/renderer/stores/settings-state.ts | 2 + apps/mobile/app/(authenticated)/_layout.tsx | 3 + apps/mobile/hooks/useDevicePresence/index.ts | 1 + .../useDevicePresence/useDevicePresence.ts | 69 + apps/mobile/package.json | 1 + .../(authenticated)/index/HomeScreen.tsx | 83 +- bun.lock | 37 +- .../0013_add_mcp_actions_and_presence.sql | 61 + packages/db/drizzle/meta/0013_snapshot.json | 2903 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/enums.ts | 15 + packages/db/src/schema/relations.ts | 48 + packages/db/src/schema/schema.ts | 109 + packages/shared/src/constants.ts | 3 + packages/trpc/package.json | 1 + packages/trpc/src/env.ts | 1 + packages/trpc/src/root.ts | 5 + packages/trpc/src/router/agent/agent.ts | 78 + packages/trpc/src/router/agent/api-keys.ts | 221 ++ packages/trpc/src/router/agent/index.ts | 2 + packages/trpc/src/router/device/device.ts | 94 + packages/trpc/src/router/device/index.ts | 1 + 61 files changed, 7337 insertions(+), 78 deletions(-) create mode 100644 apps/api/MCP_TOOLS.md create mode 100644 apps/api/src/app/api/mcp/route.ts create mode 100644 apps/api/src/lib/mcp/auth.ts create mode 100644 apps/api/src/lib/mcp/tools.ts create mode 100644 apps/desktop/VOICE_AGENT_PLAN.md create mode 100644 apps/desktop/plans/20260123-mcp-layout-tools.md create mode 100644 apps/desktop/src/main/lib/device-info.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/AgentHooks.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/create-worktree.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/delete-workspace.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/get-app-context.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/list-projects.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/list-workspaces.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/navigate-to-workspace.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/switch-workspace.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/types.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/useCommandWatcher.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/useDevicePresence.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/components/ApiKeysSettings/ApiKeysSettings.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/components/ApiKeysSettings/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/page.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/DevicesSettings.tsx create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/index.ts create mode 100644 apps/desktop/src/renderer/routes/_authenticated/settings/devices/page.tsx create mode 100644 apps/mobile/hooks/useDevicePresence/index.ts create mode 100644 apps/mobile/hooks/useDevicePresence/useDevicePresence.ts create mode 100644 packages/db/drizzle/0013_add_mcp_actions_and_presence.sql create mode 100644 packages/db/drizzle/meta/0013_snapshot.json create mode 100644 packages/trpc/src/router/agent/agent.ts create mode 100644 packages/trpc/src/router/agent/api-keys.ts create mode 100644 packages/trpc/src/router/agent/index.ts create mode 100644 packages/trpc/src/router/device/device.ts create mode 100644 packages/trpc/src/router/device/index.ts diff --git a/.env.example b/.env.example index 42e77f13c6b..464107ea1a4 100644 --- a/.env.example +++ b/.env.example @@ -103,3 +103,6 @@ QSTASH_TOKEN= # QStash webhook signature verification keys (used to verify webhook requests) QSTASH_CURRENT_SIGNING_KEY= QSTASH_NEXT_SIGNING_KEY= + +# MCP API Key for Claude Code +SUPERSET_MCP_API_KEY= diff --git a/apps/api/MCP_TOOLS.md b/apps/api/MCP_TOOLS.md new file mode 100644 index 00000000000..f6074babcf0 --- /dev/null +++ b/apps/api/MCP_TOOLS.md @@ -0,0 +1,309 @@ +# MCP Server Tools + +Superset MCP server exposing tools for task management and device orchestration. + +## Authentication + +API key passed via `X-API-Key` header. Key encodes: +- `userId` - who is making the request +- `organizationId` - which org context +- `defaultDeviceId` - default target for device commands (usually caller's own device) + +## Device Targeting + +Device commands can target **any device in the organization**: +- If `deviceId` not specified, defaults to `defaultDeviceId` from API key +- Any org member can run commands on any org device (permissions can be added later) +- Device must be online (heartbeat within last 60s) to receive commands + +## Tool Categories + +### Task Tools (Cloud - Immediate Execution) + +#### `create_task` +Create a new task in the organization. + +```typescript +const createTaskInput = z.object({ + title: z.string().min(1).describe("Task title"), + description: z.string().optional().describe("Task description (markdown)"), + priority: z.enum(["urgent", "high", "medium", "low", "none"]).default("none").describe("Task priority"), + assigneeId: z.string().uuid().optional().describe("User ID to assign the task to"), + statusId: z.string().uuid().optional().describe("Status ID (defaults to first backlog status)"), + labels: z.array(z.string()).optional().describe("Array of label strings"), + dueDate: z.string().datetime().optional().describe("Due date in ISO format"), + estimate: z.number().int().positive().optional().describe("Estimate in points/hours"), +}); + +const createTaskOutput = z.object({ + id: z.string().uuid(), + slug: z.string(), + title: z.string(), + // ... full task object +}); +``` + +#### `update_task` +Update an existing task. + +```typescript +const updateTaskInput = z.object({ + taskId: z.string().describe("Task ID or slug"), + title: z.string().min(1).optional().describe("New title"), + description: z.string().optional().describe("New description"), + priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(), + assigneeId: z.string().uuid().nullable().optional().describe("New assignee (null to unassign)"), + statusId: z.string().uuid().optional().describe("New status ID"), + labels: z.array(z.string()).optional().describe("Replace labels"), + dueDate: z.string().datetime().nullable().optional().describe("New due date (null to clear)"), + estimate: z.number().int().positive().nullable().optional(), +}); + +const updateTaskOutput = z.object({ + id: z.string().uuid(), + slug: z.string(), + // ... updated task object +}); +``` + +#### `list_tasks` +List tasks with optional filters. + +```typescript +const listTasksInput = z.object({ + statusId: z.string().uuid().optional().describe("Filter by status ID"), + statusType: z.enum(["backlog", "unstarted", "started", "completed", "canceled"]).optional().describe("Filter by status type"), + assigneeId: z.string().uuid().optional().describe("Filter by assignee"), + assignedToMe: z.boolean().optional().describe("Filter to tasks assigned to current user"), + priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(), + search: z.string().optional().describe("Search in title/description"), + limit: z.number().int().min(1).max(100).default(50), + offset: z.number().int().min(0).default(0), +}); + +const listTasksOutput = z.object({ + tasks: z.array(taskSchema), + total: z.number(), + hasMore: z.boolean(), +}); +``` + +#### `get_task` +Get a single task by ID or slug. + +```typescript +const getTaskInput = z.object({ + taskId: z.string().describe("Task ID (uuid) or slug"), +}); + +const getTaskOutput = taskSchema; // Full task with relations +``` + +#### `delete_task` +Soft delete a task. + +```typescript +const deleteTaskInput = z.object({ + taskId: z.string().describe("Task ID or slug"), +}); + +const deleteTaskOutput = z.object({ + success: z.boolean(), + deletedAt: z.string().datetime(), +}); +``` + +--- + +### Organization Tools (Cloud - Immediate Execution) + +#### `list_members` +List members in the organization. + +```typescript +const listMembersInput = z.object({ + search: z.string().optional().describe("Search by name or email"), + limit: z.number().int().min(1).max(100).default(50), +}); + +const listMembersOutput = z.object({ + members: z.array(z.object({ + id: z.string().uuid(), + name: z.string(), + email: z.string().email(), + image: z.string().url().nullable(), + role: z.enum(["owner", "admin", "member"]), + })), +}); +``` + +#### `list_task_statuses` +List available task statuses for the organization. + +```typescript +const listTaskStatusesInput = z.object({}); + +const listTaskStatusesOutput = z.object({ + statuses: z.array(z.object({ + id: z.string().uuid(), + name: z.string(), + color: z.string(), + type: z.enum(["backlog", "unstarted", "started", "completed", "canceled"]), + position: z.number(), + })), +}); +``` + +--- + +### Device Tools (Routed to Desktop Executor) + +These tools write to `agent_commands` table and poll for results. + +#### `list_devices` +List online devices in the organization. + +```typescript +const listDevicesInput = z.object({ + includeOffline: z.boolean().default(false).describe("Include recently offline devices"), +}); + +const listDevicesOutput = z.object({ + devices: z.array(z.object({ + deviceId: z.string(), + deviceName: z.string(), + deviceType: z.enum(["desktop", "mobile", "web"]), + ownerId: z.string().uuid().describe("User who owns this device"), + ownerName: z.string().describe("Name of device owner"), + lastSeenAt: z.string().datetime(), + isOnline: z.boolean(), + })), +}); +``` + +#### `list_workspaces` +List all workspaces/worktrees on a device. + +```typescript +const listWorkspacesInput = z.object({ + deviceId: z.string().optional().describe("Target device (defaults to caller's device)"), +}); + +const listWorkspacesOutput = z.object({ + workspaces: z.array(z.object({ + id: z.string().uuid(), + name: z.string(), + path: z.string(), + branch: z.string(), + isActive: z.boolean(), + repositoryId: z.string().uuid().nullable(), + })), +}); +``` + +#### `get_current_workspace` +Get the currently active workspace on a device. + +```typescript +const getCurrentWorkspaceInput = z.object({ + deviceId: z.string().optional(), +}); + +const getCurrentWorkspaceOutput = z.object({ + workspace: z.object({ + id: z.string().uuid(), + name: z.string(), + path: z.string(), + branch: z.string(), + repositoryId: z.string().uuid().nullable(), + // Additional context + uncommittedChanges: z.number().int(), + currentTask: taskSchema.nullable(), + }).nullable(), +}); +``` + +#### `create_worktree` +Create a new git worktree workspace. + +```typescript +const createWorktreeInput = z.object({ + deviceId: z.string().optional(), + name: z.string().optional().describe("Workspace name (auto-generated if not provided)"), + branchName: z.string().optional().describe("Branch name (auto-generated if not provided)"), + baseBranch: z.string().optional().describe("Branch to create from (defaults to main)"), + taskId: z.string().optional().describe("Task ID to associate with workspace"), +}); + +const createWorktreeOutput = z.object({ + workspace: z.object({ + id: z.string().uuid(), + name: z.string(), + path: z.string(), + branch: z.string(), + }), +}); +``` + +#### `switch_workspace` +Switch to a different workspace. + +```typescript +const switchWorkspaceInput = z.object({ + deviceId: z.string().optional(), + workspaceId: z.string().uuid().optional().describe("Workspace ID to switch to"), + workspaceName: z.string().optional().describe("Workspace name to switch to"), +}); + +const switchWorkspaceOutput = z.object({ + success: z.boolean(), + workspace: z.object({ + id: z.string().uuid(), + name: z.string(), + path: z.string(), + branch: z.string(), + }), +}); +``` + +--- + +## Shared Schemas + +```typescript +const taskSchema = z.object({ + id: z.string().uuid(), + slug: z.string(), + title: z.string(), + description: z.string().nullable(), + priority: z.enum(["urgent", "high", "medium", "low", "none"]), + status: z.object({ + id: z.string().uuid(), + name: z.string(), + color: z.string(), + type: z.enum(["backlog", "unstarted", "started", "completed", "canceled"]), + }), + assignee: z.object({ + id: z.string().uuid(), + name: z.string(), + email: z.string(), + image: z.string().nullable(), + }).nullable(), + labels: z.array(z.string()), + estimate: z.number().nullable(), + dueDate: z.string().datetime().nullable(), + branch: z.string().nullable(), + prUrl: z.string().url().nullable(), + createdAt: z.string().datetime(), + updatedAt: z.string().datetime(), +}); +``` + +--- + +## Implementation Notes + +1. **Validation**: All inputs validated with Zod, converted to JSON Schema for MCP +2. **Device routing**: Device tools check `canRunTool(deviceType, toolName)` before routing +3. **Timeouts**: Device commands have 30s default timeout, configurable per-call +4. **Auth**: API key required, encodes user/org/device context diff --git a/apps/api/package.json b/apps/api/package.json index 13b846ca6a1..a0228ac8371 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,7 @@ "dependencies": { "@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724", "@linear/sdk": "^68.1.0", + "@modelcontextprotocol/sdk": "^1.25.3", "@octokit/app": "^16.1.2", "@octokit/rest": "^22.0.1", "@octokit/webhooks": "^14.2.0", diff --git a/apps/api/src/app/api/electric/[...path]/utils.ts b/apps/api/src/app/api/electric/[...path]/utils.ts index 1fe43868393..34b8179d88c 100644 --- a/apps/api/src/app/api/electric/[...path]/utils.ts +++ b/apps/api/src/app/api/electric/[...path]/utils.ts @@ -1,5 +1,7 @@ import { db } from "@superset/db/client"; import { + agentCommands, + devicePresence, invitations, members, organizations, @@ -18,7 +20,9 @@ export type AllowedTable = | "auth.members" | "auth.organizations" | "auth.users" - | "auth.invitations"; + | "auth.invitations" + | "device_presence" + | "agent_commands"; interface WhereClause { fragment: string; @@ -89,6 +93,16 @@ export async function buildWhereClause( return { fragment, params: [organizationId] }; } + case "device_presence": + return build( + devicePresence, + devicePresence.organizationId, + organizationId, + ); + + case "agent_commands": + return build(agentCommands, agentCommands.organizationId, organizationId); + default: return null; } diff --git a/apps/api/src/app/api/mcp/route.ts b/apps/api/src/app/api/mcp/route.ts new file mode 100644 index 00000000000..d46c7656a00 --- /dev/null +++ b/apps/api/src/app/api/mcp/route.ts @@ -0,0 +1,145 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; +import { + authenticateMcpRequest, + createUnauthorizedResponse, +} from "@/lib/mcp/auth"; +import { registerMcpTools } from "@/lib/mcp/tools"; + +/** + * Create a new MCP server instance with tools registered + */ +function createMcpServer(ctx: { + userId: string; + organizationId: string; + defaultDeviceId: string | null; +}) { + const server = new McpServer( + { + name: "superset-mcp-server", + version: "1.0.0", + }, + { + capabilities: { + tools: {}, + }, + }, + ); + + registerMcpTools(server, ctx); + + return server; +} + +/** + * Handle POST requests to the MCP endpoint + * This is the main entry point for MCP tool calls + */ +export async function POST(request: Request): Promise { + // Authenticate the request + const ctx = await authenticateMcpRequest(request); + + if (!ctx) { + return createUnauthorizedResponse(); + } + + try { + // Create a fresh server instance for this request (stateless) + const server = createMcpServer(ctx); + + // Create transport (stateless mode - no session management) + const transport = new WebStandardStreamableHTTPServerTransport(); + + // Connect server to transport + await server.connect(transport); + + // Handle the request and return the response + const response = await transport.handleRequest(request); + + return response; + } catch (error) { + console.error("[mcp] Error handling request:", error); + + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32603, + message: "Internal server error", + }, + id: null, + }), + { + status: 500, + headers: { + "Content-Type": "application/json", + }, + }, + ); + } +} + +/** + * Handle GET requests - return method not allowed for stateless mode + */ +export async function GET(): Promise { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: + "Method not allowed. This MCP server operates in stateless mode - use POST requests only.", + }, + id: null, + }), + { + status: 405, + headers: { + "Content-Type": "application/json", + Allow: "POST, OPTIONS", + }, + }, + ); +} + +/** + * Handle DELETE requests - return method not allowed for stateless mode + */ +export async function DELETE(): Promise { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32000, + message: + "Method not allowed. This MCP server operates in stateless mode - sessions are not supported.", + }, + id: null, + }), + { + status: 405, + headers: { + "Content-Type": "application/json", + Allow: "POST, OPTIONS", + }, + }, + ); +} + +/** + * Handle OPTIONS requests for CORS preflight + */ +export async function OPTIONS(): Promise { + return new Response(null, { + status: 204, + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, DELETE, OPTIONS", + "Access-Control-Allow-Headers": + "Content-Type, X-API-Key, mcp-session-id, Last-Event-ID, mcp-protocol-version", + "Access-Control-Expose-Headers": "mcp-session-id, mcp-protocol-version", + "Access-Control-Max-Age": "86400", + }, + }); +} diff --git a/apps/api/src/lib/mcp/auth.ts b/apps/api/src/lib/mcp/auth.ts new file mode 100644 index 00000000000..558e6754779 --- /dev/null +++ b/apps/api/src/lib/mcp/auth.ts @@ -0,0 +1,96 @@ +import { createHash } from "node:crypto"; +import { db } from "@superset/db/client"; +import { apiKeys } from "@superset/db/schema"; +import { eq } from "drizzle-orm"; + +export interface McpContext { + userId: string; + organizationId: string; + defaultDeviceId: string | null; +} + +function hashApiKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); +} + +async function validateApiKey(key: string): Promise { + if (!key.startsWith("sk_live_")) { + return null; + } + + const keyHash = hashApiKey(key); + + const [found] = await db + .select({ + id: apiKeys.id, + userId: apiKeys.userId, + organizationId: apiKeys.organizationId, + defaultDeviceId: apiKeys.defaultDeviceId, + expiresAt: apiKeys.expiresAt, + revokedAt: apiKeys.revokedAt, + }) + .from(apiKeys) + .where(eq(apiKeys.keyHash, keyHash)) + .limit(1); + + if (!found) { + return null; + } + + if (found.revokedAt) { + return null; + } + + if (found.expiresAt && found.expiresAt < new Date()) { + return null; + } + + // Update last used timestamp (fire and forget) + db.update(apiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(apiKeys.id, found.id)) + .catch(() => {}); + + return { + userId: found.userId, + organizationId: found.organizationId, + defaultDeviceId: found.defaultDeviceId, + }; +} + +/** + * Authenticate an MCP request using API key from header + */ +export async function authenticateMcpRequest( + request: Request, +): Promise { + const apiKey = request.headers.get("X-API-Key"); + + if (!apiKey) { + return null; + } + + return validateApiKey(apiKey); +} + +/** + * Create an unauthorized JSON-RPC error response + */ +export function createUnauthorizedResponse(): Response { + return new Response( + JSON.stringify({ + jsonrpc: "2.0", + error: { + code: -32001, + message: "Unauthorized: Invalid or missing API key", + }, + id: null, + }), + { + status: 401, + headers: { + "Content-Type": "application/json", + }, + }, + ); +} diff --git a/apps/api/src/lib/mcp/tools.ts b/apps/api/src/lib/mcp/tools.ts new file mode 100644 index 00000000000..5a2dd60c8c7 --- /dev/null +++ b/apps/api/src/lib/mcp/tools.ts @@ -0,0 +1,1085 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { db, dbWs } from "@superset/db/client"; +import { + agentCommands, + devicePresence, + members, + taskStatuses, + tasks, + users, +} from "@superset/db/schema"; +import { getCurrentTxid } from "@superset/db/utils"; +import { and, desc, eq, gt, ilike, isNull, or } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; +import { z } from "zod"; +import type { McpContext } from "./auth"; + +const DEVICE_ONLINE_THRESHOLD_MS = 60_000; // 60 seconds + +/** + * Register all MCP tools on the server + */ +export function registerMcpTools(server: McpServer, ctx: McpContext) { + // ======================================== + // TASK TOOLS (Cloud - Immediate Execution) + // ======================================== + + server.tool( + "create_task", + "Create a new task in the organization", + { + title: z.string().min(1).describe("Task title"), + description: z + .string() + .optional() + .describe("Task description (markdown)"), + priority: z + .enum(["urgent", "high", "medium", "low", "none"]) + .default("none") + .describe("Task priority"), + assigneeId: z + .string() + .uuid() + .optional() + .describe("User ID to assign the task to"), + statusId: z + .string() + .uuid() + .optional() + .describe("Status ID (defaults to first backlog status)"), + labels: z.array(z.string()).optional().describe("Array of label strings"), + dueDate: z + .string() + .datetime() + .optional() + .describe("Due date in ISO format"), + estimate: z + .number() + .int() + .positive() + .optional() + .describe("Estimate in points/hours"), + }, + async (params) => { + // Get default status if not provided + let statusId = params.statusId; + if (!statusId) { + const defaultStatus = await db + .select() + .from(taskStatuses) + .where( + and( + eq(taskStatuses.organizationId, ctx.organizationId), + eq(taskStatuses.type, "backlog"), + ), + ) + .orderBy(taskStatuses.position) + .limit(1); + statusId = defaultStatus[0]?.id; + if (!statusId) { + return { + content: [ + { + type: "text", + text: "Error: No default status found for organization", + }, + ], + isError: true, + }; + } + } + + // Generate slug from title + const slug = params.title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-|-$/g, "") + .slice(0, 50); + + // Check for existing slug and make unique + const existingTasks = await db + .select({ slug: tasks.slug }) + .from(tasks) + .where( + and( + eq(tasks.organizationId, ctx.organizationId), + ilike(tasks.slug, `${slug}%`), + ), + ); + + let uniqueSlug = slug; + if (existingTasks.length > 0) { + const existingSlugs = new Set(existingTasks.map((t) => t.slug)); + let counter = 1; + while (existingSlugs.has(uniqueSlug)) { + uniqueSlug = `${slug}-${counter}`; + counter++; + } + } + + const result = await dbWs.transaction(async (tx) => { + const [task] = await tx + .insert(tasks) + .values({ + slug: uniqueSlug, + title: params.title, + description: params.description ?? null, + priority: params.priority ?? "none", + statusId, + organizationId: ctx.organizationId, + creatorId: ctx.userId, + assigneeId: params.assigneeId ?? null, + labels: params.labels ?? [], + dueDate: params.dueDate ? new Date(params.dueDate) : null, + estimate: params.estimate ?? null, + }) + .returning(); + + const txid = await getCurrentTxid(tx); + return { task, txid }; + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + id: result.task?.id, + slug: result.task?.slug, + title: result.task?.title, + txid: result.txid, + }, + null, + 2, + ), + }, + ], + }; + }, + ); + + server.tool( + "update_task", + "Update an existing task", + { + taskId: z.string().describe("Task ID (uuid) or slug"), + title: z.string().min(1).optional().describe("New title"), + description: z.string().optional().describe("New description"), + priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(), + assigneeId: z + .string() + .uuid() + .nullable() + .optional() + .describe("New assignee (null to unassign)"), + statusId: z.string().uuid().optional().describe("New status ID"), + labels: z.array(z.string()).optional().describe("Replace labels"), + dueDate: z + .string() + .datetime() + .nullable() + .optional() + .describe("New due date (null to clear)"), + estimate: z.number().int().positive().nullable().optional(), + }, + async (params) => { + // Find task by ID or slug + const isUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + params.taskId, + ); + const [existingTask] = await db + .select() + .from(tasks) + .where( + and( + isUuid + ? eq(tasks.id, params.taskId) + : eq(tasks.slug, params.taskId), + eq(tasks.organizationId, ctx.organizationId), + isNull(tasks.deletedAt), + ), + ) + .limit(1); + + if (!existingTask) { + return { + content: [{ type: "text", text: "Error: Task not found" }], + isError: true, + }; + } + + const updateData: Record = {}; + if (params.title !== undefined) updateData.title = params.title; + if (params.description !== undefined) + updateData.description = params.description; + if (params.priority !== undefined) updateData.priority = params.priority; + if (params.assigneeId !== undefined) + updateData.assigneeId = params.assigneeId; + if (params.statusId !== undefined) updateData.statusId = params.statusId; + if (params.labels !== undefined) updateData.labels = params.labels; + if (params.dueDate !== undefined) + updateData.dueDate = params.dueDate ? new Date(params.dueDate) : null; + if (params.estimate !== undefined) updateData.estimate = params.estimate; + + const result = await dbWs.transaction(async (tx) => { + const [task] = await tx + .update(tasks) + .set(updateData) + .where(eq(tasks.id, existingTask.id)) + .returning(); + + const txid = await getCurrentTxid(tx); + return { task, txid }; + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + id: result.task?.id, + slug: result.task?.slug, + title: result.task?.title, + txid: result.txid, + }, + null, + 2, + ), + }, + ], + }; + }, + ); + + server.tool( + "list_tasks", + "List tasks with optional filters", + { + statusId: z.string().uuid().optional().describe("Filter by status ID"), + statusType: z + .enum(["backlog", "unstarted", "started", "completed", "canceled"]) + .optional() + .describe("Filter by status type"), + assigneeId: z.string().uuid().optional().describe("Filter by assignee"), + assignedToMe: z + .boolean() + .optional() + .describe("Filter to tasks assigned to current user"), + priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(), + search: z.string().optional().describe("Search in title/description"), + limit: z.number().int().min(1).max(100).default(50), + offset: z.number().int().min(0).default(0), + }, + async (params) => { + const assignee = alias(users, "assignee"); + const status = alias(taskStatuses, "status"); + + const conditions = [ + eq(tasks.organizationId, ctx.organizationId), + isNull(tasks.deletedAt), + ]; + + if (params.statusId) { + conditions.push(eq(tasks.statusId, params.statusId)); + } + + if (params.assigneeId) { + conditions.push(eq(tasks.assigneeId, params.assigneeId)); + } else if (params.assignedToMe) { + conditions.push(eq(tasks.assigneeId, ctx.userId)); + } + + if (params.priority) { + conditions.push(eq(tasks.priority, params.priority)); + } + + if (params.search) { + const searchCondition = or( + ilike(tasks.title, `%${params.search}%`), + ilike(tasks.description, `%${params.search}%`), + ); + if (searchCondition) { + conditions.push(searchCondition); + } + } + + // Add status type filter if provided + if (params.statusType) { + // Get status IDs of the requested type + const statusesOfType = await db + .select({ id: taskStatuses.id }) + .from(taskStatuses) + .where( + and( + eq(taskStatuses.organizationId, ctx.organizationId), + eq(taskStatuses.type, params.statusType), + ), + ); + const statusIds = statusesOfType.map((s) => s.id); + if (statusIds.length > 0) { + const statusCondition = or( + ...statusIds.map((id) => eq(tasks.statusId, id)), + ); + if (statusCondition) { + conditions.push(statusCondition); + } + } else { + // No statuses of this type, return empty + return { + content: [ + { + type: "text", + text: JSON.stringify( + { tasks: [], count: 0, hasMore: false }, + null, + 2, + ), + }, + ], + }; + } + } + + const tasksList = await db + .select({ + id: tasks.id, + slug: tasks.slug, + title: tasks.title, + description: tasks.description, + priority: tasks.priority, + statusId: tasks.statusId, + statusName: status.name, + statusType: status.type, + assigneeId: tasks.assigneeId, + assigneeName: assignee.name, + labels: tasks.labels, + dueDate: tasks.dueDate, + estimate: tasks.estimate, + createdAt: tasks.createdAt, + }) + .from(tasks) + .leftJoin(assignee, eq(tasks.assigneeId, assignee.id)) + .leftJoin(status, eq(tasks.statusId, status.id)) + .where(and(...conditions)) + .orderBy(desc(tasks.createdAt)) + .limit(params.limit) + .offset(params.offset); + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + tasks: tasksList, + count: tasksList.length, + hasMore: tasksList.length === params.limit, + }, + null, + 2, + ), + }, + ], + }; + }, + ); + + server.tool( + "get_task", + "Get a single task by ID or slug", + { + taskId: z.string().describe("Task ID (uuid) or slug"), + }, + async (params) => { + const isUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + params.taskId, + ); + + const assignee = alias(users, "assignee"); + const creator = alias(users, "creator"); + const status = alias(taskStatuses, "status"); + + const [task] = await db + .select({ + id: tasks.id, + slug: tasks.slug, + title: tasks.title, + description: tasks.description, + priority: tasks.priority, + statusId: tasks.statusId, + statusName: status.name, + statusType: status.type, + statusColor: status.color, + assigneeId: tasks.assigneeId, + assigneeName: assignee.name, + assigneeEmail: assignee.email, + creatorId: tasks.creatorId, + creatorName: creator.name, + labels: tasks.labels, + dueDate: tasks.dueDate, + estimate: tasks.estimate, + branch: tasks.branch, + prUrl: tasks.prUrl, + createdAt: tasks.createdAt, + updatedAt: tasks.updatedAt, + }) + .from(tasks) + .leftJoin(assignee, eq(tasks.assigneeId, assignee.id)) + .leftJoin(creator, eq(tasks.creatorId, creator.id)) + .leftJoin(status, eq(tasks.statusId, status.id)) + .where( + and( + isUuid + ? eq(tasks.id, params.taskId) + : eq(tasks.slug, params.taskId), + eq(tasks.organizationId, ctx.organizationId), + isNull(tasks.deletedAt), + ), + ) + .limit(1); + + if (!task) { + return { + content: [{ type: "text", text: "Error: Task not found" }], + isError: true, + }; + } + + return { + content: [{ type: "text", text: JSON.stringify(task, null, 2) }], + }; + }, + ); + + server.tool( + "delete_task", + "Soft delete a task", + { + taskId: z.string().describe("Task ID (uuid) or slug"), + }, + async (params) => { + const isUuid = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test( + params.taskId, + ); + + const [existingTask] = await db + .select() + .from(tasks) + .where( + and( + isUuid + ? eq(tasks.id, params.taskId) + : eq(tasks.slug, params.taskId), + eq(tasks.organizationId, ctx.organizationId), + isNull(tasks.deletedAt), + ), + ) + .limit(1); + + if (!existingTask) { + return { + content: [{ type: "text", text: "Error: Task not found" }], + isError: true, + }; + } + + const result = await dbWs.transaction(async (tx) => { + await tx + .update(tasks) + .set({ deletedAt: new Date() }) + .where(eq(tasks.id, existingTask.id)); + + const txid = await getCurrentTxid(tx); + return { txid }; + }); + + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + success: true, + deletedAt: new Date().toISOString(), + txid: result.txid, + }, + null, + 2, + ), + }, + ], + }; + }, + ); + + // ======================================== + // ORGANIZATION TOOLS (Cloud - Immediate) + // ======================================== + + server.tool( + "list_members", + "List members in the organization", + { + search: z.string().optional().describe("Search by name or email"), + limit: z.number().int().min(1).max(100).default(50), + }, + async (params) => { + const conditions = [eq(members.organizationId, ctx.organizationId)]; + + let query = db + .select({ + id: users.id, + name: users.name, + email: users.email, + image: users.image, + role: members.role, + }) + .from(members) + .innerJoin(users, eq(members.userId, users.id)) + .where(and(...conditions)) + .limit(params.limit); + + if (params.search) { + query = db + .select({ + id: users.id, + name: users.name, + email: users.email, + image: users.image, + role: members.role, + }) + .from(members) + .innerJoin(users, eq(members.userId, users.id)) + .where( + and( + ...conditions, + or( + ilike(users.name, `%${params.search}%`), + ilike(users.email, `%${params.search}%`), + ), + ), + ) + .limit(params.limit); + } + + const membersList = await query; + + return { + content: [ + { + type: "text", + text: JSON.stringify({ members: membersList }, null, 2), + }, + ], + }; + }, + ); + + server.tool( + "list_task_statuses", + "List available task statuses for the organization", + {}, + async () => { + const statuses = await db + .select({ + id: taskStatuses.id, + name: taskStatuses.name, + color: taskStatuses.color, + type: taskStatuses.type, + position: taskStatuses.position, + }) + .from(taskStatuses) + .where(eq(taskStatuses.organizationId, ctx.organizationId)) + .orderBy(taskStatuses.position); + + return { + content: [ + { type: "text", text: JSON.stringify({ statuses }, null, 2) }, + ], + }; + }, + ); + + // ======================================== + // DEVICE TOOLS (Cloud - Query) + // ======================================== + + server.tool( + "list_devices", + "List online devices in the organization", + { + includeOffline: z + .boolean() + .default(false) + .describe("Include recently offline devices"), + }, + async (params) => { + const threshold = new Date(Date.now() - DEVICE_ONLINE_THRESHOLD_MS); + const offlineThreshold = new Date( + Date.now() - DEVICE_ONLINE_THRESHOLD_MS * 10, + ); // 10 minutes for recently offline + + const conditions = [ + eq(devicePresence.organizationId, ctx.organizationId), + ]; + + if (!params.includeOffline) { + conditions.push(gt(devicePresence.lastSeenAt, threshold)); + } else { + conditions.push(gt(devicePresence.lastSeenAt, offlineThreshold)); + } + + const devices = await db + .select({ + deviceId: devicePresence.deviceId, + deviceName: devicePresence.deviceName, + deviceType: devicePresence.deviceType, + lastSeenAt: devicePresence.lastSeenAt, + ownerId: devicePresence.userId, + ownerName: users.name, + ownerEmail: users.email, + }) + .from(devicePresence) + .innerJoin(users, eq(devicePresence.userId, users.id)) + .where(and(...conditions)) + .orderBy(desc(devicePresence.lastSeenAt)); + + const devicesWithStatus = devices.map((d) => ({ + ...d, + isOnline: d.lastSeenAt > threshold, + })); + + return { + content: [ + { + type: "text", + text: JSON.stringify({ devices: devicesWithStatus }, null, 2), + }, + ], + }; + }, + ); + + // ======================================== + // DEVICE TOOLS (Routed to Desktop) + // ======================================== + + server.tool( + "list_workspaces", + "List all workspaces/worktrees on a device", + { + deviceId: z + .string() + .optional() + .describe("Target device (defaults to caller's device)"), + }, + async (params) => { + const targetDeviceId = params.deviceId ?? ctx.defaultDeviceId; + + if (!targetDeviceId) { + return { + content: [ + { + type: "text", + text: "Error: No device specified and no default device configured", + }, + ], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId: targetDeviceId, + tool: "list_workspaces", + params: {}, + }); + }, + ); + + server.tool( + "list_projects", + "List all projects on a device", + { + deviceId: z + .string() + .optional() + .describe("Target device (defaults to caller's device)"), + }, + async (params) => { + const targetDeviceId = params.deviceId ?? ctx.defaultDeviceId; + + if (!targetDeviceId) { + return { + content: [ + { + type: "text", + text: "Error: No device specified and no default device configured", + }, + ], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId: targetDeviceId, + tool: "list_projects", + params: {}, + }); + }, + ); + + server.tool( + "get_app_context", + "Get the current app context including pathname and active workspace", + { + deviceId: z.string().optional(), + }, + async (params) => { + const targetDeviceId = params.deviceId ?? ctx.defaultDeviceId; + + if (!targetDeviceId) { + return { + content: [ + { + type: "text", + text: "Error: No device specified and no default device configured", + }, + ], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId: targetDeviceId, + tool: "get_app_context", + params: {}, + }); + }, + ); + + server.tool( + "navigate_to_workspace", + "Navigate the desktop app to a specific workspace", + { + deviceId: z.string().optional(), + workspaceId: z + .string() + .optional() + .describe("Workspace ID to navigate to"), + workspaceName: z + .string() + .optional() + .describe("Workspace name to navigate to"), + }, + async (params) => { + const targetDeviceId = params.deviceId ?? ctx.defaultDeviceId; + + if (!targetDeviceId) { + return { + content: [ + { + type: "text", + text: "Error: No device specified and no default device configured", + }, + ], + isError: true, + }; + } + + if (!params.workspaceId && !params.workspaceName) { + return { + content: [ + { + type: "text", + text: "Error: Either workspaceId or workspaceName must be provided", + }, + ], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId: targetDeviceId, + tool: "navigate_to_workspace", + params: { + workspaceId: params.workspaceId, + workspaceName: params.workspaceName, + }, + }); + }, + ); + + server.tool( + "create_workspace", + "Create a new git worktree workspace", + { + deviceId: z.string().optional(), + name: z + .string() + .optional() + .describe("Workspace name (auto-generated if not provided)"), + branchName: z + .string() + .optional() + .describe("Branch name (auto-generated if not provided)"), + baseBranch: z + .string() + .optional() + .describe("Branch to create from (defaults to main)"), + taskId: z + .string() + .optional() + .describe("Task ID to associate with workspace"), + }, + async (params) => { + const targetDeviceId = params.deviceId ?? ctx.defaultDeviceId; + + if (!targetDeviceId) { + return { + content: [ + { + type: "text", + text: "Error: No device specified and no default device configured", + }, + ], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId: targetDeviceId, + tool: "create_workspace", + params: { + name: params.name, + branchName: params.branchName, + baseBranch: params.baseBranch, + }, + }); + }, + ); + + server.tool( + "switch_workspace", + "Switch to a different workspace", + { + deviceId: z.string().optional(), + workspaceId: z + .string() + .uuid() + .optional() + .describe("Workspace ID to switch to"), + workspaceName: z + .string() + .optional() + .describe("Workspace name to switch to"), + }, + async (params) => { + const targetDeviceId = params.deviceId ?? ctx.defaultDeviceId; + + if (!targetDeviceId) { + return { + content: [ + { + type: "text", + text: "Error: No device specified and no default device configured", + }, + ], + isError: true, + }; + } + + if (!params.workspaceId && !params.workspaceName) { + return { + content: [ + { + type: "text", + text: "Error: Either workspaceId or workspaceName must be provided", + }, + ], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId: targetDeviceId, + tool: "switch_workspace", + params: { + workspaceId: params.workspaceId, + workspaceName: params.workspaceName, + }, + }); + }, + ); + + server.tool( + "delete_workspace", + "Delete a workspace", + { + deviceId: z.string().optional(), + workspaceId: z.string().uuid().describe("Workspace ID to delete"), + }, + async (params) => { + const targetDeviceId = params.deviceId ?? ctx.defaultDeviceId; + + if (!targetDeviceId) { + return { + content: [ + { + type: "text", + text: "Error: No device specified and no default device configured", + }, + ], + isError: true, + }; + } + + return executeOnDevice({ + ctx, + deviceId: targetDeviceId, + tool: "delete_workspace", + params: { + workspaceId: params.workspaceId, + }, + }); + }, + ); +} + +// ======================================== +// DEVICE COMMAND EXECUTION +// ======================================== + +const POLL_INTERVAL_MS = 500; +const DEFAULT_TIMEOUT_MS = 30_000; + +interface ExecuteOnDeviceParams { + ctx: McpContext; + deviceId: string; + tool: string; + params: Record; + timeout?: number; +} + +async function executeOnDevice({ + ctx, + deviceId, + tool, + params, + timeout = DEFAULT_TIMEOUT_MS, +}: ExecuteOnDeviceParams): Promise<{ + content: Array<{ type: "text"; text: string }>; + isError?: boolean; +}> { + const threshold = new Date(Date.now() - DEVICE_ONLINE_THRESHOLD_MS); + + // Check device is online + const [device] = await db + .select() + .from(devicePresence) + .where( + and( + eq(devicePresence.deviceId, deviceId), + eq(devicePresence.organizationId, ctx.organizationId), + gt(devicePresence.lastSeenAt, threshold), + ), + ) + .limit(1); + + if (!device) { + return { + content: [ + { + type: "text", + text: `Error: Device ${deviceId} is not online or not found in this organization`, + }, + ], + isError: true, + }; + } + + // Create command + const [cmd] = await db + .insert(agentCommands) + .values({ + userId: ctx.userId, + organizationId: ctx.organizationId, + targetDeviceId: deviceId, + targetDeviceType: device.deviceType, + tool, + params, + status: "pending", + timeoutAt: new Date(Date.now() + timeout), + }) + .returning(); + + if (!cmd) { + return { + content: [{ type: "text", text: "Error: Failed to create command" }], + isError: true, + }; + } + + // Poll for result + const startTime = Date.now(); + + while (Date.now() - startTime < timeout) { + const [updated] = await db + .select() + .from(agentCommands) + .where(eq(agentCommands.id, cmd.id)) + .limit(1); + + if (updated?.status === "completed") { + return { + content: [ + { + type: "text", + text: JSON.stringify(updated.result ?? { success: true }, null, 2), + }, + ], + }; + } + + if (updated?.status === "failed") { + return { + content: [ + { + type: "text", + text: `Error: ${updated.error ?? "Command failed"}`, + }, + ], + isError: true, + }; + } + + // Wait before next poll + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + // Mark as timeout + await db + .update(agentCommands) + .set({ status: "timeout" }) + .where(eq(agentCommands.id, cmd.id)); + + return { + content: [ + { + type: "text", + text: `Error: Command timed out after ${timeout}ms. The device may be offline or busy.`, + }, + ], + isError: true, + }; +} diff --git a/apps/desktop/VOICE_AGENT_PLAN.md b/apps/desktop/VOICE_AGENT_PLAN.md new file mode 100644 index 00000000000..8efb96f90ff --- /dev/null +++ b/apps/desktop/VOICE_AGENT_PLAN.md @@ -0,0 +1,336 @@ +# Voice Agent Implementation Plan (MVP) + +Push-to-talk voice interface for creating worktrees via natural language. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ RENDERER PROCESS │ +│ │ +│ Push-to-Talk ──► Audio Recorder ──► Voice Store ──► UI │ +│ Hotkey (Web Audio) (Zustand) │ +│ │ │ +│ │ base64 audio │ +│ ▼ │ +│ tRPC: voice.runAgent.subscribe() │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ MAIN PROCESS │ +│ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ voiceRouter.runAgent (tRPC subscription) │ │ +│ │ │ │ +│ │ 1. Transcribe audio (WisprFlow API) │ │ +│ │ 2. Stream to Claude Agent SDK with custom tools │ │ +│ │ 3. Forward messages to renderer │ │ +│ └──────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌──────────────────────────────────────────────────────────┐ │ +│ │ Custom Tools (called by Agent SDK) │ │ +│ │ - createWorktree({ name, branch?, baseBranch? }) │ │ +│ │ - listWorktrees({ projectId? }) │ │ +│ │ - getCurrentContext() │ │ +│ └──────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ + │ + ▼ (spawned by SDK) +┌─────────────────────────────────────────────────────────────────┐ +│ CLAUDE CODE CLI │ +│ Agent loop, tool execution, API calls │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Files to Create/Modify + +### 1. Hotkey (update existing) + +**File:** `apps/desktop/src/shared/hotkeys.ts` + +```typescript +// Add "Voice" to HotkeyCategory +// Add hotkey: +VOICE_PUSH_TO_TALK: defineHotkey({ + id: "VOICE_PUSH_TO_TALK", + name: "Push to Talk", + category: "Voice", + defaults: { + darwin: "meta+shift+space", + win32: "ctrl+shift+space", + linux: "ctrl+shift+space", + }, +}) +``` + +### 2. Audio Recorder (renderer) + +**New:** `apps/desktop/src/renderer/lib/audio-recorder.ts` + +```typescript +export class AudioRecorder { + private mediaRecorder: MediaRecorder | null = null; + private chunks: Blob[] = []; + + async start(): Promise { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + this.mediaRecorder = new MediaRecorder(stream); + this.chunks = []; + this.mediaRecorder.ondataavailable = (e) => this.chunks.push(e.data); + this.mediaRecorder.start(); + } + + async stop(): Promise { + return new Promise((resolve) => { + this.mediaRecorder!.onstop = async () => { + const blob = new Blob(this.chunks, { type: "audio/webm" }); + const wav = await convertToWav(blob); + const base64 = await blobToBase64(wav); + resolve(base64); + }; + this.mediaRecorder!.stop(); + }); + } +} + +// Convert to 16kHz WAV for WisprFlow +async function convertToWav(blob: Blob): Promise { /* ... */ } +async function blobToBase64(blob: Blob): Promise { /* ... */ } +``` + +### 3. Voice Store (renderer) + +**New:** `apps/desktop/src/renderer/stores/voice/store.ts` + +```typescript +interface VoiceState { + isRecording: boolean; + isProcessing: boolean; + messages: AgentMessage[]; // Streamed from agent + error: string | null; +} + +export const useVoiceStore = create((set) => ({ + isRecording: false, + isProcessing: false, + messages: [], + error: null, + + startRecording: () => set({ isRecording: true, messages: [], error: null }), + stopRecording: () => set({ isRecording: false }), + setProcessing: (v: boolean) => set({ isProcessing: v }), + addMessage: (msg: AgentMessage) => set((s) => ({ messages: [...s.messages, msg] })), + setError: (err: string) => set({ error: err, isProcessing: false }), +})); +``` + +### 4. Voice tRPC Router (main process) + +**New:** `apps/desktop/src/lib/trpc/routers/voice/index.ts` + +```typescript +import { query } from "@anthropic-ai/claude-agent-sdk"; +import { observable } from "@trpc/server/observable"; + +export const voiceRouter = router({ + runAgent: publicProcedure + .input(z.object({ audioBase64: z.string() })) + .subscription(({ input }) => { + return observable((emit) => { + const run = async () => { + // 1. Transcribe + const transcription = await transcribeAudio(input.audioBase64); + emit.next({ type: "transcription", text: transcription }); + + // 2. Run agent with custom tools + for await (const message of query({ + prompt: transcription, + options: { + systemPrompt: VOICE_AGENT_PROMPT, + tools: workspaceTools, // Custom tools defined below + } + })) { + emit.next({ type: "agent", message }); + } + }; + run().catch((err) => emit.error(err)); + }); + }), +}); + +// Custom tools for the agent +const workspaceTools = { + createWorktree: { + description: "Create a new worktree/workspace for a feature or bug fix", + parameters: z.object({ + name: z.string().describe("Human-readable name for the workspace"), + branch: z.string().optional().describe("Git branch name (auto-generated if omitted)"), + baseBranch: z.string().optional().describe("Branch to base off of (default: main)"), + }), + execute: async ({ name, branch, baseBranch }) => { + // Call existing workspace creation logic + const result = await createWorkspace({ name, branch, baseBranch }); + return { success: true, workspaceId: result.id, branch: result.branch }; + }, + }, + + listWorktrees: { + description: "List all current worktrees/workspaces", + parameters: z.object({}), + execute: async () => { + const workspaces = await getAllWorkspaces(); + return workspaces.map(w => ({ id: w.id, name: w.name, branch: w.branch })); + }, + }, + + getCurrentContext: { + description: "Get info about the currently active workspace", + parameters: z.object({}), + execute: async () => { + const current = await getActiveWorkspace(); + return current ? { id: current.id, name: current.name, branch: current.branch } : null; + }, + }, +}; + +const VOICE_AGENT_PROMPT = `You are a voice assistant for workspace management. +When the user asks to create a worktree/workspace, use the createWorktree tool. +Be concise. Confirm what you created.`; +``` + +### 5. WisprFlow Client (main process) + +**New:** `apps/desktop/src/main/lib/wisprflow.ts` + +```typescript +const WISPRFLOW_API = "https://api.wisprflow.ai/v1/transcribe"; + +export async function transcribeAudio(audioBase64: string): Promise { + const apiKey = await getStoredApiKey("wisprflow"); + + const response = await fetch(WISPRFLOW_API, { + method: "POST", + headers: { + "Authorization": `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ audio: audioBase64, format: "wav" }), + }); + + const result = await response.json(); + return result.text; +} +``` + +### 6. Voice UI (renderer) + +**New:** `apps/desktop/src/renderer/components/VoiceOverlay/VoiceOverlay.tsx` + +```typescript +export function VoiceOverlay() { + const { isRecording, isProcessing, messages } = useVoiceStore(); + + if (!isRecording && !isProcessing && messages.length === 0) return null; + + return ( +
+ {isRecording && ( +
+ + Listening... +
+ )} + + {messages.map((msg, i) => ( +
+ {msg.type === "transcription" &&

"{msg.text}"

} + {msg.type === "agent" && msg.message.result &&

{msg.message.result}

} +
+ ))} + + {isProcessing && } +
+ ); +} +``` + +### 7. Hook it up (renderer) + +**New:** `apps/desktop/src/renderer/hooks/useVoiceCommand.ts` + +```typescript +export function useVoiceCommand() { + const recorder = useRef(new AudioRecorder()); + const store = useVoiceStore(); + + const startRecording = async () => { + store.startRecording(); + await recorder.current.start(); + }; + + const stopRecording = async () => { + store.stopRecording(); + store.setProcessing(true); + + const audioBase64 = await recorder.current.stop(); + + // Subscribe to agent stream + electronTrpc.voice.runAgent.subscribe( + { audioBase64 }, + { + onData: (msg) => store.addMessage(msg), + onError: (err) => store.setError(err.message), + onComplete: () => store.setProcessing(false), + } + ); + }; + + // Bind to hotkey + useAppHotkey("VOICE_PUSH_TO_TALK", { + onKeyDown: startRecording, + onKeyUp: stopRecording, + }); +} +``` + +## File Structure + +``` +apps/desktop/src/ +├── main/lib/ +│ └── wisprflow.ts # NEW: Transcription API +├── renderer/ +│ ├── lib/audio-recorder.ts # NEW: Web Audio recording +│ ├── stores/voice/store.ts # NEW: Voice state +│ ├── hooks/useVoiceCommand.ts # NEW: Hotkey + recording logic +│ └── components/VoiceOverlay/ # NEW: UI +├── lib/trpc/routers/voice/ # NEW: tRPC router + tools +└── shared/hotkeys.ts # UPDATE: Add VOICE_PUSH_TO_TALK +``` + +## Dependencies + +```bash +bun add @anthropic-ai/claude-agent-sdk +``` + +## Implementation Order + +1. Add hotkey to `hotkeys.ts` +2. Create audio recorder +3. Create voice store +4. Create tRPC router with tools (main process) +5. Create WisprFlow client +6. Create UI overlay +7. Wire up with `useVoiceCommand` hook + +## Example Usage + +| Voice | Result | +|-------|--------| +| "Create a worktree for the login feature" | Creates workspace with branch `feature/login` | +| "Spin up a workspace for the auth bug" | Creates workspace with branch `fix/auth-bug` | +| "What worktrees do I have?" | Lists current workspaces | diff --git a/apps/desktop/plans/20260123-mcp-layout-tools.md b/apps/desktop/plans/20260123-mcp-layout-tools.md new file mode 100644 index 00000000000..c81fb9d8959 --- /dev/null +++ b/apps/desktop/plans/20260123-mcp-layout-tools.md @@ -0,0 +1,188 @@ +# MCP Layout Tools for Agent Screen Organization + +**Status:** Planned +**Date:** 2026-01-23 + +## Overview + +Enable AI agents to fully organize the desktop app's screen layout for users. The agent should be able to understand current state, create/arrange panes, and declaratively set layouts. + +## Layout Node Schema + +```typescript +type LayoutNode = + | { paneId: string } // Reference existing pane (preserves state) + | { newTerminal: true } + | { newFile: { path: string, line?: number } } + | { + split: "horizontal" | "vertical", + ratio?: number, // Default 50 + first: LayoutNode, + second: LayoutNode + } +``` + +## Tool Set + +| Tool | Purpose | +|------|---------| +| `get_layout_state` | Full state: tabs, panes, layout tree, focus | +| `set_tab_layout` | Declaratively set/modify a tab's layout | +| `create_tab` | New tab with optional initial layout | +| `close_tab` | Close tab | +| `set_active_tab` | Switch tabs | +| `focus_pane` | Set focused pane | + +## Example: Incremental Layout Changes + +```javascript +// Current state: single terminal "pane-abc" +// User: "I want to see the test file next to my terminal" + +set_tab_layout(tabId, { + split: "horizontal", + ratio: 60, + first: { paneId: "pane-abc" }, // Preserves existing terminal + its state + second: { newFile: { path: "src/Button.test.ts" } } +}) + +// Later: "Actually, add another terminal below the file" + +set_tab_layout(tabId, { + split: "horizontal", + ratio: 60, + first: { paneId: "pane-abc" }, + second: { + split: "vertical", + first: { paneId: "pane-xyz" }, // The file pane we just created + second: { newTerminal: true } + } +}) +``` + +## Example: Agent Sets Up PR Review Layout + +```javascript +// Agent thinks: "User wants to review a PR. I'll set up file viewer + terminal" + +set_tab_layout(activeTabId, { + split: "horizontal", + ratio: 60, + first: { newFile: { path: "src/components/Button.tsx" } }, + second: { newTerminal: true } +}) +``` + +## get_layout_state Response Shape + +```typescript +{ + workspaceId: string, + workspaceName: string, + tabs: [ + { + id: string, + name: string, + isActive: boolean, + layout: LayoutNode, // With paneId references resolved to include type/status + panes: [ + { + id: string, + type: "terminal" | "file-viewer", + status: "idle" | "working" | "review" | "permission", + // Terminal-specific + cwd?: string, + // File-viewer specific + filePath?: string, + } + ] + } + ], + focusedPaneId: string | null +} +``` + +## Implementation Plan + +### 1. Desktop: `tools/types.ts` - Extend ToolContext + +```typescript +interface ToolContext { + // ... existing + + // Tab operations + getLayoutState: () => LayoutState + setTabLayout: (tabId: string, layout: LayoutNode) => { createdPanes: string[] } + createTab: (workspaceId: string, layout?: LayoutNode) => { tabId: string } + closeTab: (tabId: string) => void + setActiveTab: (workspaceId: string, tabId: string) => void + focusPaneById: (paneId: string) => void +} +``` + +### 2. Desktop: `tools/layout-utils.ts` - Layout tree logic + +- `buildLayoutState()` - Serialize current state for agent +- `applyLayoutNode()` - Convert LayoutNode → MosaicNode, creating panes as needed +- `diffLayout()` - Find panes to remove (not referenced in new layout) + +### 3. Desktop: Tool files + +- `get-layout-state.ts` +- `set-tab-layout.ts` +- `create-tab.ts` +- `close-tab.ts` +- `set-active-tab.ts` +- `focus-pane.ts` + +### 4. Desktop: `useCommandWatcher.ts` - Wire up context + +- Import `useTabsStore` +- Provide the new methods to ToolContext + +### 5. API: `apps/api/src/lib/mcp/tools.ts` - MCP tool definitions + +Add 6 new `server.tool()` calls that route to desktop via `executeOnDevice()`: + +```typescript +server.tool( + "get_layout_state", + "Get the current layout state including tabs, panes, and focus", + { deviceId: z.string().optional() }, + async (params) => executeOnDevice({ ctx, deviceId, tool: "get_layout_state", params: {} }) +); + +server.tool( + "set_tab_layout", + "Declaratively set or modify a tab's pane layout", + { + deviceId: z.string().optional(), + tabId: z.string(), + layout: LayoutNodeSchema, // Zod schema matching LayoutNode type + }, + async (params) => executeOnDevice({ ctx, deviceId, tool: "set_tab_layout", params }) +); + +// ... etc for create_tab, close_tab, set_active_tab, focus_pane +``` + +## Key Design Decisions + +1. **Incremental by default** - Referencing `{ paneId: "..." }` preserves existing pane state (terminal history, file scroll position) + +2. **Panes not referenced get cleaned up** - When `set_tab_layout` is called, any panes in the old layout but not in the new layout are closed + +3. **Auto-focus on creation** - New panes should auto-focus unless specified otherwise + +4. **Ratio defaults to 50** - Splits are even by default + +## Files to Clean Up + +The following partial implementation files were created but should be deleted: + +- `apps/desktop/src/renderer/hooks/useCommandWatcher/tools/add-tab.ts` +- `apps/desktop/src/renderer/hooks/useCommandWatcher/tools/add-pane.ts` +- `apps/desktop/src/renderer/hooks/useCommandWatcher/tools/list-tabs.ts` +- `apps/desktop/src/renderer/hooks/useCommandWatcher/tools/set-active-tab.ts` +- `apps/desktop/src/renderer/hooks/useCommandWatcher/tools/remove-tab.ts` +- `apps/desktop/src/renderer/hooks/useCommandWatcher/tools/remove-pane.ts` diff --git a/apps/desktop/src/lib/trpc/routers/auth/index.ts b/apps/desktop/src/lib/trpc/routers/auth/index.ts index 75b1b77fe33..666f2a0e9f3 100644 --- a/apps/desktop/src/lib/trpc/routers/auth/index.ts +++ b/apps/desktop/src/lib/trpc/routers/auth/index.ts @@ -4,6 +4,7 @@ import { AUTH_PROVIDERS } from "@superset/shared/constants"; import { observable } from "@trpc/server/observable"; import { shell } from "electron"; import { env } from "main/env.main"; +import { getDeviceName, getHashedDeviceId } from "main/lib/device-info"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { @@ -94,6 +95,11 @@ export const createAuthRouter = () => { authEvents.emit("token-cleared"); return { success: true }; }), + + getDeviceInfo: publicProcedure.query(() => ({ + deviceId: getHashedDeviceId(), + deviceName: getDeviceName(), + })), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/auth/utils/crypto-storage.ts b/apps/desktop/src/lib/trpc/routers/auth/utils/crypto-storage.ts index faeba16f76b..7276a7f30fc 100644 --- a/apps/desktop/src/lib/trpc/routers/auth/utils/crypto-storage.ts +++ b/apps/desktop/src/lib/trpc/routers/auth/utils/crypto-storage.ts @@ -1,12 +1,10 @@ -import { execFileSync } from "node:child_process"; import { createCipheriv, createDecipheriv, randomBytes, scryptSync, } from "node:crypto"; -import { readFileSync } from "node:fs"; -import { homedir, hostname, platform } from "node:os"; +import { getMachineId } from "main/lib/device-info"; const ALGORITHM = "aes-256-gcm"; const KEY_LENGTH = 32; @@ -14,59 +12,8 @@ const SALT_LENGTH = 16; const IV_LENGTH = 12; const AUTH_TAG_LENGTH = 16; -/** - * Gets a stable machine identifier for key derivation. - */ -function getMachineId(): string { - try { - const os = platform(); - - if (os === "darwin") { - // macOS: Use IOPlatformUUID (hardware UUID) - const output = execFileSync( - "ioreg", - ["-rd1", "-c", "IOPlatformExpertDevice"], - { encoding: "utf8" }, - ); - const match = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/); - if (match?.[1]) return match[1]; - } else if (os === "linux") { - // Linux: Use machine-id - try { - return readFileSync("/etc/machine-id", "utf8").trim(); - } catch { - return readFileSync("/var/lib/dbus/machine-id", "utf8").trim(); - } - } else if (os === "win32") { - // Windows: Use MachineGuid from registry - const output = execFileSync( - "reg", - [ - "query", - "HKLM\\SOFTWARE\\Microsoft\\Cryptography", - "/v", - "MachineGuid", - ], - { encoding: "utf8" }, - ); - const match = output.match(/MachineGuid\s+REG_SZ\s+(\S+)/); - if (match?.[1]) return match[1]; - } - } catch { - // Fallback if platform-specific method fails - } - - // Fallback: Use a combination of stable system properties - // This is less secure but ensures the app still works - return `${hostname()}-${homedir()}-superset-fallback`; -} - -/** - * Derives an encryption key from the machine ID and a salt. - */ function deriveKey(salt: Buffer): Buffer { - const machineId = getMachineId(); - return scryptSync(machineId, salt, KEY_LENGTH); + return scryptSync(getMachineId(), salt, KEY_LENGTH); } /** diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts index b5a3aea6bd9..01f74ab0350 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/procedures/status.ts @@ -3,7 +3,11 @@ import { and, eq, isNull } from "drizzle-orm"; import { localDb } from "main/lib/local-db"; import { z } from "zod"; import { publicProcedure, router } from "../../.."; -import { getWorkspaceNotDeleting, touchWorkspace } from "../utils/db-helpers"; +import { + getWorkspaceNotDeleting, + setLastActiveWorkspace, + touchWorkspace, +} from "../utils/db-helpers"; export const createStatusProcedures = () => { return router({ @@ -95,5 +99,20 @@ export const createStatusProcedures = () => { return { success: true, isUnread: input.isUnread }; }), + + setActive: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .mutation(({ input }) => { + const workspace = getWorkspaceNotDeleting(input.workspaceId); + if (!workspace) { + throw new Error( + `Workspace ${input.workspaceId} not found or is being deleted`, + ); + } + + setLastActiveWorkspace(input.workspaceId); + + return { success: true, workspaceId: input.workspaceId }; + }), }); }; diff --git a/apps/desktop/src/main/lib/device-info.ts b/apps/desktop/src/main/lib/device-info.ts new file mode 100644 index 00000000000..abd369c4479 --- /dev/null +++ b/apps/desktop/src/main/lib/device-info.ts @@ -0,0 +1,96 @@ +import { execFileSync } from "node:child_process"; +import { createHmac } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { homedir, hostname, platform } from "node:os"; + +const APP_DEVICE_SALT = "superset-desktop-device-id-v1"; + +function getRawMachineId(): string { + try { + const os = platform(); + + if (os === "darwin") { + const output = execFileSync( + "ioreg", + ["-rd1", "-c", "IOPlatformExpertDevice"], + { encoding: "utf8" }, + ); + const match = output.match(/"IOPlatformUUID"\s*=\s*"([^"]+)"/); + if (match?.[1]) return match[1]; + } else if (os === "linux") { + try { + return readFileSync("/etc/machine-id", "utf8").trim(); + } catch { + return readFileSync("/var/lib/dbus/machine-id", "utf8").trim(); + } + } else if (os === "win32") { + const output = execFileSync( + "reg", + [ + "query", + "HKLM\\SOFTWARE\\Microsoft\\Cryptography", + "/v", + "MachineGuid", + ], + { encoding: "utf8" }, + ); + const match = output.match(/MachineGuid\s+REG_SZ\s+(\S+)/); + if (match?.[1]) return match[1]; + } + } catch { + // Fallback if platform-specific method fails + } + + return `${hostname()}-${homedir()}-superset-fallback`; +} + +let cachedMachineId: string | null = null; + +/** + * Raw machine ID for local encryption key derivation. + * Do NOT send this to the cloud - use getHashedDeviceId() instead. + */ +export function getMachineId(): string { + if (!cachedMachineId) { + cachedMachineId = getRawMachineId(); + } + return cachedMachineId; +} + +let cachedHashedId: string | null = null; + +/** + * Hashed device ID safe for cloud transmission. + * Non-reversible, stable, and app-specific. + */ +export function getHashedDeviceId(): string { + if (!cachedHashedId) { + const machineId = getMachineId(); + cachedHashedId = createHmac("sha256", APP_DEVICE_SALT) + .update(machineId) + .digest("hex") + .slice(0, 32); + } + return cachedHashedId; +} + +/** + * Sanitized device name for cloud transmission. + * Returns generic identifier instead of potentially PII-containing hostname. + */ +export function getDeviceName(): string { + const os = platform(); + const osName = os === "darwin" ? "Mac" : os === "win32" ? "Windows" : "Linux"; + const rawHostname = hostname(); + + // Use just the first segment if it looks like a local hostname + // e.g., "johns-macbook-pro.local" -> "johns-macbook-pro" + const shortName = rawHostname.split(".")[0] || rawHostname; + + // If hostname looks generic or is very short, use OS name + if (shortName.length < 3 || shortName === "localhost") { + return `${osName} Desktop`; + } + + return shortName; +} diff --git a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts index c8b920db958..92cf57031e2 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useCreateWorkspace.ts @@ -4,6 +4,15 @@ import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/u import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; import type { WorkspaceInitProgress } from "shared/types/workspace-init"; +type MutationOptions = Parameters< + typeof electronTrpc.workspaces.create.useMutation +>[0]; + +interface UseCreateWorkspaceOptions extends NonNullable { + /** Skip auto-navigation to new workspace (useful for MCP/agent commands) */ + skipNavigation?: boolean; +} + /** * Mutation hook for creating a new workspace * Automatically invalidates all workspace queries on success @@ -18,9 +27,7 @@ import type { WorkspaceInitProgress } from "shared/types/workspace-init"; * Note: Terminal creation is handled by WorkspaceInitEffects (always mounted in MainScreen) * to survive dialog unmounts. This hook just adds to the global pending store. */ -export function useCreateWorkspace( - options?: Parameters[0], -) { +export function useCreateWorkspace(options?: UseCreateWorkspaceOptions) { const navigate = useNavigate(); const utils = electronTrpc.useUtils(); const addPendingTerminalSetup = useWorkspaceInitStore( @@ -59,10 +66,12 @@ export function useCreateWorkspace( // WorkspaceInitEffects will process it on next render when it sees the progress // is already "ready" and there's a matching pending setup. - // Navigate to the new workspace immediately + // Navigate to the new workspace immediately (unless skipNavigation is set) // The workspace exists in DB, so it's safe to navigate // Git operations happen in background with progress shown via toast - navigateToWorkspace(data.workspace.id, navigate); + if (!options?.skipNavigation) { + navigateToWorkspace(data.workspace.id, navigate); + } // Call user's onSuccess if provided await options?.onSuccess?.(data, ...rest); diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/AgentHooks.tsx b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/AgentHooks.tsx new file mode 100644 index 00000000000..a95eb503316 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/AgentHooks.tsx @@ -0,0 +1,12 @@ +import { useCommandWatcher } from "./hooks/useCommandWatcher"; +import { useDevicePresence } from "./hooks/useDevicePresence"; + +/** + * Component that runs agent-related hooks requiring CollectionsProvider context. + * useCommandWatcher uses useCollections which must be inside the provider. + */ +export function AgentHooks() { + useDevicePresence(); + useCommandWatcher(); + return null; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/index.ts new file mode 100644 index 00000000000..7ef83abb594 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/index.ts @@ -0,0 +1 @@ +export { useCommandWatcher } from "./useCommandWatcher"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/create-worktree.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/create-worktree.ts new file mode 100644 index 00000000000..6a7fead8074 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/create-worktree.ts @@ -0,0 +1,69 @@ +import { z } from "zod"; +import type { CommandResult, ToolContext, ToolDefinition } from "./types"; + +const schema = z.object({ + name: z.string().optional(), + branchName: z.string().optional(), + baseBranch: z.string().optional(), +}); + +async function execute( + params: z.infer, + ctx: ToolContext, +): Promise { + // Derive projectId from current workspace or use the only available project + const workspaces = ctx.getWorkspaces(); + if (!workspaces || workspaces.length === 0) { + return { success: false, error: "No workspaces available" }; + } + + // Try to get from current workspace first + let projectId: string | null = null; + const activeWorkspaceId = ctx.getActiveWorkspaceId(); + if (activeWorkspaceId) { + const activeWorkspace = workspaces.find( + (ws) => ws.id === activeWorkspaceId, + ); + if (activeWorkspace) { + projectId = activeWorkspace.projectId; + } + } + + // Fall back to the most recently used workspace's project + if (!projectId) { + const sorted = [...workspaces].sort( + (a, b) => (b.lastOpenedAt ?? 0) - (a.lastOpenedAt ?? 0), + ); + projectId = sorted[0].projectId; + } + + try { + const result = await ctx.createWorktree.mutateAsync({ + projectId, + name: params.name, + branchName: params.branchName, + baseBranch: params.baseBranch, + }); + + return { + success: true, + data: { + workspaceId: result.workspace.id, + workspaceName: result.workspace.name, + branch: result.workspace.branch, + }, + }; + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to create workspace", + }; + } +} + +export const createWorkspace: ToolDefinition = { + name: "create_workspace", + schema, + execute, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/delete-workspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/delete-workspace.ts new file mode 100644 index 00000000000..faaeb8b77a2 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/delete-workspace.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import type { CommandResult, ToolContext, ToolDefinition } from "./types"; + +const schema = z.object({ + workspaceId: z.string(), +}); + +async function execute( + params: z.infer, + ctx: ToolContext, +): Promise { + try { + const result = await ctx.deleteWorkspace.mutateAsync({ + id: params.workspaceId, + }); + + if (!result.success) { + return { success: false, error: result.error ?? "Delete failed" }; + } + + return { success: true, data: { workspaceId: params.workspaceId } }; + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to delete workspace", + }; + } +} + +export const deleteWorkspace: ToolDefinition = { + name: "delete_workspace", + schema, + execute, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/get-app-context.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/get-app-context.ts new file mode 100644 index 00000000000..6366606f782 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/get-app-context.ts @@ -0,0 +1,40 @@ +import { z } from "zod"; +import type { CommandResult, ToolContext, ToolDefinition } from "./types"; + +const schema = z.object({}); + +async function execute( + _params: z.infer, + ctx: ToolContext, +): Promise { + // Hash routing: path is in window.location.hash (e.g., "#/workspace/abc123") + const hash = window.location.hash; + const pathname = hash.startsWith("#") ? hash.slice(1) : hash; + + // Parse workspace ID from route if present (route is /workspace/$workspaceId) + const workspaceMatch = pathname.match(/\/workspace\/([^/]+)/); + const currentWorkspaceId = workspaceMatch ? workspaceMatch[1] : null; + + // Get workspace details if we have an ID + let currentWorkspace = null; + if (currentWorkspaceId) { + const workspaces = ctx.getWorkspaces(); + currentWorkspace = + workspaces?.find((ws) => ws.id === currentWorkspaceId) ?? null; + } + + return { + success: true, + data: { + pathname, + currentWorkspaceId, + currentWorkspace, + }, + }; +} + +export const getAppContext: ToolDefinition = { + name: "get_app_context", + schema, + execute, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/index.ts new file mode 100644 index 00000000000..ad5eec98c42 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/index.ts @@ -0,0 +1,52 @@ +import { createWorkspace } from "./create-worktree"; +import { deleteWorkspace } from "./delete-workspace"; +import { getAppContext } from "./get-app-context"; +import { listProjects } from "./list-projects"; +import { listWorkspaces } from "./list-workspaces"; +import { navigateToWorkspace } from "./navigate-to-workspace"; +import { switchWorkspace } from "./switch-workspace"; +import type { CommandResult, ToolContext, ToolDefinition } from "./types"; + +// Registry of all available tools +// biome-ignore lint/suspicious/noExplicitAny: Tool schemas vary +const tools: ToolDefinition[] = [ + createWorkspace, + deleteWorkspace, + getAppContext, + listProjects, + listWorkspaces, + navigateToWorkspace, + switchWorkspace, +]; + +// Map for O(1) lookup by name +const toolsByName = new Map(tools.map((t) => [t.name, t])); + +/** + * Execute a tool by name with validation. + * Returns error if tool not found or params invalid. + */ +export async function executeTool( + name: string, + params: Record | null, + ctx: ToolContext, +): Promise { + const tool = toolsByName.get(name); + + if (!tool) { + return { success: false, error: `Unknown tool: ${name}` }; + } + + // Validate params + const parsed = tool.schema.safeParse(params ?? {}); + if (!parsed.success) { + return { + success: false, + error: `Invalid params: ${parsed.error.errors.map((e: { message: string }) => e.message).join(", ")}`, + }; + } + + return tool.execute(parsed.data, ctx); +} + +export type { CommandResult, ToolContext } from "./types"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/list-projects.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/list-projects.ts new file mode 100644 index 00000000000..5e04117be60 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/list-projects.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import type { CommandResult, ToolContext, ToolDefinition } from "./types"; + +const schema = z.object({}); + +async function execute( + _params: z.infer, + ctx: ToolContext, +): Promise { + const projects = ctx.getProjects(); + + if (!projects) { + return { success: false, error: "Projects not available" }; + } + + return { + success: true, + data: { + projects: projects.map((p) => ({ + id: p.id, + name: p.name, + mainRepoPath: p.mainRepoPath, + defaultBranch: p.defaultBranch, + color: p.color, + lastOpenedAt: p.lastOpenedAt, + tabOrder: p.tabOrder, + })), + }, + }; +} + +export const listProjects: ToolDefinition = { + name: "list_projects", + schema, + execute, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/list-workspaces.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/list-workspaces.ts new file mode 100644 index 00000000000..73d6d827b8b --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/list-workspaces.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import type { CommandResult, ToolContext, ToolDefinition } from "./types"; + +const schema = z.object({}); + +async function execute( + _params: z.infer, + ctx: ToolContext, +): Promise { + const workspaces = ctx.getWorkspaces(); + + if (!workspaces) { + return { success: false, error: "Failed to get workspaces" }; + } + + return { + success: true, + data: { workspaces: workspaces as unknown as Record[] }, + }; +} + +export const listWorkspaces: ToolDefinition = { + name: "list_workspaces", + schema, + execute, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/navigate-to-workspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/navigate-to-workspace.ts new file mode 100644 index 00000000000..0e23ebe081f --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/navigate-to-workspace.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; +import type { CommandResult, ToolContext, ToolDefinition } from "./types"; + +const schema = z + .object({ + workspaceId: z.string().optional(), + workspaceName: z.string().optional(), + }) + .refine((data) => data.workspaceId || data.workspaceName, { + message: "Must provide workspaceId or workspaceName", + }); + +async function execute( + params: z.infer, + ctx: ToolContext, +): Promise { + let targetWorkspaceId = params.workspaceId; + + // Look up by name if no ID provided + if (!targetWorkspaceId && params.workspaceName) { + const workspaces = ctx.getWorkspaces(); + if (!workspaces) { + return { success: false, error: "Failed to get workspaces" }; + } + + const searchName = params.workspaceName.toLowerCase(); + const found = workspaces.find( + (ws) => + ws.name.toLowerCase() === searchName || + ws.branch.toLowerCase() === searchName, + ); + + if (!found) { + return { + success: false, + error: `Workspace "${params.workspaceName}" not found`, + }; + } + targetWorkspaceId = found.id; + } + + if (!targetWorkspaceId) { + return { + success: false, + error: "Could not determine workspace to navigate to", + }; + } + + try { + await ctx.navigateToWorkspace(targetWorkspaceId); + return { + success: true, + data: { workspaceId: targetWorkspaceId }, + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : "Failed to navigate", + }; + } +} + +export const navigateToWorkspace: ToolDefinition = { + name: "navigate_to_workspace", + schema, + execute, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/switch-workspace.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/switch-workspace.ts new file mode 100644 index 00000000000..841dd110093 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/switch-workspace.ts @@ -0,0 +1,65 @@ +import { z } from "zod"; +import type { CommandResult, ToolContext, ToolDefinition } from "./types"; + +const schema = z + .object({ + workspaceId: z.string().optional(), + workspaceName: z.string().optional(), + }) + .refine((data) => data.workspaceId || data.workspaceName, { + message: "Must provide workspaceId or workspaceName", + }); + +async function execute( + params: z.infer, + ctx: ToolContext, +): Promise { + let targetWorkspaceId = params.workspaceId; + + // Lookup workspace by name if no ID provided + if (!targetWorkspaceId && params.workspaceName) { + const workspaces = ctx.getWorkspaces(); + if (!workspaces) { + return { success: false, error: "Failed to get workspaces" }; + } + + const searchName = params.workspaceName.toLowerCase(); + const found = workspaces.find( + (ws) => + ws.name.toLowerCase() === searchName || + ws.branch.toLowerCase() === searchName, + ); + + if (!found) { + return { + success: false, + error: `Workspace "${params.workspaceName}" not found`, + }; + } + targetWorkspaceId = found.id; + } + + if (!targetWorkspaceId) { + return { + success: false, + error: "Could not determine workspace to switch to", + }; + } + + try { + await ctx.setActive.mutateAsync({ workspaceId: targetWorkspaceId }); + return { success: true, data: { workspaceId: targetWorkspaceId } }; + } catch (error) { + return { + success: false, + error: + error instanceof Error ? error.message : "Failed to switch workspace", + }; + } +} + +export const switchWorkspace: ToolDefinition = { + name: "switch_workspace", + schema, + execute, +}; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/types.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/types.ts new file mode 100644 index 00000000000..5bccfd63f23 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/tools/types.ts @@ -0,0 +1,33 @@ +import type { SelectProject, SelectWorkspace } from "@superset/local-db"; +import type { electronTrpc } from "renderer/lib/electron-trpc"; +import type { z } from "zod"; + +export interface CommandResult { + success: boolean; + data?: Record; + error?: string; +} + +// Available mutations and queries passed to tool handlers +export interface ToolContext { + // Mutations + createWorktree: ReturnType; + setActive: ReturnType; + deleteWorkspace: ReturnType< + typeof electronTrpc.workspaces.delete.useMutation + >; + // Query helpers + refetchWorkspaces: () => Promise; + getWorkspaces: () => SelectWorkspace[] | undefined; + getProjects: () => SelectProject[] | undefined; + getActiveWorkspaceId: () => string | null; + // Navigation + navigateToWorkspace: (workspaceId: string) => Promise; +} + +// Tool definition with schema and execute function +export interface ToolDefinition { + name: string; + schema: T; + execute: (params: z.infer, ctx: ToolContext) => Promise; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/useCommandWatcher.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/useCommandWatcher.ts new file mode 100644 index 00000000000..1e16ddcc42e --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useCommandWatcher/useCommandWatcher.ts @@ -0,0 +1,185 @@ +import { FEATURE_FLAGS } from "@superset/shared/constants"; +import { eq } from "@tanstack/db"; +import { useLiveQuery } from "@tanstack/react-db"; +import { useNavigate } from "@tanstack/react-router"; +import { useFeatureFlagEnabled } from "posthog-js/react"; +import { useCallback, useEffect, useMemo } from "react"; +import { authClient } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { useCreateWorkspace } from "renderer/react-query/workspaces/useCreateWorkspace"; +import { useDeleteWorkspace } from "renderer/react-query/workspaces/useDeleteWorkspace"; +import { navigateToWorkspace } from "renderer/routes/_authenticated/_dashboard/utils/workspace-navigation"; +import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider/CollectionsProvider"; +import { executeTool, type ToolContext } from "./tools"; + +const processingCommands = new Set(); + +export function useCommandWatcher() { + const isEnabled = useFeatureFlagEnabled(FEATURE_FLAGS.AGENT_COMMANDS_ACCESS); + const { data: deviceInfo } = electronTrpc.auth.getDeviceInfo.useQuery(); + const { data: session } = authClient.useSession(); + const collections = useCollections(); + const navigate = useNavigate(); + + const organizationId = session?.session?.activeOrganizationId; + const shouldWatch = isEnabled && !!deviceInfo && !!organizationId; + + const createWorktree = useCreateWorkspace({ skipNavigation: true }); + const setActive = electronTrpc.workspaces.setActive.useMutation(); + const deleteWorkspace = useDeleteWorkspace(); + + const { data: workspaces, refetch: refetchWorkspaces } = + electronTrpc.workspaces.getAll.useQuery(); + const { data: projects } = electronTrpc.projects.getRecents.useQuery(); + + const getCurrentWorkspaceIdFromRoute = useCallback(() => { + const hash = window.location.hash; + const pathname = hash.startsWith("#") ? hash.slice(1) : hash; + const match = pathname.match(/\/workspace\/([^/]+)/); + return match ? match[1] : null; + }, []); + + const toolContext: ToolContext = useMemo( + () => ({ + createWorktree, + setActive, + deleteWorkspace, + refetchWorkspaces: async () => refetchWorkspaces(), + getWorkspaces: () => workspaces, + getProjects: () => projects, + getActiveWorkspaceId: getCurrentWorkspaceIdFromRoute, + navigateToWorkspace: (workspaceId: string) => + navigateToWorkspace(workspaceId, navigate), + }), + [ + createWorktree, + setActive, + deleteWorkspace, + refetchWorkspaces, + workspaces, + projects, + getCurrentWorkspaceIdFromRoute, + navigate, + ], + ); + + const { data: pendingCommands } = useLiveQuery( + (q) => + q + .from({ commands: collections.agentCommands }) + .where(({ commands }) => eq(commands.status, "pending")) + .select(({ commands }) => ({ ...commands })), + [collections.agentCommands], + ); + + const processCommand = useCallback( + async ( + commandId: string, + tool: string, + params: Record | null, + ) => { + if (processingCommands.has(commandId)) return; + + processingCommands.add(commandId); + console.log(`[command-watcher] Processing: ${commandId} (${tool})`); + + try { + collections.agentCommands.update(commandId, (draft) => { + draft.status = "claimed"; + draft.claimedBy = deviceInfo?.deviceId ?? null; + draft.claimedAt = new Date(); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + collections.agentCommands.update(commandId, (draft) => { + draft.status = "executing"; + }); + + const result = await executeTool(tool, params, toolContext); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + if (result.success) { + collections.agentCommands.update(commandId, (draft) => { + draft.status = "completed"; + draft.result = result.data ?? {}; + draft.executedAt = new Date(); + }); + } else { + collections.agentCommands.update(commandId, (draft) => { + draft.status = "failed"; + draft.error = result.error ?? "Unknown error"; + draft.executedAt = new Date(); + }); + console.error(`[command-watcher] Failed: ${commandId}`, result.error); + } + } catch (error) { + console.error(`[command-watcher] Error: ${commandId}`, error); + collections.agentCommands.update(commandId, (draft) => { + draft.status = "failed"; + draft.error = + error instanceof Error ? error.message : "Execution error"; + draft.executedAt = new Date(); + }); + } finally { + processingCommands.delete(commandId); + } + }, + [collections.agentCommands, deviceInfo?.deviceId, toolContext], + ); + + useEffect(() => { + if ( + !shouldWatch || + !deviceInfo?.deviceId || + !pendingCommands || + !organizationId + ) { + return; + } + + const now = new Date(); + const commandsForThisDevice = pendingCommands.filter((cmd) => { + if (cmd.targetDeviceId !== deviceInfo.deviceId) return false; + if (processingCommands.has(cmd.id)) return false; + + // Security: verify org matches (don't trust Electric filtering alone) + if (cmd.organizationId !== organizationId) { + console.warn(`[command-watcher] Org mismatch for ${cmd.id}`); + return false; + } + + if (cmd.timeoutAt && new Date(cmd.timeoutAt) < now) { + collections.agentCommands.update(cmd.id, (draft) => { + draft.status = "timeout"; + draft.error = "Command expired before execution"; + }); + return false; + } + + return true; + }); + + for (const cmd of commandsForThisDevice) { + processCommand(cmd.id, cmd.tool, cmd.params); + } + }, [ + shouldWatch, + deviceInfo?.deviceId, + organizationId, + pendingCommands, + processCommand, + collections.agentCommands, + ]); + + return { + isEnabled, + isWatching: shouldWatch && !!deviceInfo?.deviceId, + deviceId: deviceInfo?.deviceId, + pendingCount: + pendingCommands?.filter( + (cmd) => cmd.targetDeviceId === deviceInfo?.deviceId, + ).length ?? 0, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/index.ts new file mode 100644 index 00000000000..8d1954762ce --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/index.ts @@ -0,0 +1 @@ +export { useDevicePresence } from "./useDevicePresence"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/useDevicePresence.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/useDevicePresence.ts new file mode 100644 index 00000000000..ae7a5933f10 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/hooks/useDevicePresence/useDevicePresence.ts @@ -0,0 +1,45 @@ +import { useCallback, useEffect, useRef } from "react"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { authClient } from "renderer/lib/auth-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; + +const HEARTBEAT_INTERVAL_MS = 30_000; + +export function useDevicePresence() { + const { data: session } = authClient.useSession(); + const intervalRef = useRef | null>(null); + const { data: deviceInfo } = electronTrpc.auth.getDeviceInfo.useQuery(); + + const sendHeartbeat = useCallback(async () => { + if (!deviceInfo || !session?.session?.activeOrganizationId) return; + + try { + await apiTrpcClient.device.heartbeat.mutate({ + deviceId: deviceInfo.deviceId, + deviceName: deviceInfo.deviceName, + deviceType: "desktop", + }); + } catch { + // Heartbeat can fail when offline - ignore + } + }, [deviceInfo, session?.session?.activeOrganizationId]); + + useEffect(() => { + if (!deviceInfo || !session?.session?.activeOrganizationId) return; + + sendHeartbeat(); + intervalRef.current = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [deviceInfo, session?.session?.activeOrganizationId, sendHeartbeat]); + + return { + deviceInfo, + isActive: !!deviceInfo && !!session?.session?.activeOrganizationId, + }; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/index.ts b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/index.ts new file mode 100644 index 00000000000..529a3e3b3b7 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/components/AgentHooks/index.ts @@ -0,0 +1 @@ +export { AgentHooks } from "./AgentHooks"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx index b4052a29850..146b5b6de52 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/layout.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/layout.tsx @@ -17,6 +17,7 @@ import { useHotkeysSync } from "renderer/stores/hotkeys"; import { useAgentHookListener } from "renderer/stores/tabs/useAgentHookListener"; import { useWorkspaceInitStore } from "renderer/stores/workspace-init"; import { MOCK_ORG_ID } from "shared/constants"; +import { AgentHooks } from "./components/AgentHooks"; import { CollectionsProvider } from "./providers/CollectionsProvider"; export const Route = createFileRoute("/_authenticated")({ @@ -32,7 +33,7 @@ function AuthenticatedLayout() { const navigate = useNavigate(); const utils = electronTrpc.useUtils(); - // Global hooks and subscriptions + // Global hooks and subscriptions (these don't need CollectionsProvider) useAgentHookListener(); useUpdateListener(); useHotkeysSync(); @@ -76,6 +77,7 @@ function AuthenticatedLayout() { return ( + diff --git a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts index ff2e1f40db6..babf4ce4f5a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/providers/CollectionsProvider/collections.ts @@ -1,5 +1,7 @@ import { snakeCamelMapper } from "@electric-sql/client"; import type { + SelectAgentCommand, + SelectDevicePresence, SelectInvitation, SelectMember, SelectOrganization, @@ -27,6 +29,8 @@ interface OrgCollections { members: Collection; users: Collection; invitations: Collection; + agentCommands: Collection; + devicePresence: Collection; } // Per-org collections cache @@ -196,7 +200,64 @@ function createOrgCollections(organizationId: string): OrgCollections { }), ); - return { tasks, taskStatuses, repositories, members, users, invitations }; + const agentCommands = createCollection( + electricCollectionOptions({ + id: `agent_commands-${organizationId}`, + shapeOptions: { + url: electricUrl, + params: { + table: "agent_commands", + organizationId, + }, + headers, + columnMapper, + }, + getKey: (item) => item.id, + onUpdate: async ({ transaction }) => { + const { original, changes } = transaction.mutations[0]; + if (!changes.status) { + return { txid: Date.now() }; + } + const result = await apiClient.agent.updateCommand.mutate({ + id: original.id, + status: changes.status, + claimedBy: changes.claimedBy ?? undefined, + claimedAt: changes.claimedAt ?? undefined, + result: changes.result ?? undefined, + error: changes.error ?? undefined, + executedAt: changes.executedAt ?? undefined, + }); + return { txid: Number(result.txid) }; + }, + }), + ); + + const devicePresence = createCollection( + electricCollectionOptions({ + id: `device_presence-${organizationId}`, + shapeOptions: { + url: electricUrl, + params: { + table: "device_presence", + organizationId, + }, + headers, + columnMapper, + }, + getKey: (item) => item.id, + }), + ); + + return { + tasks, + taskStatuses, + repositories, + members, + users, + invitations, + agentCommands, + devicePresence, + }; } /** diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/components/ApiKeysSettings/ApiKeysSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/components/ApiKeysSettings/ApiKeysSettings.tsx new file mode 100644 index 00000000000..d2c5aa39cc6 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/components/ApiKeysSettings/ApiKeysSettings.tsx @@ -0,0 +1,323 @@ +import { Badge } from "@superset/ui/badge"; +import { Button } from "@superset/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@superset/ui/dialog"; +import { Input } from "@superset/ui/input"; +import { Label } from "@superset/ui/label"; +import { Skeleton } from "@superset/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@superset/ui/table"; +import { useCallback, useEffect, useState } from "react"; +import { + HiOutlineClipboardDocument, + HiOutlineKey, + HiOutlinePlus, + HiOutlineTrash, +} from "react-icons/hi2"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; +import { electronTrpc } from "renderer/lib/electron-trpc"; +import { + isItemVisible, + SETTING_ITEM_ID, + type SettingItemId, +} from "../../../utils/settings-search"; + +interface ApiKeysSettingsProps { + visibleItems?: SettingItemId[] | null; +} + +interface ApiKey { + id: string; + name: string; + keyPrefix: string; + defaultDeviceId: string | null; + lastUsedAt: Date | null; + usageCount: string; + createdAt: Date; + expiresAt: Date | null; +} + +export function ApiKeysSettings({ visibleItems }: ApiKeysSettingsProps) { + const [apiKeys, setApiKeys] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isGenerating, setIsGenerating] = useState(false); + const [showGenerateDialog, setShowGenerateDialog] = useState(false); + const [showNewKeyDialog, setShowNewKeyDialog] = useState(false); + const [newKeyName, setNewKeyName] = useState(""); + const [newKeyValue, setNewKeyValue] = useState(""); + const [copied, setCopied] = useState(false); + + const { data: deviceInfo } = electronTrpc.auth.getDeviceInfo.useQuery(); + + const showApiKeysList = isItemVisible( + SETTING_ITEM_ID.API_KEYS_LIST, + visibleItems, + ); + const showGenerateButton = isItemVisible( + SETTING_ITEM_ID.API_KEYS_GENERATE, + visibleItems, + ); + + const loadApiKeys = useCallback(async () => { + try { + setIsLoading(true); + const keys = await apiTrpcClient.apiKeys.list.query(); + setApiKeys(keys); + } catch (error) { + console.error("[api-keys] Failed to load API keys:", error); + } finally { + setIsLoading(false); + } + }, []); + + useEffect(() => { + loadApiKeys(); + }, [loadApiKeys]); + + const handleGenerateKey = async () => { + if (!newKeyName.trim()) return; + + try { + setIsGenerating(true); + const result = await apiTrpcClient.apiKeys.generate.mutate({ + name: newKeyName.trim(), + defaultDeviceId: deviceInfo?.deviceId, + }); + setNewKeyValue(result.key); + setShowGenerateDialog(false); + setShowNewKeyDialog(true); + setNewKeyName(""); + loadApiKeys(); + } catch (error) { + console.error("[api-keys] Failed to generate API key:", error); + } finally { + setIsGenerating(false); + } + }; + + const handleRevokeKey = async (id: string) => { + try { + await apiTrpcClient.apiKeys.revoke.mutate({ id }); + loadApiKeys(); + } catch (error) { + console.error("[api-keys] Failed to revoke API key:", error); + } + }; + + const handleCopyKey = () => { + navigator.clipboard.writeText(newKeyValue); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const formatDate = (date: Date | string | null) => { + if (!date) return "Never"; + const d = date instanceof Date ? date : new Date(date); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }; + + return ( +
+
+
+

API Keys

+

+ Manage API keys for MCP server access and external integrations +

+
+
+ +
+
+ {showGenerateButton && ( +
+ +
+ )} + +
+

Your API Keys

+

+ API keys allow external applications like Claude Desktop or Claude + Code to interact with Superset on your behalf. +

+ + {showApiKeysList && + (isLoading ? ( +
+ {[1, 2, 3].map((i) => ( +
+ +
+ + +
+ +
+ ))} +
+ ) : apiKeys.length === 0 ? ( +
+ +

No API keys yet

+

+ Generate a key to use with MCP servers +

+
+ ) : ( +
+ + + + Name + Key + Created + Last Used + + + + + {apiKeys.map((key) => ( + + +
+ + {key.name} +
+
+ + + {key.keyPrefix} + + + + {formatDate(key.createdAt)} + + + {formatDate(key.lastUsedAt)} + + + + +
+ ))} +
+
+
+ ))} +
+
+
+ + {/* Generate Key Dialog */} + + + + Generate API Key + + Create a new API key for external integrations like Claude Desktop + or Claude Code. + + +
+
+ + setNewKeyName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleGenerateKey(); + }} + /> +

+ Give your key a descriptive name to remember where it's used. +

+
+
+ + + + +
+
+ + {/* New Key Display Dialog */} + + + + API Key Generated + + Copy your API key now. You won't be able to see it again! + + +
+
+ + +
+ {copied && ( +

Copied to clipboard!

+ )} +
+

+ Make sure to copy this key now. For security reasons, it will + not be displayed again. +

+
+
+ + + +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/components/ApiKeysSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/components/ApiKeysSettings/index.ts new file mode 100644 index 00000000000..f222e417b00 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/components/ApiKeysSettings/index.ts @@ -0,0 +1 @@ +export { ApiKeysSettings } from "./ApiKeysSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/page.tsx new file mode 100644 index 00000000000..f043499f53a --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/api-keys/page.tsx @@ -0,0 +1,22 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useSettingsSearchQuery } from "renderer/stores/settings-state"; +import { getMatchingItemsForSection } from "../utils/settings-search"; +import { ApiKeysSettings } from "./components/ApiKeysSettings"; + +export const Route = createFileRoute("/_authenticated/settings/api-keys/")({ + component: ApiKeysSettingsPage, +}); + +function ApiKeysSettingsPage() { + const searchQuery = useSettingsSearchQuery(); + + const visibleItems = useMemo(() => { + if (!searchQuery) return null; + return getMatchingItemsForSection(searchQuery, "apikeys").map( + (item) => item.id, + ); + }, [searchQuery]); + + return ; +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx index 62da88c347a..370d3a16926 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/components/SettingsSidebar/GeneralSettings.tsx @@ -7,6 +7,8 @@ import { HiOutlineBuildingOffice2, HiOutlineCommandLine, HiOutlineCreditCard, + HiOutlineDevicePhoneMobile, + HiOutlineKey, HiOutlinePaintBrush, HiOutlinePuzzlePiece, HiOutlineSparkles, @@ -28,13 +30,16 @@ type SettingsRoute = | "/settings/behavior" | "/settings/terminal" | "/settings/integrations" - | "/settings/billing"; + | "/settings/billing" + | "/settings/devices" + | "/settings/api-keys"; const GENERAL_SECTIONS: { id: SettingsRoute; section: SettingsSection; label: string; icon: React.ReactNode; + requiresAgentCommands?: boolean; }[] = [ { id: "/settings/account", @@ -90,19 +95,42 @@ const GENERAL_SECTIONS: { label: "Billing", icon: , }, + { + id: "/settings/devices", + section: "devices", + label: "Devices", + icon: , + requiresAgentCommands: true, + }, + { + id: "/settings/api-keys", + section: "apikeys", + label: "API Keys", + icon: , + requiresAgentCommands: true, + }, ]; export function GeneralSettings({ matchCounts }: GeneralSettingsProps) { const matchRoute = useMatchRoute(); const billingEnabled = useFeatureFlagEnabled(FEATURE_FLAGS.BILLING_ENABLED); + const hasAgentCommandsAccess = useFeatureFlagEnabled( + FEATURE_FLAGS.AGENT_COMMANDS_ACCESS, + ); + + // Filter by feature flags first, then by search matches + const availableSections = GENERAL_SECTIONS.filter((section) => { + if (section.section === "billing" && !billingEnabled) return false; + if (section.requiresAgentCommands && !hasAgentCommandsAccess) return false; + return true; + }); - const filteredSections = ( - matchCounts - ? GENERAL_SECTIONS.filter( - (section) => (matchCounts[section.section] ?? 0) > 0, - ) - : GENERAL_SECTIONS - ).filter((section) => section.section !== "billing" || billingEnabled); + // When searching, only show sections that have matches + const filteredSections = matchCounts + ? availableSections.filter( + (section) => (matchCounts[section.section] ?? 0) > 0, + ) + : availableSections; if (filteredSections.length === 0) { return null; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/DevicesSettings.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/DevicesSettings.tsx new file mode 100644 index 00000000000..2a567fb69ea --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/DevicesSettings.tsx @@ -0,0 +1,120 @@ +import { useCallback, useEffect, useState } from "react"; +import { + HiOutlineComputerDesktop, + HiOutlineDevicePhoneMobile, + HiOutlineGlobeAlt, +} from "react-icons/hi2"; +import { apiTrpcClient } from "renderer/lib/api-trpc-client"; + +interface OnlineDevice { + id: string; + deviceId: string; + deviceName: string; + deviceType: "desktop" | "mobile" | "web"; + lastSeenAt: Date; + ownerId: string; + ownerName: string; + ownerEmail: string; +} + +const DEVICE_ICONS = { + desktop: HiOutlineComputerDesktop, + mobile: HiOutlineDevicePhoneMobile, + web: HiOutlineGlobeAlt, +}; + +export function DevicesSettings() { + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchDevices = useCallback(async () => { + try { + setLoading(true); + setError(null); + const result = await apiTrpcClient.device.listOnlineDevices.query(); + setDevices(result); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch devices"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchDevices(); + // Refresh every 10 seconds + const interval = setInterval(fetchDevices, 10_000); + return () => clearInterval(interval); + }, [fetchDevices]); + + const formatLastSeen = (date: Date) => { + const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + return new Date(date).toLocaleTimeString(); + }; + + return ( +
+
+

Online Devices

+

+ Devices currently connected to your account. Refreshes every 10 + seconds. +

+
+ + {loading && devices.length === 0 && ( +
Loading...
+ )} + + {error && ( +
+ {error} +
+ )} + + {!loading && devices.length === 0 && !error && ( +
No devices online
+ )} + +
+ {devices.map((device) => { + const Icon = + DEVICE_ICONS[device.deviceType] || HiOutlineComputerDesktop; + return ( +
+
+ +
+
+
{device.deviceName}
+
+ {device.ownerName} · {device.deviceType} ·{" "} + {formatLastSeen(device.lastSeenAt)} +
+
+
+
+ Online +
+
+ ); + })} +
+ + +
+ ); +} diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/index.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/index.ts new file mode 100644 index 00000000000..c7656e000c9 --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/devices/components/DevicesSettings/index.ts @@ -0,0 +1 @@ +export { DevicesSettings } from "./DevicesSettings"; diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/devices/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/settings/devices/page.tsx new file mode 100644 index 00000000000..76781244faa --- /dev/null +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/devices/page.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { DevicesSettings } from "./components/DevicesSettings"; + +export const Route = createFileRoute("/_authenticated/settings/devices/")({ + component: DevicesSettings, +}); diff --git a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts index 34c9e35be49..99e6fde6943 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/settings/utils/settings-search/settings-search.ts @@ -43,6 +43,10 @@ export const SETTING_ITEM_ID = { WORKSPACE_NAME: "workspace-name", WORKSPACE_BRANCH: "workspace-branch", WORKSPACE_PATH: "workspace-path", + + // API Keys + API_KEYS_LIST: "api-keys-list", + API_KEYS_GENERATE: "api-keys-generate", } as const; export type SettingItemId = @@ -567,6 +571,42 @@ export const SETTINGS_ITEMS: SettingsItem[] = [ description: "The file path to this workspace", keywords: ["workspace", "path", "folder", "directory", "location", "root"], }, + + // API Keys + { + id: SETTING_ITEM_ID.API_KEYS_LIST, + section: "apikeys", + title: "API Keys", + description: "Manage API keys for MCP server access", + keywords: [ + "api", + "key", + "keys", + "mcp", + "claude", + "integration", + "external", + "access", + "token", + "authentication", + ], + }, + { + id: SETTING_ITEM_ID.API_KEYS_GENERATE, + section: "apikeys", + title: "Generate API Key", + description: "Create new API keys for external integrations", + keywords: [ + "api", + "key", + "generate", + "create", + "new", + "mcp", + "claude desktop", + "claude code", + ], + }, ]; export function searchSettings(query: string): SettingsItem[] { diff --git a/apps/desktop/src/renderer/stores/settings-state.ts b/apps/desktop/src/renderer/stores/settings-state.ts index 349c1db7940..5106f7ce9f2 100644 --- a/apps/desktop/src/renderer/stores/settings-state.ts +++ b/apps/desktop/src/renderer/stores/settings-state.ts @@ -15,6 +15,8 @@ export type SettingsSection = | "terminal" | "integrations" | "billing" + | "devices" + | "apikeys" | "project" | "workspace"; diff --git a/apps/mobile/app/(authenticated)/_layout.tsx b/apps/mobile/app/(authenticated)/_layout.tsx index 9bab929335a..30806acfeb3 100644 --- a/apps/mobile/app/(authenticated)/_layout.tsx +++ b/apps/mobile/app/(authenticated)/_layout.tsx @@ -1,7 +1,10 @@ import { Stack } from "expo-router"; +import { useDevicePresence } from "@/hooks/useDevicePresence"; import { CollectionsProvider } from "@/providers/CollectionsProvider"; export default function AuthenticatedLayout() { + useDevicePresence(); + return ( diff --git a/apps/mobile/hooks/useDevicePresence/index.ts b/apps/mobile/hooks/useDevicePresence/index.ts new file mode 100644 index 00000000000..8d1954762ce --- /dev/null +++ b/apps/mobile/hooks/useDevicePresence/index.ts @@ -0,0 +1 @@ +export { useDevicePresence } from "./useDevicePresence"; diff --git a/apps/mobile/hooks/useDevicePresence/useDevicePresence.ts b/apps/mobile/hooks/useDevicePresence/useDevicePresence.ts new file mode 100644 index 00000000000..2eb3b7d5b4e --- /dev/null +++ b/apps/mobile/hooks/useDevicePresence/useDevicePresence.ts @@ -0,0 +1,69 @@ +import Constants from "expo-constants"; +import { randomUUID } from "expo-crypto"; +import * as SecureStore from "expo-secure-store"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { Platform } from "react-native"; +import { useSession } from "@/lib/auth/client"; +import { apiClient } from "@/lib/trpc/client"; + +const HEARTBEAT_INTERVAL_MS = 30_000; +const DEVICE_ID_KEY = "superset-device-id"; + +async function getOrCreateDeviceId(): Promise { + const existingId = await SecureStore.getItemAsync(DEVICE_ID_KEY).catch( + () => null, + ); + if (existingId) return existingId; + + const newId = randomUUID(); + await SecureStore.setItemAsync(DEVICE_ID_KEY, newId).catch(() => {}); + return newId; +} + +export function useDevicePresence() { + const { data: session } = useSession(); + const [deviceId, setDeviceId] = useState(null); + const intervalRef = useRef | null>(null); + const activeOrganizationId = session?.session?.activeOrganizationId; + + useEffect(() => { + getOrCreateDeviceId().then(setDeviceId); + }, []); + + const sendHeartbeat = useCallback(async () => { + if (!deviceId || !activeOrganizationId) { + return; + } + + try { + await apiClient.device.heartbeat.mutate({ + deviceId, + deviceName: + Constants.deviceName ?? + (Platform.OS === "ios" ? "iPhone" : "Android"), + deviceType: "mobile", + }); + } catch { + // Heartbeat can fail when offline - ignore + } + }, [deviceId, activeOrganizationId]); + + useEffect(() => { + if (!deviceId || !activeOrganizationId) return; + + sendHeartbeat(); + intervalRef.current = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [deviceId, activeOrganizationId, sendHeartbeat]); + + return { + deviceId, + isActive: !!deviceId && !!activeOrganizationId, + }; +} diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 25a7761be33..3ea2308de16 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -32,6 +32,7 @@ "dotenv": "^17.2.3", "expo": "~54.0.31", "expo-constants": "^18.0.13", + "expo-crypto": "^15.0.8", "expo-dev-client": "~6.0.20", "expo-linking": "~8.0.11", "expo-network": "~8.0.8", diff --git a/apps/mobile/screens/(authenticated)/index/HomeScreen.tsx b/apps/mobile/screens/(authenticated)/index/HomeScreen.tsx index 37e0d0e421e..5e6cb6682c0 100644 --- a/apps/mobile/screens/(authenticated)/index/HomeScreen.tsx +++ b/apps/mobile/screens/(authenticated)/index/HomeScreen.tsx @@ -1,5 +1,5 @@ import { useRouter } from "expo-router"; -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { ScrollView, View } from "react-native"; import { Button } from "@/components/ui/button"; import { @@ -14,11 +14,51 @@ import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { Text } from "@/components/ui/text"; import { signOut } from "@/lib/auth/client"; +import { apiClient } from "@/lib/trpc/client"; + +interface OnlineDevice { + id: string; + deviceId: string; + deviceName: string; + deviceType: "desktop" | "mobile" | "web"; + lastSeenAt: Date; + ownerId: string; + ownerName: string; + ownerEmail: string; +} export function HomeScreen() { const router = useRouter(); const [switchValue, setSwitchValue] = useState(false); const [inputValue, setInputValue] = useState(""); + const [devices, setDevices] = useState([]); + const [devicesLoading, setDevicesLoading] = useState(true); + + const fetchDevices = useCallback(async () => { + try { + setDevicesLoading(true); + const result = await apiClient.device.listOnlineDevices.query(); + setDevices(result); + } catch (err) { + console.warn("[devices] Failed to fetch:", err); + } finally { + setDevicesLoading(false); + } + }, []); + + useEffect(() => { + fetchDevices(); + const interval = setInterval(fetchDevices, 10_000); + return () => clearInterval(interval); + }, [fetchDevices]); + + const formatLastSeen = (date: Date) => { + const seconds = Math.floor((Date.now() - new Date(date).getTime()) / 1000); + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + return new Date(date).toLocaleTimeString(); + }; const handleSignOut = async () => { await signOut(); @@ -57,6 +97,47 @@ export function HomeScreen() { + {/* Online Devices */} + + + Online Devices + + Devices connected to your account (refreshes every 10s) + + + + {devicesLoading && devices.length === 0 && ( + Loading... + )} + {!devicesLoading && devices.length === 0 && ( + No devices online + )} + {devices.map((device) => ( + + + {device.deviceName} + + {device.ownerName} · {device.deviceType} ·{" "} + {formatLastSeen(device.lastSeenAt)} + + + + + Online + + + ))} + + + + + + {/* Typography Section */} diff --git a/bun.lock b/bun.lock index 1185ec23097..b2d50e81267 100644 --- a/bun.lock +++ b/bun.lock @@ -60,6 +60,7 @@ "dependencies": { "@electric-sql/client": "https://pkg.pr.new/@electric-sql/client@3724", "@linear/sdk": "^68.1.0", + "@modelcontextprotocol/sdk": "^1.25.3", "@octokit/app": "^16.1.2", "@octokit/rest": "^22.0.1", "@octokit/webhooks": "^14.2.0", @@ -361,6 +362,7 @@ "dotenv": "^17.2.3", "expo": "~54.0.31", "expo-constants": "^18.0.13", + "expo-crypto": "^15.0.8", "expo-dev-client": "~6.0.20", "expo-linking": "~8.0.11", "expo-network": "~8.0.8", @@ -550,6 +552,7 @@ "name": "@superset/trpc", "version": "0.1.0", "dependencies": { + "@anthropic-ai/sdk": "^0.71.2", "@linear/sdk": "^68.1.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", @@ -666,6 +669,8 @@ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], + "@apm-js-collab/code-transformer": ["@apm-js-collab/code-transformer@0.8.2", "", {}, "sha512-YRjJjNq5KFSjDUoqu5pFUWrrsvGOxl6c3bu+uMFc9HNNptZ2rNU/TI2nLw4jnhQNtka972Ee2m3uqbvDQtPeCA=="], "@apm-js-collab/tracing-hooks": ["@apm-js-collab/tracing-hooks@0.3.1", "", { "dependencies": { "@apm-js-collab/code-transformer": "^0.8.0", "debug": "^4.4.1", "module-details-from-path": "^1.0.4" } }, "sha512-Vu1CbmPURlN5fTboVuKMoJjbO5qcq9fA5YXpskx3dXe/zTBvjODFoerw+69rVBlRLrJpwPqSDqEuJDEKIrTldw=="], @@ -1074,6 +1079,8 @@ "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + "@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], + "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], @@ -1186,6 +1193,8 @@ "@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], + "@monaco-editor/loader": ["@monaco-editor/loader@1.7.0", "", { "dependencies": { "state-local": "^1.0.6" } }, "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA=="], "@monaco-editor/react": ["@monaco-editor/react@4.7.0", "", { "dependencies": { "@monaco-editor/loader": "^1.5.0" }, "peerDependencies": { "monaco-editor": ">= 0.25.0 < 1", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA=="], @@ -2812,6 +2821,8 @@ "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], "exec-async": ["exec-async@2.2.0", "", {}, "sha512-87OpwcEiMia/DeiKFzaQNBNFeN3XkkpYIh9FyOqq5mS2oKv3CBE67PXoEKcr6nodWdXNogTiQ0jE2NGuoffXPw=="], @@ -2826,6 +2837,8 @@ "expo-constants": ["expo-constants@18.0.13", "", { "dependencies": { "@expo/config": "~12.0.13", "@expo/env": "~2.0.8" }, "peerDependencies": { "expo": "*", "react-native": "*" } }, "sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ=="], + "expo-crypto": ["expo-crypto@15.0.8", "", { "dependencies": { "base64-js": "^1.3.0" }, "peerDependencies": { "expo": "*" } }, "sha512-aF7A914TB66WIlTJvl5J6/itejfY78O7dq3ibvFltL9vnTALJ/7LYHvLT4fwmx9yUNS6ekLBtDGWivFWnj2Fcw=="], + "expo-dev-client": ["expo-dev-client@6.0.20", "", { "dependencies": { "expo-dev-launcher": "6.0.20", "expo-dev-menu": "7.0.18", "expo-dev-menu-interface": "2.0.0", "expo-manifests": "~1.0.10", "expo-updates-interface": "~2.0.0" }, "peerDependencies": { "expo": "*" } }, "sha512-5XjoVlj1OxakNxy55j/AUaGPrDOlQlB6XdHLLWAw61w5ffSpUDHDnuZzKzs9xY1eIaogOqTOQaAzZ2ddBkdXLA=="], "expo-dev-launcher": ["expo-dev-launcher@6.0.20", "", { "dependencies": { "ajv": "^8.11.0", "expo-dev-menu": "7.0.18", "expo-manifests": "~1.0.10" }, "peerDependencies": { "expo": "*" } }, "sha512-a04zHEeT9sB0L5EB38fz7sNnUKJ2Ar1pXpcyl60Ki8bXPNCs9rjY7NuYrDkP/irM8+1DklMBqHpyHiLyJ/R+EA=="], @@ -2870,6 +2883,8 @@ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="], + "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], @@ -3090,6 +3105,8 @@ "hoist-non-react-statics": ["hoist-non-react-statics@3.3.2", "", { "dependencies": { "react-is": "^16.7.0" } }, "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw=="], + "hono": ["hono@4.11.5", "", {}, "sha512-WemPi9/WfyMwZs+ZUXdiwcCh9Y+m7L+8vki9MzDw3jJ+W9Lc+12HGsd368Qc1vZi1xwW8BWMMsnK5efYKPdt4g=="], + "hosted-git-info": ["hosted-git-info@5.2.1", "", { "dependencies": { "lru-cache": "^7.5.1" } }, "sha512-xIcQYMnhcx2Nr4JTjsFmwwnr9vldugPy9uVm0o87bjqqWMv9GaqsTeT+i99wTl0mk1uLxJtHxLb8kymqTENQsw=="], "html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="], @@ -3116,7 +3133,7 @@ "iconv-corefoundation": ["iconv-corefoundation@1.1.7", "", { "dependencies": { "cli-truncate": "^2.1.0", "node-addon-api": "^1.6.3" }, "os": "darwin" }, "sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ=="], - "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "idb": ["idb@8.0.3", "", {}, "sha512-LtwtVyVYO5BqRvcsKuB2iUMnHwPVByPCXFXOpuU96IZPPoPN6xjOGxZQ74pgSVVLQWtUOYgyeL4GE98BY5D3wg=="], @@ -3282,6 +3299,8 @@ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -3804,6 +3823,8 @@ "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "pkg-conf": ["pkg-conf@4.0.0", "", { "dependencies": { "find-up": "^6.0.0", "load-json-file": "^7.0.0" } }, "sha512-7dmgi4UY4qk+4mj5Cd8v/GExPo0K+SlY+hulOSdfZ/T6jVH6//y7NtzZo5WrfhDBxuQ0jCa7fLZmNaNh7EWL/w=="], "pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="], @@ -4460,6 +4481,8 @@ "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "^1.0.1" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], @@ -4696,6 +4719,8 @@ "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + "zustand": ["zustand@5.0.10", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-U1AiltS1O9hSy3rul+Ub82ut2fqIAefiSuwECWt6jlMVUGejvf+5omLcRBSzqbRagSM3hQZbtzdeRc6QVScXTg=="], "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], @@ -5004,8 +5029,6 @@ "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], - "body-parser/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], "cacache/fs-minipass": ["fs-minipass@3.0.3", "", { "dependencies": { "minipass": "^7.0.3" } }, "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw=="], @@ -5058,6 +5081,8 @@ "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="], + "d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="], "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], @@ -5068,6 +5093,8 @@ "dir-compare/minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + "dmg-builder/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "dmg-license/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], "dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -5084,6 +5111,8 @@ "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], + "encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + "engine.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "engine.io/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], @@ -5278,8 +5307,6 @@ "randombytes/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], - "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], - "react-arborist/react-dnd": ["react-dnd@14.0.5", "", { "dependencies": { "@react-dnd/invariant": "^2.0.0", "@react-dnd/shallowequal": "^2.0.0", "dnd-core": "14.0.1", "fast-deep-equal": "^3.1.3", "hoist-non-react-statics": "^3.3.2" }, "peerDependencies": { "@types/hoist-non-react-statics": ">= 3.3.1", "@types/node": ">= 12", "@types/react": ">= 16", "react": ">= 16.14" }, "optionalPeers": ["@types/hoist-non-react-statics", "@types/node", "@types/react"] }, "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A=="], "react-arborist/react-dnd-html5-backend": ["react-dnd-html5-backend@14.1.0", "", { "dependencies": { "dnd-core": "14.0.1" } }, "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw=="], diff --git a/packages/db/drizzle/0013_add_mcp_actions_and_presence.sql b/packages/db/drizzle/0013_add_mcp_actions_and_presence.sql new file mode 100644 index 00000000000..60507c23018 --- /dev/null +++ b/packages/db/drizzle/0013_add_mcp_actions_and_presence.sql @@ -0,0 +1,61 @@ +CREATE TYPE "public"."command_status" AS ENUM('pending', 'claimed', 'executing', 'completed', 'failed', 'timeout');--> statement-breakpoint +CREATE TYPE "public"."device_type" AS ENUM('desktop', 'mobile', 'web');--> statement-breakpoint +CREATE TABLE "agent_commands" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "organization_id" uuid NOT NULL, + "target_device_id" text, + "target_device_type" text, + "tool" text NOT NULL, + "params" jsonb, + "parent_command_id" uuid, + "status" "command_status" DEFAULT 'pending' NOT NULL, + "claimed_by" text, + "claimed_at" timestamp, + "result" jsonb, + "error" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "executed_at" timestamp, + "timeout_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "api_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "organization_id" uuid NOT NULL, + "name" text NOT NULL, + "key_prefix" text NOT NULL, + "key_hash" text NOT NULL, + "default_device_id" text, + "last_used_at" timestamp, + "usage_count" text DEFAULT '0' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp, + "revoked_at" timestamp +); +--> statement-breakpoint +CREATE TABLE "device_presence" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "organization_id" uuid NOT NULL, + "device_id" text NOT NULL, + "device_name" text NOT NULL, + "device_type" "device_type" NOT NULL, + "last_seen_at" timestamp DEFAULT now() NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "agent_commands" ADD CONSTRAINT "agent_commands_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "agent_commands" ADD CONSTRAINT "agent_commands_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "device_presence" ADD CONSTRAINT "device_presence_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "device_presence" ADD CONSTRAINT "device_presence_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "auth"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "agent_commands_user_status_idx" ON "agent_commands" USING btree ("user_id","status");--> statement-breakpoint +CREATE INDEX "agent_commands_target_device_status_idx" ON "agent_commands" USING btree ("target_device_id","status");--> statement-breakpoint +CREATE INDEX "agent_commands_org_created_idx" ON "agent_commands" USING btree ("organization_id","created_at");--> statement-breakpoint +CREATE INDEX "api_keys_user_org_idx" ON "api_keys" USING btree ("user_id","organization_id");--> statement-breakpoint +CREATE INDEX "api_keys_key_hash_idx" ON "api_keys" USING btree ("key_hash");--> statement-breakpoint +CREATE INDEX "device_presence_user_org_idx" ON "device_presence" USING btree ("user_id","organization_id");--> statement-breakpoint +CREATE UNIQUE INDEX "device_presence_user_device_idx" ON "device_presence" USING btree ("user_id","device_id");--> statement-breakpoint +CREATE INDEX "device_presence_last_seen_idx" ON "device_presence" USING btree ("last_seen_at"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0013_snapshot.json b/packages/db/drizzle/meta/0013_snapshot.json new file mode 100644 index 00000000000..e536c483a4a --- /dev/null +++ b/packages/db/drizzle/meta/0013_snapshot.json @@ -0,0 +1,2903 @@ +{ + "id": "72ae3e85-0ad3-42de-908d-cf2f12c8b194", + "prevId": "e2df2abc-157a-4a39-ab7f-06906df6cbdf", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.accounts": { + "name": "accounts", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "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 + } + }, + "indexes": { + "accounts_user_id_idx": { + "name": "accounts_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.invitations": { + "name": "invitations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "inviter_id": { + "name": "inviter_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "invitations_organization_id_idx": { + "name": "invitations_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitations_email_idx": { + "name": "invitations_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitations_organization_id_organizations_id_fk": { + "name": "invitations_organization_id_organizations_id_fk", + "tableFrom": "invitations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitations_inviter_id_users_id_fk": { + "name": "invitations_inviter_id_users_id_fk", + "tableFrom": "invitations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.members": { + "name": "members", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "members_organization_id_idx": { + "name": "members_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "members_user_id_idx": { + "name": "members_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "members_organization_id_organizations_id_fk": { + "name": "members_organization_id_organizations_id_fk", + "tableFrom": "members", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.organizations": { + "name": "organizations", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organizations_slug_idx": { + "name": "organizations_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.sessions": { + "name": "sessions", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "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 + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sessions_user_id_idx": { + "name": "sessions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.users": { + "name": "users", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "organization_ids": { + "name": "organization_ids", + "type": "uuid[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "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": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verifications": { + "name": "verifications", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_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": { + "verifications_identifier_idx": { + "name": "verifications_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_installations": { + "name": "github_installations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "installation_id": { + "name": "installation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_login": { + "name": "account_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_type": { + "name": "account_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permissions": { + "name": "permissions", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "suspended": { + "name": "suspended", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_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": { + "github_installations_installation_id_idx": { + "name": "github_installations_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_installations_organization_id_organizations_id_fk": { + "name": "github_installations_organization_id_organizations_id_fk", + "tableFrom": "github_installations", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "github_installations_connected_by_user_id_users_id_fk": { + "name": "github_installations_connected_by_user_id_users_id_fk", + "tableFrom": "github_installations", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_installations_installation_id_unique": { + "name": "github_installations_installation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "installation_id" + ] + }, + "github_installations_org_unique": { + "name": "github_installations_org_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_pull_requests": { + "name": "github_pull_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "node_id": { + "name": "node_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_login": { + "name": "author_login", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_avatar_url": { + "name": "author_avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_draft": { + "name": "is_draft", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "additions": { + "name": "additions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deletions": { + "name": "deletions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "changed_files": { + "name": "changed_files", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "checks": { + "name": "checks", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_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": { + "github_pull_requests_repository_id_idx": { + "name": "github_pull_requests_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_state_idx": { + "name": "github_pull_requests_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_pull_requests_head_branch_idx": { + "name": "github_pull_requests_head_branch_idx", + "columns": [ + { + "expression": "head_branch", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_pull_requests_repository_id_github_repositories_id_fk": { + "name": "github_pull_requests_repository_id_github_repositories_id_fk", + "tableFrom": "github_pull_requests", + "tableTo": "github_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_pull_requests_repo_pr_unique": { + "name": "github_pull_requests_repo_pr_unique", + "nullsNotDistinct": false, + "columns": [ + "repository_id", + "pr_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.github_repositories": { + "name": "github_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "installation_id": { + "name": "installation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repo_id": { + "name": "repo_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "is_private": { + "name": "is_private", + "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": { + "github_repositories_installation_id_idx": { + "name": "github_repositories_installation_id_idx", + "columns": [ + { + "expression": "installation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "github_repositories_full_name_idx": { + "name": "github_repositories_full_name_idx", + "columns": [ + { + "expression": "full_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "github_repositories_installation_id_github_installations_id_fk": { + "name": "github_repositories_installation_id_github_installations_id_fk", + "tableFrom": "github_repositories", + "tableTo": "github_installations", + "columnsFrom": [ + "installation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "github_repositories_repo_id_unique": { + "name": "github_repositories_repo_id_unique", + "nullsNotDistinct": false, + "columns": [ + "repo_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "ingest.webhook_events": { + "name": "webhook_events", + "schema": "ingest", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "retry_count": { + "name": "retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "received_at": { + "name": "received_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "webhook_events_provider_status_idx": { + "name": "webhook_events_provider_status_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_provider_event_id_idx": { + "name": "webhook_events_provider_event_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "event_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_events_received_at_idx": { + "name": "webhook_events_received_at_idx", + "columns": [ + { + "expression": "received_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_commands": { + "name": "agent_commands", + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_device_id": { + "name": "target_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_device_type": { + "name": "target_device_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tool": { + "name": "tool", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "params": { + "name": "params", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "parent_command_id": { + "name": "parent_command_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "command_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "executed_at": { + "name": "executed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "timeout_at": { + "name": "timeout_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_commands_user_status_idx": { + "name": "agent_commands_user_status_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_target_device_status_idx": { + "name": "agent_commands_target_device_status_idx", + "columns": [ + { + "expression": "target_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_commands_org_created_idx": { + "name": "agent_commands_org_created_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_commands_user_id_users_id_fk": { + "name": "agent_commands_user_id_users_id_fk", + "tableFrom": "agent_commands", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_commands_organization_id_organizations_id_fk": { + "name": "agent_commands_organization_id_organizations_id_fk", + "tableFrom": "agent_commands", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_device_id": { + "name": "default_device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "usage_count": { + "name": "usage_count", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_keys_user_org_idx": { + "name": "api_keys_user_org_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_keys_key_hash_idx": { + "name": "api_keys_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_keys_organization_id_organizations_id_fk": { + "name": "api_keys_organization_id_organizations_id_fk", + "tableFrom": "api_keys", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_presence": { + "name": "device_presence", + "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 + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "device_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_presence_user_org_idx": { + "name": "device_presence_user_org_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_user_device_idx": { + "name": "device_presence_user_device_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_presence_last_seen_idx": { + "name": "device_presence_last_seen_idx", + "columns": [ + { + "expression": "last_seen_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_presence_user_id_users_id_fk": { + "name": "device_presence_user_id_users_id_fk", + "tableFrom": "device_presence", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "device_presence_organization_id_organizations_id_fk": { + "name": "device_presence_organization_id_organizations_id_fk", + "tableFrom": "device_presence", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration_connections": { + "name": "integration_connections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "connected_by_user_id": { + "name": "connected_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "external_org_id": { + "name": "external_org_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_org_name": { + "name": "external_org_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "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": { + "integration_connections_org_idx": { + "name": "integration_connections_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integration_connections_organization_id_organizations_id_fk": { + "name": "integration_connections_organization_id_organizations_id_fk", + "tableFrom": "integration_connections", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "integration_connections_connected_by_user_id_users_id_fk": { + "name": "integration_connections_connected_by_user_id_users_id_fk", + "tableFrom": "integration_connections", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "connected_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "integration_connections_unique": { + "name": "integration_connections_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "provider" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.repositories": { + "name": "repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'main'" + }, + "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": { + "repositories_organization_id_idx": { + "name": "repositories_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "repositories_slug_idx": { + "name": "repositories_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "repositories_organization_id_organizations_id_fk": { + "name": "repositories_organization_id_organizations_id_fk", + "tableFrom": "repositories", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "repositories_org_slug_unique": { + "name": "repositories_org_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "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": { + "subscriptions_reference_id_idx": { + "name": "subscriptions_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_stripe_customer_id_idx": { + "name": "subscriptions_stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "subscriptions_status_idx": { + "name": "subscriptions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_reference_id_organizations_id_fk": { + "name": "subscriptions_reference_id_organizations_id_fk", + "tableFrom": "subscriptions", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "reference_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.task_statuses": { + "name": "task_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "progress_percent": { + "name": "progress_percent", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "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": { + "task_statuses_organization_id_idx": { + "name": "task_statuses_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "task_statuses_type_idx": { + "name": "task_statuses_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "task_statuses_organization_id_organizations_id_fk": { + "name": "task_statuses_organization_id_organizations_id_fk", + "tableFrom": "task_statuses", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "task_statuses_org_external_unique": { + "name": "task_statuses_org_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tasks": { + "name": "tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "priority": { + "name": "priority", + "type": "task_priority", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "assignee_id": { + "name": "assignee_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "creator_id": { + "name": "creator_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "estimate": { + "name": "estimate", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "due_date": { + "name": "due_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "labels": { + "name": "labels", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'[]'::jsonb" + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "pr_url": { + "name": "pr_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_provider": { + "name": "external_provider", + "type": "integration_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_key": { + "name": "external_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_url": { + "name": "external_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "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 + }, + "deleted_at": { + "name": "deleted_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": { + "tasks_slug_idx": { + "name": "tasks_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_organization_id_idx": { + "name": "tasks_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_repository_id_idx": { + "name": "tasks_repository_id_idx", + "columns": [ + { + "expression": "repository_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_assignee_id_idx": { + "name": "tasks_assignee_id_idx", + "columns": [ + { + "expression": "assignee_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_creator_id_idx": { + "name": "tasks_creator_id_idx", + "columns": [ + { + "expression": "creator_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_status_id_idx": { + "name": "tasks_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_created_at_idx": { + "name": "tasks_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tasks_external_provider_idx": { + "name": "tasks_external_provider_idx", + "columns": [ + { + "expression": "external_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tasks_status_id_task_statuses_id_fk": { + "name": "tasks_status_id_task_statuses_id_fk", + "tableFrom": "tasks", + "tableTo": "task_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tasks_organization_id_organizations_id_fk": { + "name": "tasks_organization_id_organizations_id_fk", + "tableFrom": "tasks", + "tableTo": "organizations", + "schemaTo": "auth", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_repository_id_repositories_id_fk": { + "name": "tasks_repository_id_repositories_id_fk", + "tableFrom": "tasks", + "tableTo": "repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tasks_assignee_id_users_id_fk": { + "name": "tasks_assignee_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "assignee_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "tasks_creator_id_users_id_fk": { + "name": "tasks_creator_id_users_id_fk", + "tableFrom": "tasks", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tasks_slug_unique": { + "name": "tasks_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + }, + "tasks_external_unique": { + "name": "tasks_external_unique", + "nullsNotDistinct": false, + "columns": [ + "organization_id", + "external_provider", + "external_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.command_status": { + "name": "command_status", + "schema": "public", + "values": [ + "pending", + "claimed", + "executing", + "completed", + "failed", + "timeout" + ] + }, + "public.device_type": { + "name": "device_type", + "schema": "public", + "values": [ + "desktop", + "mobile", + "web" + ] + }, + "public.integration_provider": { + "name": "integration_provider", + "schema": "public", + "values": [ + "linear", + "github" + ] + }, + "public.task_priority": { + "name": "task_priority", + "schema": "public", + "values": [ + "urgent", + "high", + "medium", + "low", + "none" + ] + }, + "public.task_status": { + "name": "task_status", + "schema": "public", + "values": [ + "backlog", + "todo", + "planning", + "working", + "needs-feedback", + "ready-to-merge", + "completed", + "canceled" + ] + } + }, + "schemas": { + "auth": "auth", + "ingest": "ingest" + }, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 24fe5c0a782..27bc4d11c77 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1769106861342, "tag": "0012_add_stripe_subscription_fields", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1769225155008, + "tag": "0013_add_mcp_actions_and_presence", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/enums.ts b/packages/db/src/schema/enums.ts index 5bd62de50e8..d4ca978c868 100644 --- a/packages/db/src/schema/enums.ts +++ b/packages/db/src/schema/enums.ts @@ -26,3 +26,18 @@ export type TaskPriority = z.infer; export const integrationProviderValues = ["linear", "github"] as const; export const integrationProviderEnum = z.enum(integrationProviderValues); export type IntegrationProvider = z.infer; + +export const deviceTypeValues = ["desktop", "mobile", "web"] as const; +export const deviceTypeEnum = z.enum(deviceTypeValues); +export type DeviceType = z.infer; + +export const commandStatusValues = [ + "pending", + "claimed", + "executing", + "completed", + "failed", + "timeout", +] as const; +export const commandStatusEnum = z.enum(commandStatusValues); +export type CommandStatus = z.infer; diff --git a/packages/db/src/schema/relations.ts b/packages/db/src/schema/relations.ts index 1143fd92f9d..8a87bf94aa1 100644 --- a/packages/db/src/schema/relations.ts +++ b/packages/db/src/schema/relations.ts @@ -14,6 +14,9 @@ import { githubRepositories, } from "./github"; import { + agentCommands, + apiKeys, + devicePresence, integrationConnections, repositories, subscriptions, @@ -30,6 +33,9 @@ export const usersRelations = relations(users, ({ many }) => ({ assignedTasks: many(tasks, { relationName: "assignee" }), connectedIntegrations: many(integrationConnections), githubInstallations: many(githubInstallations), + devicePresence: many(devicePresence), + agentCommands: many(agentCommands), + apiKeys: many(apiKeys), })); export const sessionsRelations = relations(sessions, ({ one }) => ({ @@ -55,6 +61,20 @@ export const organizationsRelations = relations(organizations, ({ many }) => ({ taskStatuses: many(taskStatuses), integrations: many(integrationConnections), githubInstallations: many(githubInstallations), + devicePresence: many(devicePresence), + agentCommands: many(agentCommands), + apiKeys: many(apiKeys), +})); + +export const apiKeysRelations = relations(apiKeys, ({ one }) => ({ + user: one(users, { + fields: [apiKeys.userId], + references: [users.id], + }), + organization: one(organizations, { + fields: [apiKeys.organizationId], + references: [organizations.id], + }), })); export const membersRelations = relations(members, ({ one }) => ({ @@ -183,3 +203,31 @@ export const githubPullRequestsRelations = relations( }), }), ); + +// Agent relations +export const devicePresenceRelations = relations(devicePresence, ({ one }) => ({ + user: one(users, { + fields: [devicePresence.userId], + references: [users.id], + }), + organization: one(organizations, { + fields: [devicePresence.organizationId], + references: [organizations.id], + }), +})); + +export const agentCommandsRelations = relations(agentCommands, ({ one }) => ({ + user: one(users, { + fields: [agentCommands.userId], + references: [users.id], + }), + organization: one(organizations, { + fields: [agentCommands.organizationId], + references: [organizations.id], + }), + parentCommand: one(agentCommands, { + fields: [agentCommands.parentCommandId], + references: [agentCommands.id], + relationName: "parentCommand", + }), +})); diff --git a/packages/db/src/schema/schema.ts b/packages/db/src/schema/schema.ts index ca6204d2dac..9552c4d7f1f 100644 --- a/packages/db/src/schema/schema.ts +++ b/packages/db/src/schema/schema.ts @@ -9,11 +9,14 @@ import { text, timestamp, unique, + uniqueIndex, uuid, } from "drizzle-orm/pg-core"; import { organizations, users } from "./auth"; import { + commandStatusValues, + deviceTypeValues, integrationProviderValues, taskPriorityValues, taskStatusEnumValues, @@ -26,6 +29,8 @@ export const integrationProvider = pgEnum( "integration_provider", integrationProviderValues, ); +export const deviceType = pgEnum("device_type", deviceTypeValues); +export const commandStatus = pgEnum("command_status", commandStatusValues); export const repositories = pgTable( "repositories", @@ -250,3 +255,107 @@ export const subscriptions = pgTable( export type InsertSubscription = typeof subscriptions.$inferInsert; export type SelectSubscription = typeof subscriptions.$inferSelect; + +// Device presence - tracks online devices for command routing +export const devicePresence = pgTable( + "device_presence", + { + id: uuid().primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + deviceId: text("device_id").notNull(), + deviceName: text("device_name").notNull(), + deviceType: deviceType("device_type").notNull(), + lastSeenAt: timestamp("last_seen_at").notNull().defaultNow(), + createdAt: timestamp("created_at").notNull().defaultNow(), + }, + (table) => [ + index("device_presence_user_org_idx").on( + table.userId, + table.organizationId, + ), + uniqueIndex("device_presence_user_device_idx").on( + table.userId, + table.deviceId, + ), + index("device_presence_last_seen_idx").on(table.lastSeenAt), + ], +); + +export type InsertDevicePresence = typeof devicePresence.$inferInsert; +export type SelectDevicePresence = typeof devicePresence.$inferSelect; + +// Agent commands - synced via Electric SQL to executors +export const agentCommands = pgTable( + "agent_commands", + { + id: uuid().primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + targetDeviceId: text("target_device_id"), + targetDeviceType: text("target_device_type"), + tool: text().notNull(), + params: jsonb().$type>(), + parentCommandId: uuid("parent_command_id"), + status: commandStatus().notNull().default("pending"), + claimedBy: text("claimed_by"), + claimedAt: timestamp("claimed_at"), + result: jsonb().$type>(), + error: text(), + createdAt: timestamp("created_at").notNull().defaultNow(), + executedAt: timestamp("executed_at"), + timeoutAt: timestamp("timeout_at"), + }, + (table) => [ + index("agent_commands_user_status_idx").on(table.userId, table.status), + index("agent_commands_target_device_status_idx").on( + table.targetDeviceId, + table.status, + ), + index("agent_commands_org_created_idx").on( + table.organizationId, + table.createdAt, + ), + ], +); + +export type InsertAgentCommand = typeof agentCommands.$inferInsert; +export type SelectAgentCommand = typeof agentCommands.$inferSelect; + +// API Keys for MCP server authentication +export const apiKeys = pgTable( + "api_keys", + { + id: uuid().primaryKey().defaultRandom(), + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + organizationId: uuid("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + name: text().notNull(), + keyPrefix: text("key_prefix").notNull(), + keyHash: text("key_hash").notNull(), + defaultDeviceId: text("default_device_id"), + lastUsedAt: timestamp("last_used_at"), + usageCount: text("usage_count").notNull().default("0"), + createdAt: timestamp("created_at").notNull().defaultNow(), + expiresAt: timestamp("expires_at"), + revokedAt: timestamp("revoked_at"), + }, + (table) => [ + index("api_keys_user_org_idx").on(table.userId, table.organizationId), + index("api_keys_key_hash_idx").on(table.keyHash), + ], +); + +export type InsertApiKey = typeof apiKeys.$inferInsert; +export type SelectApiKey = typeof apiKeys.$inferSelect; diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index cc0dcf8646a..9cc24b62a5b 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -45,5 +45,8 @@ export const POSTHOG_COOKIE_NAME = "superset"; export const FEATURE_FLAGS = { /** Gates access to experimental Electric SQL tasks feature. */ ELECTRIC_TASKS_ACCESS: "electric-tasks-access", + /** Gates access to billing features. */ BILLING_ENABLED: "billing-enabled", + /** Gates access to MCP agent commands (cloud-to-desktop sync). */ + AGENT_COMMANDS_ACCESS: "agent-commands-access", } as const; diff --git a/packages/trpc/package.json b/packages/trpc/package.json index be2cd8867e1..a04a063cc8b 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -18,6 +18,7 @@ "typecheck": "tsc --noEmit --emitDeclarationOnly false" }, "dependencies": { + "@anthropic-ai/sdk": "^0.71.2", "@linear/sdk": "^68.1.0", "@superset/auth": "workspace:*", "@superset/db": "workspace:*", diff --git a/packages/trpc/src/env.ts b/packages/trpc/src/env.ts index b0f11862257..5a5cb9dd2bc 100644 --- a/packages/trpc/src/env.ts +++ b/packages/trpc/src/env.ts @@ -6,6 +6,7 @@ export const env = createEnv({ NODE_ENV: z .enum(["development", "production", "test"]) .default("development"), + ANTHROPIC_API_KEY: z.string().min(1).optional(), BLOB_READ_WRITE_TOKEN: z.string().min(1), POSTHOG_API_KEY: z.string(), POSTHOG_API_HOST: z.string().url().default("https://us.posthog.com"), diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts index 6829cd677d8..444f79db0c5 100644 --- a/packages/trpc/src/root.ts +++ b/packages/trpc/src/root.ts @@ -1,7 +1,9 @@ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; import { adminRouter } from "./router/admin"; +import { agentRouter, apiKeysRouter } from "./router/agent"; import { analyticsRouter } from "./router/analytics"; +import { deviceRouter } from "./router/device"; import { integrationRouter } from "./router/integration"; import { organizationRouter } from "./router/organization"; import { repositoryRouter } from "./router/repository"; @@ -11,7 +13,10 @@ import { createCallerFactory, createTRPCRouter } from "./trpc"; export const appRouter = createTRPCRouter({ admin: adminRouter, + agent: agentRouter, analytics: analyticsRouter, + apiKeys: apiKeysRouter, + device: deviceRouter, integration: integrationRouter, organization: organizationRouter, repository: repositoryRouter, diff --git a/packages/trpc/src/router/agent/agent.ts b/packages/trpc/src/router/agent/agent.ts new file mode 100644 index 00000000000..c597a6bfcda --- /dev/null +++ b/packages/trpc/src/router/agent/agent.ts @@ -0,0 +1,78 @@ +import { db } from "@superset/db/client"; +import { agentCommands, commandStatusValues } from "@superset/db/schema"; +import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; +import { protectedProcedure } from "../../trpc"; + +export const agentRouter = { + /** + * Update a command's status (called by device executors via Electric sync) + */ + updateCommand: protectedProcedure + .input( + z.object({ + id: z.string().uuid(), + status: z.enum(commandStatusValues), + claimedBy: z.string().optional(), + claimedAt: z.date().optional(), + result: z.record(z.string(), z.unknown()).optional(), + error: z.string().optional(), + executedAt: z.date().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No active organization selected", + }); + } + + const [existingCommand] = await db + .select() + .from(agentCommands) + .where( + and( + eq(agentCommands.id, input.id), + eq(agentCommands.organizationId, organizationId), + ), + ); + + if (!existingCommand) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Command not found", + }); + } + + const updateData: Partial = { + status: input.status, + }; + + if (input.claimedBy !== undefined) { + updateData.claimedBy = input.claimedBy; + } + if (input.claimedAt !== undefined) { + updateData.claimedAt = input.claimedAt; + } + if (input.result !== undefined) { + updateData.result = input.result; + } + if (input.error !== undefined) { + updateData.error = input.error; + } + if (input.executedAt !== undefined) { + updateData.executedAt = input.executedAt; + } + + const [updated] = await db + .update(agentCommands) + .set(updateData) + .where(eq(agentCommands.id, input.id)) + .returning(); + + return { command: updated, txid: BigInt(Date.now()) }; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/agent/api-keys.ts b/packages/trpc/src/router/agent/api-keys.ts new file mode 100644 index 00000000000..3a10c836ad0 --- /dev/null +++ b/packages/trpc/src/router/agent/api-keys.ts @@ -0,0 +1,221 @@ +import { createHash, randomBytes } from "node:crypto"; +import { db } from "@superset/db/client"; +import { apiKeys } from "@superset/db/schema"; +import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; +import { and, eq, isNull } from "drizzle-orm"; +import { z } from "zod"; +import { protectedProcedure } from "../../trpc"; + +/** + * Generate a secure random API key + * Format: sk_live_<32 random bytes as base64url> + */ +function generateApiKey(): string { + const bytes = randomBytes(32); + return `sk_live_${bytes.toString("base64url")}`; +} + +/** + * Hash an API key for storage using SHA-256 + */ +function hashApiKey(key: string): string { + return createHash("sha256").update(key).digest("hex"); +} + +/** + * Get a display prefix for an API key (first 7 + last 4 chars) + * e.g., "sk_live...xyz1" + */ +function getKeyPrefix(key: string): string { + return `${key.slice(0, 7)}...${key.slice(-4)}`; +} + +export const apiKeysRouter = { + /** + * Generate a new API key + * Returns the full key only once - it cannot be retrieved later + */ + generate: protectedProcedure + .input( + z.object({ + name: z.string().min(1).max(100), + defaultDeviceId: z.string().optional(), + expiresAt: z.string().datetime().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No active organization selected", + }); + } + + const userId = ctx.session.user.id; + + // Generate the key + const plainKey = generateApiKey(); + const keyHash = hashApiKey(plainKey); + const keyPrefix = getKeyPrefix(plainKey); + + // Store the key (hash only) + const result = await db + .insert(apiKeys) + .values({ + userId, + organizationId, + name: input.name, + keyPrefix, + keyHash, + defaultDeviceId: input.defaultDeviceId, + expiresAt: input.expiresAt ? new Date(input.expiresAt) : undefined, + }) + .returning(); + + const created = result[0]; + if (!created) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Failed to create API key", + }); + } + + // Return the full key only this once + return { + id: created.id, + name: created.name, + key: plainKey, // Only returned on creation! + keyPrefix: created.keyPrefix, + createdAt: created.createdAt, + expiresAt: created.expiresAt, + }; + }), + + /** + * List all API keys for the current user in the active organization + * Does NOT return the actual keys (only prefix for identification) + */ + list: protectedProcedure.query(async ({ ctx }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + return []; + } + + const userId = ctx.session.user.id; + + const keys = await db + .select({ + id: apiKeys.id, + name: apiKeys.name, + keyPrefix: apiKeys.keyPrefix, + defaultDeviceId: apiKeys.defaultDeviceId, + lastUsedAt: apiKeys.lastUsedAt, + usageCount: apiKeys.usageCount, + createdAt: apiKeys.createdAt, + expiresAt: apiKeys.expiresAt, + }) + .from(apiKeys) + .where( + and( + eq(apiKeys.userId, userId), + eq(apiKeys.organizationId, organizationId), + isNull(apiKeys.revokedAt), // Only non-revoked keys + ), + ) + .orderBy(apiKeys.createdAt); + + return keys; + }), + + /** + * Revoke an API key (soft delete) + */ + revoke: protectedProcedure + .input(z.object({ id: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + + // Verify ownership and revoke + const [revoked] = await db + .update(apiKeys) + .set({ revokedAt: new Date() }) + .where( + and( + eq(apiKeys.id, input.id), + eq(apiKeys.userId, userId), + isNull(apiKeys.revokedAt), + ), + ) + .returning({ id: apiKeys.id }); + + if (!revoked) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "API key not found or already revoked", + }); + } + + return { success: true, revokedAt: new Date() }; + }), + + /** + * Update an API key (name, default device) + */ + update: protectedProcedure + .input( + z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(100).optional(), + defaultDeviceId: z.string().nullable().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const userId = ctx.session.user.id; + + const updates: Partial<{ + name: string; + defaultDeviceId: string | null; + }> = {}; + + if (input.name !== undefined) { + updates.name = input.name; + } + if (input.defaultDeviceId !== undefined) { + updates.defaultDeviceId = input.defaultDeviceId; + } + + if (Object.keys(updates).length === 0) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No updates provided", + }); + } + + const [updated] = await db + .update(apiKeys) + .set(updates) + .where( + and( + eq(apiKeys.id, input.id), + eq(apiKeys.userId, userId), + isNull(apiKeys.revokedAt), + ), + ) + .returning(); + + if (!updated) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "API key not found or already revoked", + }); + } + + return { + id: updated.id, + name: updated.name, + keyPrefix: updated.keyPrefix, + defaultDeviceId: updated.defaultDeviceId, + }; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/agent/index.ts b/packages/trpc/src/router/agent/index.ts new file mode 100644 index 00000000000..48fbb01701d --- /dev/null +++ b/packages/trpc/src/router/agent/index.ts @@ -0,0 +1,2 @@ +export { agentRouter } from "./agent"; +export { apiKeysRouter } from "./api-keys"; diff --git a/packages/trpc/src/router/device/device.ts b/packages/trpc/src/router/device/device.ts new file mode 100644 index 00000000000..c6417a5fdfd --- /dev/null +++ b/packages/trpc/src/router/device/device.ts @@ -0,0 +1,94 @@ +import { db } from "@superset/db/client"; +import { devicePresence, deviceTypeValues, users } from "@superset/db/schema"; +import { TRPCError, type TRPCRouterRecord } from "@trpc/server"; +import { and, eq, gt } from "drizzle-orm"; +import { z } from "zod"; +import { protectedProcedure } from "../../trpc"; + +const OFFLINE_THRESHOLD_MS = 60_000; + +export const deviceRouter = { + /** + * Register or update device presence (heartbeat) + * Called by desktop/mobile apps to indicate they're online + */ + heartbeat: protectedProcedure + .input( + z.object({ + deviceId: z.string().min(1), + deviceName: z.string().min(1), + deviceType: z.enum(deviceTypeValues), + }), + ) + .mutation(async ({ ctx, input }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "No active organization selected", + }); + } + + const userId = ctx.session.user.id; + const now = new Date(); + + const [device] = await db + .insert(devicePresence) + .values({ + userId, + organizationId, + deviceId: input.deviceId, + deviceName: input.deviceName, + deviceType: input.deviceType, + lastSeenAt: now, + createdAt: now, + }) + .onConflictDoUpdate({ + target: [devicePresence.userId, devicePresence.deviceId], + set: { + deviceName: input.deviceName, + deviceType: input.deviceType, + lastSeenAt: now, + organizationId, + }, + }) + .returning(); + + return { device, timestamp: now }; + }), + + /** + * List online devices in the organization + */ + listOnlineDevices: protectedProcedure.query(async ({ ctx }) => { + const organizationId = ctx.session.session.activeOrganizationId; + if (!organizationId) { + return []; + } + + const threshold = new Date(Date.now() - OFFLINE_THRESHOLD_MS); + + const devices = await db + .select({ + id: devicePresence.id, + deviceId: devicePresence.deviceId, + deviceName: devicePresence.deviceName, + deviceType: devicePresence.deviceType, + lastSeenAt: devicePresence.lastSeenAt, + createdAt: devicePresence.createdAt, + ownerId: devicePresence.userId, + ownerName: users.name, + ownerEmail: users.email, + }) + .from(devicePresence) + .innerJoin(users, eq(devicePresence.userId, users.id)) + .where( + and( + eq(devicePresence.organizationId, organizationId), + gt(devicePresence.lastSeenAt, threshold), + ), + ); + + return devices; + }), +} satisfies TRPCRouterRecord; diff --git a/packages/trpc/src/router/device/index.ts b/packages/trpc/src/router/device/index.ts new file mode 100644 index 00000000000..44c3db917ba --- /dev/null +++ b/packages/trpc/src/router/device/index.ts @@ -0,0 +1 @@ +export { deviceRouter } from "./device";