diff --git a/pilot/.env.example b/pilot/.env.example new file mode 100644 index 00000000..98ad5e05 --- /dev/null +++ b/pilot/.env.example @@ -0,0 +1,11 @@ +# OAuth providers +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Session encryption +SESSION_SECRET= + +# Environment +ENVIRONMENT=development diff --git a/pilot/.gitignore b/pilot/.gitignore new file mode 100644 index 00000000..eb1c387d --- /dev/null +++ b/pilot/.gitignore @@ -0,0 +1,9 @@ +node_modules +.svelte-kit +build +.wrangler +.env +.env.* +!.env.example +.dev.vars +package-lock.json diff --git a/pilot/Dockerfile.sandbox b/pilot/Dockerfile.sandbox new file mode 100644 index 00000000..5044c0b5 --- /dev/null +++ b/pilot/Dockerfile.sandbox @@ -0,0 +1,14 @@ +FROM docker.io/cloudflare/sandbox:0.7.4 + +# Install Claude Code and serve globally +RUN npm install -g @anthropic-ai/claude-code serve + +# Set up git config +RUN git config --global user.name "TaskYou Agent" && \ + git config --global user.email "agent@taskyou.dev" + +# Initialize workspace +RUN mkdir -p /root/worktrees /workspace + +# Expose port for web app previews +EXPOSE 8080 diff --git a/pilot/package.json b/pilot/package.json new file mode 100644 index 00000000..ce401176 --- /dev/null +++ b/pilot/package.json @@ -0,0 +1,39 @@ +{ + "name": "taskyou-pilot", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build && node scripts/postbuild.js", + "preview": "wrangler dev", + "deploy": "npm run build && wrangler deploy", + "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "test": "vitest" + }, + "devDependencies": { + "@cloudflare/workers-types": "^4.20250129.0", + "@sveltejs/adapter-cloudflare": "^7.2.7", + "@sveltejs/kit": "^2.0.0", + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "@tailwindcss/vite": "^4.0.0", + "svelte": "^5.0.0", + "svelte-check": "^4.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", + "vite": "^5.0.0", + "vitest": "^2.0.0", + "wrangler": "^4.65.0" + }, + "dependencies": { + "@ai-sdk/anthropic": "^3.0.44", + "@cloudflare/ai-chat": "^0.1.1", + "@cloudflare/sandbox": "^0.7.4", + "agents": "^0.5.0", + "ai": "^6.0.87", + "basecoat-css": "^0.3.11", + "lucide-svelte": "^0.460.0", + "marked": "^17.0.2", + "zod": "^4.3.6" + } +} diff --git a/pilot/sandbox-init.sh b/pilot/sandbox-init.sh new file mode 100644 index 00000000..a3b2d954 --- /dev/null +++ b/pilot/sandbox-init.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Initialization script for taskyou sandbox containers +# This runs inside each user's isolated container + +echo "TaskYou sandbox initialized" +echo "Container ready for task execution" + +# Keep the container running +exec sleep infinity diff --git a/pilot/scripts/postbuild.js b/pilot/scripts/postbuild.js new file mode 100644 index 00000000..c90ee941 --- /dev/null +++ b/pilot/scripts/postbuild.js @@ -0,0 +1,59 @@ +// Post-build: Wrap SvelteKit worker with custom entry point +// - Adds routeAgentRequest() for agent WebSocket/HTTP handling +// - Adds proxyToSandbox() for sandbox preview URL routing +// - Re-exports TaskYouAgent, TaskExecutionWorkflow, and Sandbox for wrangler bindings +// - SvelteKit handles everything else (auth, CRUD, static assets) + +import { readFileSync, writeFileSync, existsSync } from 'fs'; + +const workerFile = 'worker-entry.js'; + +if (!existsSync(workerFile)) { + console.error(`✗ ${workerFile} not found - did vite build run?`); + process.exit(1); +} + +let content = readFileSync(workerFile, 'utf8'); + +// Check if already patched +if (content.includes('routeAgentRequest')) { + console.log('✓ worker-entry.js already patched'); + process.exit(0); +} + +// Add agent + sandbox imports at the top of the file +const imports = ` +import { routeAgentRequest } from "agents"; +import { proxyToSandbox } from "@cloudflare/sandbox"; +`; + +content = imports + content; + +// Wrap the existing fetch handler with sandbox proxy + agent routing +content = content.replace( + 'async fetch(req, env2, ctx) {', + `async fetch(req, env2, ctx) { + // Route agent WebSocket/HTTP requests before SvelteKit + const agentResponse = await routeAgentRequest(req, env2); + if (agentResponse) return agentResponse; + + // Proxy sandbox preview URLs (after agent routing) + // Only relevant with custom domains for preview URL subdomains + try { + const sandboxResponse = await proxyToSandbox(req, env2); + if (sandboxResponse && sandboxResponse.status !== 404) return sandboxResponse; + } catch (e) { + // Sandbox proxy not available — continue to SvelteKit + }` +); + +// Add DO, Workflow, and Sandbox class re-exports at the end +content += ` +// Re-export agent classes for Durable Object, Workflow, and Container bindings +export { TaskYouAgent } from "./src/lib/server/agent.ts"; +export { TaskExecutionWorkflow } from "./src/lib/server/workflow.ts"; +export { Sandbox } from "@cloudflare/sandbox"; +`; + +writeFileSync(workerFile, content); +console.log('✓ Patched worker-entry.js with proxyToSandbox + routeAgentRequest + exports'); diff --git a/pilot/src/app.css b/pilot/src/app.css new file mode 100644 index 00000000..81a24042 --- /dev/null +++ b/pilot/src/app.css @@ -0,0 +1,364 @@ +@import "tailwindcss"; +@import "basecoat-css"; + +@custom-variant dark (&:where(.dark, .dark *)); + +@theme { + --font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, sans-serif; +} + +@layer base { + :root { + --sidebar-width: 15.5rem; + --sidebar-collapsed-width: 3.5rem; + + /* Warm-tinted light palette */ + --background: oklch(0.985 0.005 75); + --foreground: oklch(0.16 0.015 55); + --muted: oklch(0.945 0.008 75); + --muted-foreground: oklch(0.50 0.015 55); + --accent: oklch(0.94 0.01 75); + --accent-foreground: oklch(0.16 0.015 55); + --border: oklch(0.90 0.012 75); + --input: oklch(0.88 0.012 75); + --ring: oklch(0.60 0.16 55); + + /* Warm amber primary */ + --primary: oklch(0.55 0.16 55); + --primary-foreground: oklch(0.99 0.005 75); + + --card: oklch(0.993 0.003 75); + --card-foreground: oklch(0.16 0.015 55); + + --secondary: oklch(0.93 0.01 75); + --secondary-foreground: oklch(0.22 0.015 55); + + --destructive: oklch(0.55 0.2 25); + --destructive-foreground: oklch(0.99 0.005 75); + + --popover: oklch(0.993 0.003 75); + --popover-foreground: oklch(0.16 0.015 55); + + /* Sidebar: slightly cooler to contrast the warm content */ + --sidebar-background: oklch(0.975 0.004 260); + --sidebar-foreground: oklch(0.22 0.01 260); + --sidebar-accent: oklch(0.935 0.008 260); + --sidebar-accent-foreground: oklch(0.16 0.01 260); + --sidebar-border: oklch(0.91 0.008 260); + --sidebar-primary: oklch(0.55 0.16 55); + --sidebar-primary-foreground: oklch(0.99 0.005 75); + } + + .dark { + --background: oklch(0.13 0.01 260); + --foreground: oklch(0.92 0.008 75); + --muted: oklch(0.20 0.01 260); + --muted-foreground: oklch(0.58 0.015 75); + --accent: oklch(0.22 0.012 260); + --accent-foreground: oklch(0.92 0.008 75); + --border: oklch(0.24 0.012 260); + --input: oklch(0.26 0.012 260); + --ring: oklch(0.65 0.16 55); + + --primary: oklch(0.68 0.16 55); + --primary-foreground: oklch(0.13 0.01 55); + + --card: oklch(0.155 0.01 260); + --card-foreground: oklch(0.92 0.008 75); + + --secondary: oklch(0.22 0.012 260); + --secondary-foreground: oklch(0.88 0.008 75); + + --destructive: oklch(0.60 0.2 25); + --destructive-foreground: oklch(0.99 0.005 75); + + --popover: oklch(0.17 0.01 260); + --popover-foreground: oklch(0.92 0.008 75); + + --sidebar-background: oklch(0.12 0.008 260); + --sidebar-foreground: oklch(0.88 0.008 260); + --sidebar-accent: oklch(0.20 0.012 260); + --sidebar-accent-foreground: oklch(0.92 0.008 260); + --sidebar-border: oklch(0.22 0.008 260); + --sidebar-primary: oklch(0.68 0.16 55); + --sidebar-primary-foreground: oklch(0.13 0.01 55); + } + + body { + @apply antialiased; + font-feature-settings: "cv02", "cv03", "cv04", "cv11"; + letter-spacing: -0.011em; + } + + /* Tighter headings */ + h1, h2, h3, h4 { + letter-spacing: -0.025em; + font-weight: 600; + } +} + +/* Status colors — desaturated, warm-shifted */ +:root { + --status-backlog: oklch(0.58 0.03 260); + --status-backlog-bg: oklch(0.96 0.006 260); + --status-queued: oklch(0.62 0.14 80); + --status-queued-bg: oklch(0.96 0.03 80); + --status-processing: oklch(0.55 0.16 55); + --status-processing-bg: oklch(0.96 0.03 55); + --status-blocked: oklch(0.58 0.16 30); + --status-blocked-bg: oklch(0.96 0.03 30); + --status-done: oklch(0.52 0.14 155); + --status-done-bg: oklch(0.96 0.03 155); + --status-failed: oklch(0.52 0.18 25); + --status-failed-bg: oklch(0.96 0.03 25); +} + +.dark { + --status-backlog: oklch(0.52 0.03 260); + --status-backlog-bg: oklch(0.19 0.008 260); + --status-queued: oklch(0.65 0.14 80); + --status-queued-bg: oklch(0.22 0.03 80); + --status-processing: oklch(0.65 0.16 55); + --status-processing-bg: oklch(0.20 0.03 55); + --status-blocked: oklch(0.60 0.16 30); + --status-blocked-bg: oklch(0.20 0.03 30); + --status-done: oklch(0.58 0.14 155); + --status-done-bg: oklch(0.20 0.03 155); + --status-failed: oklch(0.58 0.18 25); + --status-failed-bg: oklch(0.20 0.03 25); +} + +/* ---- Sidebar ---- */ + +.sidebar nav > section ul li > a, +.sidebar nav > section ul li > button { + @apply flex items-center gap-2 min-w-0; +} + +.sidebar nav > section ul li > a > span:not(.flex-shrink-0):not(.sidebar-item-initial), +.sidebar nav > section ul li > button > span:not(.flex-shrink-0):not(.sidebar-item-initial) { + @apply truncate min-w-0; +} + +/* Collapsed icon-rail */ +.sidebar[data-collapsed] { + --sidebar-width: var(--sidebar-collapsed-width); + + nav { + overflow: hidden; + } + + & + * { + --sidebar-width: var(--sidebar-collapsed-width); + } + + [data-sidebar-label] { + @apply hidden; + } + + [data-sidebar-collapsed-only] { + @apply block!; + } + + nav > hr[role="separator"], + nav > section > h3, + nav > section > [role="group"] > h3, + nav > section [role="group"] h3 { + @apply hidden; + } + + nav > section ul li > a > button, + nav > section ul li .group > button { + @apply hidden; + } + + nav > footer > button, + nav > footer > div { + @apply justify-center px-0; + } + + nav > header > div { + @apply flex-col items-center gap-1.5 px-0; + } + + nav > header > div > button { + @apply ml-0; + } + + nav > section ul li > a, + nav > section ul li > button { + @apply justify-center px-0; + } + + nav > section ul li > a > span:not(.sidebar-item-initial), + nav > section ul li > button > span:not(.sidebar-item-initial) { + @apply hidden; + } +} + +/* ---- Kanban ---- */ + +/* Column headers: clean top edge with status color */ +.kanban-column { + @apply flex flex-col overflow-hidden; + border-radius: 0; + background: transparent; + border: none; +} + +.kanban-column-header { + @apply flex items-center justify-between px-1 pb-2; + border-bottom: 1px solid var(--border); +} + +.kanban-column-header[data-has-tasks] { + border-bottom-color: color-mix(in oklch, var(--col-color, var(--border)) 50%, var(--border)); +} + +/* Focused column: highlight header when navigated to */ +.kanban-column.focused-column > .kanban-column-header { + border-bottom-color: var(--col-color, var(--primary)); +} + +/* Focused card: visible bg shift */ +.focused-card { + background: var(--accent); + outline: 1.5px solid color-mix(in oklch, var(--foreground) 12%, transparent); + outline-offset: -1.5px; +} + +/* Multi-selected card */ +.selected-card { + background: color-mix(in oklch, var(--primary) 8%, transparent); + outline: 1.5px solid color-mix(in oklch, var(--primary) 25%, transparent); + outline-offset: -1.5px; +} + +/* ---- Chat markdown ---- */ + +.chat-markdown p { + margin: 0.25em 0; +} +.chat-markdown p:first-child { + margin-top: 0; +} +.chat-markdown p:last-child { + margin-bottom: 0; +} +.chat-markdown pre { + @apply bg-accent rounded-lg overflow-x-auto; + padding: 0.75rem 1rem; + margin: 0.5em 0; + font-size: 0.8125rem; +} +.chat-markdown code { + @apply bg-accent px-1 py-0.5 rounded; + font-size: 0.8125rem; +} +.chat-markdown pre code { + background: none; + padding: 0; +} +.chat-markdown ul, .chat-markdown ol { + padding-left: 1.25rem; + margin: 0.25em 0; +} +.chat-markdown li { + margin: 0.125em 0; +} +.chat-markdown blockquote { + @apply border-l-3 border-muted-foreground/30 pl-3 italic text-muted-foreground; + margin: 0.5em 0; +} +.chat-markdown table { + border-collapse: collapse; + margin: 0.5em 0; + font-size: 0.8125rem; +} +.chat-markdown th, .chat-markdown td { + @apply border border-border; + padding: 0.25rem 0.5rem; +} + +/* ---- Scrollbar ---- */ + +@utility scrollbar-thin { + scrollbar-width: thin; + scrollbar-color: oklch(0.556 0.015 55 / 0.25) transparent; + &::-webkit-scrollbar { + width: 5px; + height: 5px; + } + &::-webkit-scrollbar-track { + background: transparent; + } + &::-webkit-scrollbar-thumb { + background: oklch(0.556 0.015 55 / 0.25); + border-radius: 3px; + } +} + +/* ---- Status borders ---- */ + +@utility border-status-backlog { + border-left: 3px solid var(--status-backlog); +} +@utility border-status-queued { + border-left: 3px solid var(--status-queued); +} +@utility border-status-processing { + border-left: 3px solid var(--status-processing); +} +@utility border-status-blocked { + border-left: 3px solid var(--status-blocked); +} +@utility border-status-done { + border-left: 3px solid var(--status-done); +} +@utility border-status-failed { + border-left: 3px solid var(--status-failed); +} + +/* ---- Dialogs ---- */ + +dialog.dialog[open] { + opacity: 1; +} +dialog.dialog[open]::backdrop { + opacity: 1; +} +dialog.dialog[open] > * { + scale: 1; +} + +/* ---- Animations ---- */ + +@theme { + --animate-slide-in-right: slide-in-right 0.2s ease-out; + --animate-fade-in: fade-in 0.15s ease-out; + + @keyframes slide-in-right { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } +} + +/* ---- Resize handle ---- */ + +.resize-handle { + @apply w-1 flex-shrink-0 cursor-col-resize bg-transparent hover:bg-primary/20 transition-colors relative; +} + +.resize-handle::after { + content: ""; + @apply absolute inset-y-0 -left-1 -right-1; +} + +.resize-handle.active { + @apply bg-primary/30; +} diff --git a/pilot/src/app.d.ts b/pilot/src/app.d.ts new file mode 100644 index 00000000..b8a88de8 --- /dev/null +++ b/pilot/src/app.d.ts @@ -0,0 +1,30 @@ +/// +/// + +declare global { + namespace App { + interface Locals { + user: import('$lib/types').User | null; + sessionId: string | null; + } + interface Platform { + env: { + DB: D1Database; + TASKYOU_AGENT: DurableObjectNamespace; + TASK_WORKFLOW: Workflow; + SESSIONS: KVNamespace; + STORAGE?: R2Bucket; + GOOGLE_CLIENT_ID?: string; + GOOGLE_CLIENT_SECRET?: string; + GITHUB_CLIENT_ID?: string; + GITHUB_CLIENT_SECRET?: string; + ANTHROPIC_API_KEY?: string; + SESSION_SECRET?: string; + ENVIRONMENT?: string; + }; + context: ExecutionContext; + } + } +} + +export {}; diff --git a/pilot/src/app.html b/pilot/src/app.html new file mode 100644 index 00000000..b228260a --- /dev/null +++ b/pilot/src/app.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + TaskYou + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/pilot/src/hooks.server.ts b/pilot/src/hooks.server.ts new file mode 100644 index 00000000..715acb52 --- /dev/null +++ b/pilot/src/hooks.server.ts @@ -0,0 +1,67 @@ +import type { Handle } from '@sveltejs/kit'; +import { getSessionUserId } from '$lib/server/auth'; +import { getUserById, initHostDB } from '$lib/server/db'; + +let dbInitialized = false; + +export const handle: Handle = async ({ event, resolve }) => { + const platform = event.platform; + + // Initialize DB on first request + if (platform?.env?.DB && !dbInitialized) { + try { + await initHostDB(platform.env.DB); + dbInitialized = true; + } catch (e) { + // Table already exists is fine + dbInitialized = true; + } + } + + // Dev mode: auto-authenticate with mock user + if (platform?.env?.ENVIRONMENT === 'development' || !platform?.env?.DB) { + const devUser = { + id: 'dev-user', + email: 'dev@localhost', + name: 'Development User', + avatar_url: '', + }; + // Ensure dev user exists in DB + if (platform?.env?.DB && dbInitialized) { + try { + await platform.env.DB.prepare( + `INSERT OR IGNORE INTO users (id, email, name, avatar_url, provider, provider_id) VALUES (?, ?, ?, ?, ?, ?)` + ).bind(devUser.id, devUser.email, devUser.name, devUser.avatar_url, 'dev', 'dev').run(); + } catch { + // ignore if already exists + } + } + event.locals.user = devUser; + event.locals.sessionId = 'dev-session'; + return resolve(event); + } + + // Extract session from cookie + const sessionId = event.cookies.get('session'); + if (sessionId && platform?.env?.SESSIONS && platform?.env?.DB) { + const userId = await getSessionUserId(platform.env.SESSIONS, sessionId); + if (userId) { + const user = await getUserById(platform.env.DB, userId); + if (user) { + event.locals.user = user; + event.locals.sessionId = sessionId; + } + } + } + + // Protect API routes (except auth endpoints) + const path = event.url.pathname; + if (path.startsWith('/api/') && !path.startsWith('/api/auth/') && !event.locals.user) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return resolve(event); +}; diff --git a/pilot/src/lib/api/client.ts b/pilot/src/lib/api/client.ts new file mode 100644 index 00000000..1dba45bf --- /dev/null +++ b/pilot/src/lib/api/client.ts @@ -0,0 +1,171 @@ +import type { + User, + Task, + TaskFile, + CreateTaskRequest, + UpdateTaskRequest, + Project, + CreateProjectRequest, + UpdateProjectRequest, + TaskLog, + Chat, + Message, + Model, + Integration, + Workspace, +} from '$lib/types'; + +async function fetchJSON(path: string, options?: RequestInit): Promise { + const response = await fetch(`/api${path}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + credentials: 'include', + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ error: response.statusText })) as { error?: string }; + throw new Error(error.error || 'Request failed'); + } + + if (response.status === 204) { + return undefined as T; + } + + return response.json(); +} + +// Auth API +export const auth = { + getMe: () => fetchJSON('/auth'), + logout: () => fetchJSON<{ success: boolean }>('/auth', { method: 'POST' }), +}; + +// Tasks API +export const tasks = { + list: (options?: { status?: string; project_id?: string; type?: string; all?: boolean }) => { + const params = new URLSearchParams(); + if (options?.status) params.set('status', options.status); + if (options?.project_id) params.set('project_id', options.project_id); + if (options?.type) params.set('type', options.type); + if (options?.all) params.set('all', 'true'); + const query = params.toString(); + return fetchJSON(`/tasks${query ? `?${query}` : ''}`); + }, + get: (id: number) => fetchJSON(`/tasks/${id}`), + create: (data: CreateTaskRequest) => + fetchJSON('/tasks', { + method: 'POST', + body: JSON.stringify(data), + }), + update: (id: number, data: UpdateTaskRequest) => + fetchJSON(`/tasks/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + delete: (id: number) => + fetchJSON(`/tasks/${id}`, { method: 'DELETE' }), + getLogs: (id: number, limit?: number) => { + const params = limit ? `?limit=${limit}` : ''; + return fetchJSON(`/tasks/${id}/logs${params}`); + }, + listFiles: (id: number) => fetchJSON(`/tasks/${id}/files`), + getFileContent: (id: number, path: string) => + fetch(`/api/tasks/${id}/file?path=${encodeURIComponent(path)}`, { credentials: 'include' }).then(r => r.text()), +}; + +// Projects API +export const projects = { + list: () => fetchJSON('/projects'), + get: (id: string) => fetchJSON(`/projects/${id}`), + create: (data?: CreateProjectRequest) => + fetchJSON('/projects', { + method: 'POST', + body: JSON.stringify(data || {}), + }), + update: (id: string, data: UpdateProjectRequest) => + fetchJSON(`/projects/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + delete: (id: string) => + fetchJSON(`/projects/${id}`, { method: 'DELETE' }), +}; + +// Chats API +export const chats = { + list: () => fetchJSON('/chats'), + get: (id: string) => fetchJSON(`/chats/${id}`), + create: (data?: { title?: string; model_id?: string; project_id?: string }) => + fetchJSON('/chats', { + method: 'POST', + body: JSON.stringify(data || {}), + }), + update: (id: string, data: { title?: string; model_id?: string }) => + fetchJSON(`/chats/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + delete: (id: string) => + fetchJSON(`/chats/${id}`, { method: 'DELETE' }), +}; + +// Messages API +export const messages = { + list: (chatId: string) => fetchJSON(`/chats/${chatId}/messages`), +}; + +// Models API +export const models = { + list: () => fetchJSON('/models'), +}; + +// Integrations API +export const integrations = { + list: () => fetchJSON('/integrations'), +}; + +// Workspaces API +export const workspaces = { + list: () => fetchJSON('/workspaces'), + get: (id: string) => fetchJSON(`/workspaces/${id}`), + create: (data: { name: string }) => + fetchJSON('/workspaces', { + method: 'POST', + body: JSON.stringify(data), + }), + update: (id: string, data: { name?: string; autonomous_enabled?: boolean; weekly_budget_cents?: number }) => + fetchJSON(`/workspaces/${id}`, { + method: 'PUT', + body: JSON.stringify(data), + }), + delete: (id: string) => + fetchJSON(`/workspaces/${id}`, { method: 'DELETE' }), +}; + +// GitHub API +export const github = { + status: () => fetchJSON<{ connected: boolean; login?: string; avatar_url?: string }>('/auth/github/status'), + startDeviceFlow: () => + fetchJSON<{ device_code: string; user_code: string; verification_uri: string; expires_in: number; interval: number }>( + '/auth/github/device', + { method: 'POST' }, + ), + pollDeviceFlow: (device_code: string) => + fetchJSON<{ status: 'complete' | 'pending' | 'error'; error?: string; error_description?: string }>( + '/auth/github/device/poll', + { method: 'POST', body: JSON.stringify({ device_code }) }, + ), +}; + +// Settings API +export const settings = { + get: () => fetchJSON>('/settings'), + update: (data: Record) => + fetchJSON<{ success: boolean }>('/settings', { + method: 'PUT', + body: JSON.stringify(data), + }), +}; diff --git a/pilot/src/lib/components/ApprovalsPage.svelte b/pilot/src/lib/components/ApprovalsPage.svelte new file mode 100644 index 00000000..b0a29180 --- /dev/null +++ b/pilot/src/lib/components/ApprovalsPage.svelte @@ -0,0 +1,89 @@ + + +
+
+
+ +

Approvals

+
+ + {#if pendingTasks.length === 0 && blockedTasks.length === 0} +
+
+ +
+

All Clear

+

No tasks pending approval

+
+ {:else} + + {#if pendingTasks.length > 0} +
+

Pending Review

+
+ {#each pendingTasks as task} +
+
+
+

{task.title}

+ {#if task.body} +

{task.body}

+ {/if} +
+ {task.type} + {task.project_id} +
+
+
+ + +
+
+
+ {/each} +
+
+ {/if} + + + {#if blockedTasks.length > 0} +
+

Needs Attention

+
+ {#each blockedTasks as task} +
+
+ +
+

{task.title}

+

Blocked since {new Date(task.updated_at).toLocaleDateString()}

+
+ {task.type} +
+
+ {/each} +
+
+ {/if} + {/if} +
+
diff --git a/pilot/src/lib/components/AuditLogPage.svelte b/pilot/src/lib/components/AuditLogPage.svelte new file mode 100644 index 00000000..c32862cb --- /dev/null +++ b/pilot/src/lib/components/AuditLogPage.svelte @@ -0,0 +1,20 @@ + + +
+
+
+ +

Audit Log

+
+ +
+
+ +
+

Coming Soon

+

Agent actions will appear here once workflow execution is enabled

+
+
+
diff --git a/pilot/src/lib/components/ChatPanel.svelte b/pilot/src/lib/components/ChatPanel.svelte new file mode 100644 index 00000000..0c6fb0f0 --- /dev/null +++ b/pilot/src/lib/components/ChatPanel.svelte @@ -0,0 +1,262 @@ + + +
+ +
+
+ +
+

Pilot Chat

+ {#if chatProject} +

{chatProject.name}{chatProject.github_repo ? ` · ${chatProject.github_repo}` : ''}

+ {/if} +
+
+ Claude Sonnet 4.5 +
+ + +
+ {#if chatState.agentMessages.length === 0 && !agentChatStream.streaming} + +
+

+ Ask Pilot to create tasks, check progress, or plan your work. +

+
+ {#each quickActions as action} + + {/each} +
+
+ {:else} + {#each chatState.agentMessages as message (message.id)} +
+ +
+ {#if message.role === 'user'} +
+ +
+ {:else} +
+ +
+ {/if} +
+ + +
+
+ {message.role === 'user' ? 'You' : 'Pilot'} +
+ {#if message.role === 'assistant'} +
+ {@html renderMarkdown(message.content)} +
+ {:else} +
+ {message.content} +
+ {/if} + + + {#if message.toolInvocations?.length} +
+ {#each message.toolInvocations as tool} +
+ {tool.toolName} + {#if tool.state === 'result'} + done + {:else} + running... + {/if} +
+ {/each} +
+ {/if} +
+
+ {/each} + + + {#if agentChatStream.streaming} +
+
+
+ +
+
+
+
+ Pilot + +
+ {#if agentChatStream.streamingContent} +
{@html renderMarkdown(agentChatStream.streamingContent)}
+ {:else} +
+ + + +
+ {/if} +
+
+ {/if} + {/if} + +
+
+ + +
+
+ + +
+
+
diff --git a/pilot/src/lib/components/CommandPalette.svelte b/pilot/src/lib/components/CommandPalette.svelte new file mode 100644 index 00000000..97a6610e --- /dev/null +++ b/pilot/src/lib/components/CommandPalette.svelte @@ -0,0 +1,181 @@ + + +{#if isOpen} + + +
+
+ +
+ +
+ + + esc +
+ + +
+ {#if items.length === 0} +
+ No results found +
+ {:else} + {#each items as item, i} + +
handleSelect(item)} + onmouseenter={() => (selectedIndex = i)} + > + +
+
{item.label}
+ {#if item.description} +
{item.description}
+ {/if} +
+ {#if item.shortcut} + {item.shortcut} + {/if} + {#if i === selectedIndex} + + {/if} +
+ {/each} + {/if} +
+
+
+{/if} diff --git a/pilot/src/lib/components/ContextMenu.svelte b/pilot/src/lib/components/ContextMenu.svelte new file mode 100644 index 00000000..507facde --- /dev/null +++ b/pilot/src/lib/components/ContextMenu.svelte @@ -0,0 +1,73 @@ + + +
+ {#each items as item} + {#if item.separator} +
+ {/if} + + {/each} +
diff --git a/pilot/src/lib/components/Dashboard.svelte b/pilot/src/lib/components/Dashboard.svelte new file mode 100644 index 00000000..70f69833 --- /dev/null +++ b/pilot/src/lib/components/Dashboard.svelte @@ -0,0 +1,293 @@ + + + + +
+ + {#if activeProject} +
+
+

{activeProject.name}

+ {#if activeProject.github_repo} + + + {activeProject.github_repo} + {#if activeProject.github_branch} + / + {activeProject.github_branch} + {/if} + + {/if} +
+ {/if} + + +
+ +
+
+ (showNewTask = true)} + /> +
+
+ + {#if navState.chatPanelOpen} + + +
+
+
+
+ + +
+ +
+ {/if} +
+ + + +
+ + +{#if showKeyboardHelp} + + (showKeyboardHelp = false)} + onclick={(e) => { if (e.target === keyboardHelpEl) keyboardHelpEl.close(); }} + > +
+
+

Keyboard Shortcuts

+
+
+
+
+

Navigation

+
+
Navigate board
+
Open task
+
SearchK
+
+
+
+

Actions

+
+
New taskN
+
RefreshR
+
+
+
+

Panels

+
+
Toggle sidebar[
+
Toggle chat]
+
+
+
+

General

+
+
Close / dismissEsc
+
This help?
+
+
+
+
+
+ +
+ +
+
+{/if} + + (showCommandPalette = false)} + tasks={taskState.tasks} + onSelectTask={(task) => (selectedTask = task)} + onNewTask={() => (showNewTask = true)} + onNavigate={navigate} + onToggleSidebar={toggleSidebar} + onToggleChat={toggleChatPanel} + onRefreshTasks={fetchTasks} + onShowKeyboardHelp={() => (showKeyboardHelp = true)} +/> + +{#if showNewTask} + (showNewTask = false)} + /> +{/if} + +{#if selectedTask} + (selectedTask = null)} + onUpdate={handleTaskUpdate} + onDelete={() => (selectedTask = null)} + /> +{/if} diff --git a/pilot/src/lib/components/Header.svelte b/pilot/src/lib/components/Header.svelte new file mode 100644 index 00000000..53e7b18c --- /dev/null +++ b/pilot/src/lib/components/Header.svelte @@ -0,0 +1,138 @@ + + +
+
+
+ +
+
+
+ +
+ + taskyou + +
+
+ + + + + +
+ + + + + + + + + + +
+ + + {#if showUserMenu} + + +
(showUserMenu = false)}>
+
+
+

{user.name}

+

{user.email}

+
+ + +
+ {/if} +
+
+
+
+
diff --git a/pilot/src/lib/components/IntegrationsPage.svelte b/pilot/src/lib/components/IntegrationsPage.svelte new file mode 100644 index 00000000..e5011a03 --- /dev/null +++ b/pilot/src/lib/components/IntegrationsPage.svelte @@ -0,0 +1,109 @@ + + +
+
+
+ +

Integrations

+
+ +

+ Connect external services to extend Pilot's capabilities. +

+ + {#if loading} +
+
+
+ {:else} +
+ {#each providers as provider} + {@const status = getStatus(provider.id)} +
+
+
+ +
+
+
+

{provider.name}

+ {#if status === 'connected'} + + + Connected + + {:else if status === 'error'} + + + Error + + {/if} +
+

{provider.description}

+ {#if status === 'connected'} + + {:else} + + {/if} +
+
+
+ {/each} +
+ {/if} +
+
diff --git a/pilot/src/lib/components/LoginPage.svelte b/pilot/src/lib/components/LoginPage.svelte new file mode 100644 index 00000000..d03eca22 --- /dev/null +++ b/pilot/src/lib/components/LoginPage.svelte @@ -0,0 +1,111 @@ + + +
+ + + + +
+
+ +
+
+ +
+ + taskyou + +
+ +
+

Sign in

+

+ Choose a method to get started +

+
+ + + +

+ By signing in, you agree to our + Terms + and + Privacy Policy +

+
+
+
diff --git a/pilot/src/lib/components/NewTaskDialog.svelte b/pilot/src/lib/components/NewTaskDialog.svelte new file mode 100644 index 00000000..2184a11f --- /dev/null +++ b/pilot/src/lib/components/NewTaskDialog.svelte @@ -0,0 +1,153 @@ + + + + +
+
+

New Task

+

What would you like AI to do?

+
+ +
+
+ +
+ + +
+ + +
+ + +
+ + +
+ +
+ {#each taskTypes as t} + + {/each} +
+
+ + +
+ + +
+
+
+ +
+ + +
+ + +
+
diff --git a/pilot/src/lib/components/ProjectDialog.svelte b/pilot/src/lib/components/ProjectDialog.svelte new file mode 100644 index 00000000..f0bbfa07 --- /dev/null +++ b/pilot/src/lib/components/ProjectDialog.svelte @@ -0,0 +1,259 @@ + + + + +
+
+

{isEditing ? 'Edit Project' : 'New Project'}

+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+
+ + GitHub Repository + {#if ghStatus?.connected} + + + {ghStatus.login} + + {/if} +
+ + {#if !ghStatus?.connected} + + {#if deviceFlow} +
+

Enter this code on GitHub:

+
+ {deviceFlow.user_code} + + Open GitHub + +
+

+ + Waiting for authorization... +

+
+ {:else} + + {/if} + {:else} + +
+
+ + +
+
+ + +
+
+ {/if} +
+
+
+ +
+ {#if isEditing && onDelete} + + {/if} + + +
+ + +
+
diff --git a/pilot/src/lib/components/ProjectsPage.svelte b/pilot/src/lib/components/ProjectsPage.svelte new file mode 100644 index 00000000..d9283e80 --- /dev/null +++ b/pilot/src/lib/components/ProjectsPage.svelte @@ -0,0 +1,143 @@ + + +
+
+
+
+ +

Projects

+
+ +
+ +

+ Organize your tasks into projects. Use the chat to ask Pilot to create and manage tasks. +

+ + {#if loading} +
+
+
+ {:else if projectList.length === 0} +
+
+ +
+

No Projects

+

Create a project to organize your tasks

+ +
+ {:else} +
+ {#each projectList as project (project.id)} +
+
+
+
+ +
+
+

{project.name}

+ {#if project.github_repo} +

+ + {project.github_repo} + {#if project.github_branch} + ({project.github_branch}) + {/if} +

+ {/if} + {#if project.instructions} +

{project.instructions}

+ {/if} +

+ Created {new Date(project.created_at).toLocaleDateString()} +

+
+
+
+ + +
+
+
+ {/each} +
+ {/if} +
+
+ +{#if showNewProject} + (showNewProject = false)} + /> +{/if} + +{#if editingProject} + (editingProject = null)} + /> +{/if} diff --git a/pilot/src/lib/components/SettingsPage.svelte b/pilot/src/lib/components/SettingsPage.svelte new file mode 100644 index 00000000..1554e280 --- /dev/null +++ b/pilot/src/lib/components/SettingsPage.svelte @@ -0,0 +1,265 @@ + + + + +{#if loading} +
+
+
+

Loading settings...

+
+
+{:else} +
+
+ +
+ +
+

Settings

+

Customize your taskyou experience

+
+
+ +
+ +
+
+
+
+ +
+
+

Appearance

+

Customize how taskyou looks

+
+
+
+ +
+ {#each ['light', 'dark', 'system'] as theme} + {@const isActive = settings.theme === theme || (!settings.theme && theme === 'system')} + + {/each} +
+
+
+
+ + +
+
+
+
+ +
+
+

Defaults

+

Default values for new tasks

+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+
+

Projects

+

Manage your projects

+
+
+ +
+ + {#if projects.length === 0} +
+ +

No projects yet

+ +
+ {:else} +
+ {#each projects as project} +
+
+
+
+
{project.name}
+
{project.instructions || 'No instructions'}
+
+
+ +
+ {/each} +
+ {/if} +
+
+ + +
+
+
+
+ +
+
+

Data

+

Export or manage your data

+
+
+

+ Data export is available through the API. +

+
+
+ + +
+ +
+
+
+
+{/if} + +{#if showNewProject} + (showNewProject = false)} + /> +{/if} + +{#if editingProject} + (editingProject = null)} + /> +{/if} diff --git a/pilot/src/lib/components/Sidebar.svelte b/pilot/src/lib/components/Sidebar.svelte new file mode 100644 index 00000000..4cada786 --- /dev/null +++ b/pilot/src/lib/components/Sidebar.svelte @@ -0,0 +1,379 @@ + + + +{#if navState.sidebarMobileOpen} + + +
+{/if} + + + +{#if showNewProject} + (showNewProject = false)} + /> +{/if} + +{#if editingProject} + (editingProject = null)} + /> +{/if} diff --git a/pilot/src/lib/components/TaskBoard.svelte b/pilot/src/lib/components/TaskBoard.svelte new file mode 100644 index 00000000..4c6ad479 --- /dev/null +++ b/pilot/src/lib/components/TaskBoard.svelte @@ -0,0 +1,418 @@ + + + + +
+ + {#if selectedCount > 0} +
+ {selectedCount} selected +
+ + + + +
+
+ {:else} + +
+
+

Tasks

+
+ {#if getInProgressTasks().length > 0} + + + {getInProgressTasks().length} + + {/if} + {#if getBlockedTasks().length > 0} + + + {getBlockedTasks().length} + + {/if} +
+
+ +
+ {/if} + + +
+ {#each columns as col, colIdx} + {@const columnTasks = getColumnTasks(col.key)} + +
handleDragOver(e, col.key)} + ondragleave={handleDragLeave} + ondrop={(e) => handleDrop(e, col.targetStatus)} + > + +
0 ? '' : undefined} + style:--col-color={col.color} + > +
+ +

{col.title}

+
+ + {columnTasks.length} + +
+ + +
+ {#each columnTasks as task, rowIdx (task.id)} + +
handleDragStart(e, task)} + ondragend={handleDragEnd} + oncontextmenu={(e) => handleContextMenu(e, task)} + class="rounded-lg transition-colors" + class:selected-card={selectedIds.has(task.id)} + class:opacity-50={draggedTask?.id === task.id} + > + +
+ {/each} + + {#if columnTasks.length === 0} +
+

{col.emptyMessage}

+
+ {/if} +
+ + + {#if col.showAdd} +
+ +
+ {/if} +
+ {/each} +
+
+ +{#if contextMenu} + (contextMenu = null)} + /> +{/if} diff --git a/pilot/src/lib/components/TaskCard.svelte b/pilot/src/lib/components/TaskCard.svelte new file mode 100644 index 00000000..6af0b4e8 --- /dev/null +++ b/pilot/src/lib/components/TaskCard.svelte @@ -0,0 +1,45 @@ + + + + +
onClick(task)} +> +
+

{task.title}

+ {#if isRunning} + + {/if} +
+ +
+ {#if task.type} + {task.type} + {/if} + #{task.id}{#if task.started_at && isRunning} · {timeAgo(task.started_at)}{:else if task.completed_at} · {timeAgo(task.completed_at)}{:else if task.created_at} · {timeAgo(task.created_at)}{/if} +
+
diff --git a/pilot/src/lib/components/TaskChat.svelte b/pilot/src/lib/components/TaskChat.svelte new file mode 100644 index 00000000..4e81b0c6 --- /dev/null +++ b/pilot/src/lib/components/TaskChat.svelte @@ -0,0 +1,237 @@ + + +
+ +
+
+ + Task Chat +
+
+ + + {taskChatState.connected ? 'Connected' : 'Disconnected'} + +
+
+ + +
+ {#if taskChatState.messages.length === 0 && !taskChatState.streaming} + +
+

+ Chat with the agent about this task. It has access to the task's sandbox. +

+
+ {:else} + {#each taskChatState.messages as message (message.id)} +
+ +
+ {#if message.role === 'user'} +
+ +
+ {:else} +
+ +
+ {/if} +
+ + +
+
+ {message.role === 'user' ? 'You' : 'Agent'} +
+ {#if message.role === 'assistant'} +
+ {@html renderMarkdown(message.content)} +
+ {:else} +
+ {message.content} +
+ {/if} + + + {#if message.toolInvocations?.length} +
+ {#each message.toolInvocations as tool} +
+ {tool.toolName} + {#if tool.state === 'result'} + done + {:else} + running... + {/if} +
+ {/each} +
+ {/if} +
+
+ {/each} + + + {#if taskChatState.streaming} +
+
+
+ +
+
+
+
+ Agent + +
+ {#if taskChatState.streamingContent} +
+ {@html renderMarkdown(taskChatState.streamingContent)} +
+ {:else} +
+ + + +
+ {/if} +
+
+ {/if} + {/if} + +
+
+ + +
+
+ + +
+
+
diff --git a/pilot/src/lib/components/TaskDetail.svelte b/pilot/src/lib/components/TaskDetail.svelte new file mode 100644 index 00000000..e5c1166b --- /dev/null +++ b/pilot/src/lib/components/TaskDetail.svelte @@ -0,0 +1,647 @@ + + + + +
+ +
+

{task.title}

+
+ {config.label} + {#if task.type} + {task.type} + {/if} + #{task.id} + {#if isRunning} + + {/if} + {#if task.preview_url} + + + Open Preview + + {/if} + {#if task.status === 'backlog' || task.status === 'blocked' || task.status === 'failed'} + + {/if} + {#if task.status === 'blocked' || task.status === 'failed'} + + {/if} + {#if isRunning} + + {/if} + {#if !isEditing} + + {/if} + {#if task.started_at} + started {timeAgo(task.started_at)} + {/if} + {#if task.completed_at} + · completed {timeAgo(task.completed_at)} + {:else if !task.started_at} + {timeAgo(task.created_at)} + {/if} +
+
+ + +
+ {#if hasFiles} + +
+ +
+ +
+ {#if isEditing} +
+ + +
+ + +
+
+ {:else} +
+ {#if task.body} +
{@html renderMarkdown(task.body)}
+ {:else} +

No description

+ {/if} + +
+ {/if} + + {#if subtasks.length > 0} +
+

Subtasks

+
+ {#each subtasks as subtask} +
+ {#if subtask.done} + + {:else} +
+ {/if} + {subtask.title} +
+ {/each} +
+
+ {/if} + + {#if task.output} +
+

Output

+
+ {@html renderMarkdown(task.output)} +
+
+ {/if} + + {#if isRunning} +
+

Live Output

+
+ {#if logs.length === 0} +

Waiting for output...

+ {:else} + {#each logs.slice(-20) as log} +
{log.content}
+ {/each} + {/if} +
+
+ {/if} +
+ + + {#if userId} +
+ +
+ {/if} +
+ + +
+ +
+
+ {#each taskFiles as file} + + {/each} +
+
+
+ {#each (hasHtmlFile ? ['preview', 'source'] as const : ['source'] as const) as mode} + + {/each} +
+ {#if hasHtmlFile} + + + + {/if} +
+
+ + +
+ {#if fileViewMode === 'preview' && hasHtmlFile} + + + {:else if fileLoading} +
+ +
+ {:else if fileViewMode === 'source'} + + {@html renderSourceHtml(fileContent)} +
+ {:else} + {@const ext = selectedFile ? fileExtension(selectedFile) : ''} + {#if ext === '.md' || ext === '.markdown'} +
+ {@html renderMarkdown(fileContent)} +
+ {:else if ext === '.json'} +
{(() => { try { return JSON.stringify(JSON.parse(fileContent), null, 2); } catch { return fileContent; } })()}
+ {:else} + + {@html renderSourceHtml(fileContent)} +
+ {/if} + {/if} +
+
+
+ {:else} + +
+ +
+ {#if isEditing} +
+ + +
+ + +
+
+ {:else} +
+ {#if task.body} +
{@html renderMarkdown(task.body)}
+ {:else} +

No description

+ {/if} + +
+ {/if} + + {#if subtasks.length > 0} +
+

Subtasks

+
+ {#each subtasks as subtask} +
+ {#if subtask.done} + + {:else} +
+ {/if} + {subtask.title} +
+ {/each} +
+
+ {/if} + + {#if task.output} +
+

Summary

+
+ {@html renderMarkdown(task.output)} +
+
+ {/if} + + {#if task.summary} +
+

Files Changed

+
+
{task.summary}
+
+
+ {/if} + + {#if isRunning} +
+

Live Output

+
+ {#if logs.length === 0} +

Waiting for output...

+ {:else} + {#each logs as log} +
{log.content}
+ {/each} + {/if} +
+
+
+ {/if} +
+ + + {#if userId} +
+ +
+ {/if} +
+ {/if} +
+ + +
+
+ + +{#if showRetryDialog} + + { showRetryDialog = false; retryFeedback = ''; }} + onclick={handleRetryBackdropClick} + > +
+
+

Add Feedback

+

Provide additional context or instructions for the AI to consider when retrying this task.

+
+
+ +
+
+ + +
+ +
+
+{/if} diff --git a/pilot/src/lib/server/agent.ts b/pilot/src/lib/server/agent.ts new file mode 100644 index 00000000..3f4bd25e --- /dev/null +++ b/pilot/src/lib/server/agent.ts @@ -0,0 +1,635 @@ +import { AIChatAgent } from "@cloudflare/ai-chat"; +import { createAnthropic } from "@ai-sdk/anthropic"; +import { streamText, generateText, convertToModelMessages, tool, stepCountIs } from "ai"; +import { z } from "zod"; +import * as db from "./db"; + +// Worker env type for agent context (not SvelteKit platform) +interface AgentEnv { + DB: D1Database; + TASKYOU_AGENT: DurableObjectNamespace; + TASK_WORKFLOW: unknown; // Workflow binding for TaskExecutionWorkflow + SANDBOX: DurableObjectNamespace; + SESSIONS: KVNamespace; + STORAGE?: R2Bucket; + ANTHROPIC_API_KEY?: string; + [key: string]: unknown; +} + +// State synced to all connected frontend clients in real-time +export type AgentState = { + tasks: Array<{ + id: number; + title: string; + status: string; + type: string; + project_id: string; + updated_at: string; + }>; + activeProject: string | null; + lastSync: string; +}; + +export class TaskYouAgent extends AIChatAgent { + initialState: AgentState = { + tasks: [], + activeProject: null, + lastSync: "", + }; + + onError(error: unknown) { + console.error("[TaskYouAgent] onError:", error); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async onChatMessage(onFinish: any, options?: any) { + try { + if (!this.messages.length) { + throw new Error("No messages to process"); + } + + const apiKey = this.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new Error("ANTHROPIC_API_KEY not configured"); + } + + const anthropic = createAnthropic({ apiKey }); + const modelMessages = await convertToModelMessages(this.messages as any); + + // Task-scoped instance: provide task context + sandbox tools + const taskId = this.getTaskId(); + if (taskId !== null) { + const userId = this.getUserId(); + const task = await db.getTask(this.env.DB, userId, taskId); + if (!task) { + throw new Error(`Task ${taskId} not found`); + } + + let project: import("$lib/types").Project | null = null; + if (task.project_id) { + project = await db.getProjectById(this.env.DB, task.project_id); + } + + const { getSandbox } = await import("@cloudflare/sandbox"); + const sandbox = getSandbox(this.env.SANDBOX as any, `task-${taskId}`, { + normalizeId: true, + sleepAfter: "15m", + }); + + const sandboxTools = buildSandboxTools(sandbox, this.env.DB, taskId, undefined, this.env.STORAGE); + + const result = streamText({ + model: anthropic("claude-sonnet-4-5-20250929"), + system: this.buildTaskChatSystemPrompt(task, project), + messages: modelMessages, + tools: sandboxTools, + onFinish, + stopWhen: stepCountIs(10), + }); + + return result.toUIMessageStreamResponse(); + } + + // Main chat instance: orchestrator tools + const result = streamText({ + model: anthropic("claude-sonnet-4-5-20250929"), + system: this.buildSystemPrompt(), + messages: modelMessages, + tools: this.getTools(), + onFinish, + stopWhen: stepCountIs(5), + }); + + return result.toUIMessageStreamResponse(); + } catch (err) { + console.error("[TaskYouAgent] onChatMessage error:", err); + throw err; + } + } + + getTaskId(): number | null { + const decoded = decodeURIComponent(this.name); + const parts = decoded.split(':'); + if (parts.length < 2) return null; + const segment = parts[1]; + if (segment.startsWith('task-')) { + const id = parseInt(segment.slice(5), 10); + return isNaN(id) ? null : id; + } + return null; + } + + private getTools() { + const self = this; + + return { + create_task: tool({ + description: "Create a new task on the board", + inputSchema: z.object({ + title: z.string().describe("Task title"), + body: z.string().optional().describe("Task description/details"), + type: z.enum(["code", "writing", "thinking"]).optional().describe("Task type"), + project_id: z.string().optional().describe("Project ID to assign to"), + }), + execute: async ({ title, body, type, project_id }) => { + const task = await db.createTask(self.env.DB, self.getUserId(), { + title, body, type, project_id, + }); + await self.syncTasks(); + return { id: task.id, title: task.title, status: task.status }; + }, + }), + list_tasks: tool({ + description: "List tasks, optionally filtered by status or project", + inputSchema: z.object({ + status: z.string().optional().describe("Filter by status: backlog, queued, processing, blocked, done, failed"), + project_id: z.string().optional().describe("Filter by project ID"), + }), + execute: async ({ status, project_id }) => { + const tasks = await db.listTasks(self.env.DB, self.getUserId(), { + status, project_id, includeClosed: true, + }); + return tasks.map((t) => ({ + id: t.id, title: t.title, status: t.status, + type: t.type, project_id: t.project_id, created_at: t.created_at, + })); + }, + }), + update_task: tool({ + description: "Update a task's title, body, status, or type", + inputSchema: z.object({ + task_id: z.number().describe("Task ID to update"), + title: z.string().optional(), + body: z.string().optional(), + status: z.enum(["backlog", "queued", "processing", "blocked", "done", "failed"]).optional(), + type: z.string().optional(), + }), + execute: async ({ task_id, ...data }) => { + const task = await db.updateTask(self.env.DB, self.getUserId(), task_id, data); + await self.syncTasks(); + return task + ? { id: task.id, title: task.title, status: task.status } + : { error: "Task not found" }; + }, + }), + delete_task: tool({ + description: "Delete a task from the board", + inputSchema: z.object({ + task_id: z.number().describe("Task ID to delete"), + }), + execute: async ({ task_id }) => { + const deleted = await db.deleteTask(self.env.DB, self.getUserId(), task_id); + await self.syncTasks(); + return { deleted }; + }, + }), + run_task: tool({ + description: "Execute a task using an AI agent with a sandbox environment. The task must already exist. The agent writes files, runs commands, and can serve web apps in the sandbox. If the task's project has a GitHub repo, the sandbox clones it first.", + inputSchema: z.object({ + task_id: z.number().describe("Task ID to execute"), + }), + execute: async ({ task_id }) => { + const apiKey = self.env.ANTHROPIC_API_KEY; + if (!apiKey) return { error: "ANTHROPIC_API_KEY not configured" }; + + await self.syncTasks(); + const result = await executeTask({ + db: self.env.DB, + sandbox: self.env.SANDBOX, + sessions: self.env.SESSIONS, + storage: self.env.STORAGE, + apiKey, + userId: self.getUserId(), + taskId: task_id, + }); + await self.syncTasks(); + return 'error' in result ? result : { executed: true, output: result.output }; + }, + }), + show_board: tool({ + description: "Show the current kanban board summary with task counts per column", + inputSchema: z.object({}), + execute: async () => { + const tasks = await db.listTasks(self.env.DB, self.getUserId(), { includeClosed: true }); + return { + backlog: tasks.filter((t) => t.status === "backlog").length, + running: tasks.filter((t) => ["queued", "processing"].includes(t.status)).length, + blocked: tasks.filter((t) => t.status === "blocked").length, + done: tasks.filter((t) => t.status === "done").length, + failed: tasks.filter((t) => t.status === "failed").length, + total: tasks.length, + }; + }, + }), + list_projects: tool({ + description: "List all projects", + inputSchema: z.object({}), + execute: async () => { + const projects = await db.listProjects(self.env.DB, self.getUserId()); + return projects.map((p) => ({ + id: p.id, name: p.name, color: p.color, + })); + }, + }), + }; + } + + // Sync tasks from D1 -> agent state -> all connected clients + async syncTasks() { + const tasks = await db.listTasks(this.env.DB, this.getUserId(), { includeClosed: true }); + this.setState({ + ...this.state, + tasks: tasks.map((t) => ({ + id: t.id, + title: t.title, + status: t.status, + type: t.type, + project_id: t.project_id, + updated_at: t.updated_at, + })), + lastSync: new Date().toISOString(), + }); + } + + // Workflow lifecycle callbacks — relay progress to connected frontend clients + async onWorkflowProgress(_name: string, _id: string, progress: unknown) { + this.broadcast(JSON.stringify({ type: 'workflow-progress', workflowId: _id, progress })); + await this.syncTasks(); + } + + async onWorkflowComplete(_name: string, _id: string, result?: unknown) { + this.broadcast(JSON.stringify({ type: 'workflow-complete', workflowId: _id, result })); + await this.syncTasks(); + } + + async onWorkflowError(_name: string, _id: string, error: string) { + this.broadcast(JSON.stringify({ type: 'workflow-error', workflowId: _id, error })); + await this.syncTasks(); + } + + private buildSystemPrompt(): string { + return `You are Pilot, the orchestrator for the TaskYou platform. +You manage tasks and delegate execution to specialized workflow agents. + +You can: +- Create tasks on the kanban board +- Execute tasks by spawning AI workflow agents via run_task +- List tasks, show board summary, list projects +- Update or delete tasks + +When a user asks you to do something (write a poem, fix a bug, draft an email, etc.): +1. Create a task for it using create_task +2. Execute it using run_task — this spawns an AI agent with a sandbox environment +3. The agent writes real files, runs commands, and can serve web apps +4. The board updates in real-time as the task progresses + +The sandbox environment gives each task its own Linux container with a real filesystem. +Tasks can produce files (code, documents) and web apps with live preview URLs. +Be concise and action-oriented. Create the task, run it, and let the user know it's underway.`; + } + + private buildTaskChatSystemPrompt(task: import("$lib/types").Task, project: import("$lib/types").Project | null): string { + let prompt = `You are Pilot, an AI assistant helping with a specific task in its sandbox environment. +You have access to the task's Linux sandbox with tools to read/write files and run commands. + +## Current Task +- **Title**: ${task.title} +- **Status**: ${task.status} +- **Type**: ${task.type}`; + + if (task.body) { + prompt += `\n- **Details**: ${task.body}`; + } + + if (project?.instructions) { + prompt += `\n\n## Project Instructions\n${project.instructions}`; + } + + prompt += `\n +## Capabilities +- Use read_file to inspect files in the sandbox at /workspace +- Use write_file to create or modify files +- Use run_command to execute shell commands (build, test, install packages, etc.) +- Use serve_app to start a web server and get a preview URL + +The sandbox may already contain files from a previous task execution. Explore first with run_command or read_file before making changes. +Be concise and helpful. Focus on the task at hand.`; + + return prompt; + } + + private getUserId(): string { + // The agent instance name is URL-encoded "{userId}:{chatId}" — decode and extract userId + const decoded = decodeURIComponent(this.name); + return decoded.split(':')[0]; + } +} + +// ── Task execution (used by both agent run_task tool and API route) ── + +export async function executeTask(opts: { + db: D1Database; + sandbox: DurableObjectNamespace; + sessions: KVNamespace; + storage?: R2Bucket; + apiKey: string; + userId: string; + taskId: number; +}): Promise<{ output: string } | { error: string }> { + const { db: database, sandbox: sandboxNs, sessions, storage, apiKey, userId, taskId } = opts; + + const task = await db.getTask(database, userId, taskId); + if (!task) return { error: "Task not found" }; + + await db.updateTask(database, userId, taskId, { status: "processing" }); + await db.addTaskLog(database, taskId, "system", `Starting task: ${task.title}`); + + try { + const { getSandbox } = await import("@cloudflare/sandbox"); + const sandbox = getSandbox(sandboxNs as any, `task-${taskId}`, { + normalizeId: true, + sleepAfter: "15m", + }); + + // Load project and check for GitHub repo + let gitContext: GitContext | undefined; + let project: import("$lib/types").Project | null = null; + if (task.project_id) { + project = await db.getProjectById(database, task.project_id); + if (project?.github_repo) { + const token = await sessions.get(`github-token:${userId}`); + if (!token) { + await db.updateTask(database, userId, taskId, { status: "failed", output: "GitHub not connected" }); + return { error: "GitHub not connected. Connect your GitHub account in project settings." }; + } + + const branch = project.github_branch || "main"; + await db.addTaskLog(database, taskId, "system", `Cloning ${project.github_repo} (branch: ${branch})...`); + + const cloneResult = await sandbox.exec( + `git clone --depth=50 --branch ${branch} https://x-access-token:${token}@github.com/${project.github_repo}.git /workspace`, + { cwd: "/" } as any, + ); + if (!cloneResult.success) { + await db.updateTask(database, userId, taskId, { status: "failed", output: `Clone failed: ${cloneResult.stderr}` }); + return { error: `Failed to clone repo: ${cloneResult.stderr}` }; + } + + await sandbox.exec('git config user.name "TaskYou Bot"', { cwd: "/workspace" } as any); + await sandbox.exec('git config user.email "bot@taskyou.dev"', { cwd: "/workspace" } as any); + const taskBranch = `taskyou/task-${taskId}`; + await sandbox.exec(`git checkout -b ${taskBranch}`, { cwd: "/workspace" } as any); + await sandbox.exec( + `git config credential.helper '!f() { echo "username=x-access-token"; echo "password=${token}"; }; f'`, + { cwd: "/workspace" } as any, + ); + + await db.addTaskLog(database, taskId, "system", `Cloned. Working on branch ${taskBranch}`); + gitContext = { token, repo: project.github_repo, defaultBranch: branch }; + } + } + + const sandboxTools = buildSandboxTools(sandbox, database, taskId, undefined, storage, gitContext); + const anthropic = createAnthropic({ apiKey }); + const response = await generateText({ + model: anthropic("claude-sonnet-4-5-20250929"), + system: buildTaskExecutionPrompt(project || undefined), + prompt: `Execute this task:\nTitle: ${task.title}\n${task.body ? `Details: ${task.body}` : ""}\n${task.type ? `Type: ${task.type}` : ""}\n\nComplete this task thoroughly.${gitContext ? " The repo is already cloned at /workspace. Read existing code before making changes. After making changes, commit, push, and create a PR." : " Use write_file to create output files. For web apps, use serve_app after writing files."}`, + tools: sandboxTools, + stopWhen: stepCountIs(15), + onStepFinish: async (event) => { + try { + for (const tc of event.toolCalls || []) { + const args = JSON.stringify((tc as any).args) || ''; + await db.addTaskLog(database, taskId, "tool", `${tc.toolName}(${args.slice(0, 200)})`); + } + for (const tr of event.toolResults || []) { + const result = JSON.stringify((tr as any).result) || ''; + await db.addTaskLog(database, taskId, "output", result.slice(0, 500)); + } + if (event.text) { + await db.addTaskLog(database, taskId, "text", event.text.slice(0, 500)); + } + } catch (logErr) { + console.error("[executeTask] onStepFinish logging error:", logErr); + } + }, + }); + + await db.updateTask(database, userId, taskId, { + status: "done", + output: response.text, + summary: response.text.slice(0, 200), + }); + await db.addTaskLog(database, taskId, "output", response.text); + return { output: response.text }; + } catch (execErr) { + console.error("[executeTask] Execution failed:", execErr); + const errMsg = execErr instanceof Error ? execErr.message : String(execErr); + await db.updateTask(database, userId, taskId, { + status: "failed", + output: `Execution error: ${errMsg}`, + }); + await db.addTaskLog(database, taskId, "error", errMsg); + return { error: `Execution failed: ${errMsg}` }; + } +} + +// ── Sandbox tools shared between agent and workflow ── + +function guessMimeType(path: string): string { + const ext = path.split(".").pop()?.toLowerCase() || ""; + const mimeMap: Record = { + html: "text/html", htm: "text/html", css: "text/css", js: "application/javascript", + ts: "text/typescript", json: "application/json", md: "text/markdown", + py: "text/x-python", rb: "text/x-ruby", sh: "text/x-shellscript", + txt: "text/plain", svg: "image/svg+xml", xml: "application/xml", + yaml: "text/yaml", yml: "text/yaml", toml: "text/toml", + }; + return mimeMap[ext] || "text/plain"; +} + +export type GitContext = { + token: string; + repo: string; // "owner/repo" + defaultBranch: string; +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function buildSandboxTools( + sandbox: any, + database: D1Database, + taskId: number, + hostname?: string, + storage?: R2Bucket, + gitContext?: GitContext, +) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const tools: Record = { + write_file: tool({ + description: "Write a file to the task sandbox. Use this to create deliverables — code, HTML, documents, etc.", + inputSchema: z.object({ + path: z.string().describe("File path relative to /workspace (e.g. 'index.html', 'src/app.js')"), + content: z.string().describe("File content"), + }), + execute: async ({ path, content }) => { + const fullPath = `/workspace/${path}`; + await sandbox.writeFile(fullPath, content, { encoding: "utf-8" }); + const mimeType = guessMimeType(path); + const sizeBytes = new TextEncoder().encode(content).length; + await db.addTaskFile(database, taskId, path, mimeType, sizeBytes); + // Persist to R2 so files survive sandbox shutdown + if (storage) { + await storage.put(`tasks/${taskId}/${path}`, content, { + httpMetadata: { contentType: mimeType }, + }); + } + return { written: true, path, size: sizeBytes }; + }, + }), + read_file: tool({ + description: "Read a file from the task sandbox", + inputSchema: z.object({ + path: z.string().describe("File path relative to /workspace"), + }), + execute: async ({ path }) => { + const file = await sandbox.readFile(`/workspace/${path}`, { encoding: "utf-8" }); + return { content: file.content, path }; + }, + }), + run_command: tool({ + description: "Run a shell command in the task sandbox (e.g. npm install, python script.py, build commands)", + inputSchema: z.object({ + command: z.string().describe("Shell command to execute"), + cwd: z.string().optional().describe("Working directory (default: /workspace)"), + }), + execute: async ({ command, cwd }) => { + const result = await sandbox.exec(command, { cwd: cwd || "/workspace" } as any); + return { + success: result.success, + exitCode: result.exitCode, + stdout: result.stdout?.slice(0, 5000), + stderr: result.stderr?.slice(0, 2000), + }; + }, + }), + serve_app: tool({ + description: "Start a web server in the sandbox and expose it via a preview URL. Use after writing HTML/JS/CSS files.", + inputSchema: z.object({ + port: z.number().optional().describe("Port to serve on (default: 8080)"), + directory: z.string().optional().describe("Directory to serve (default: /workspace)"), + }), + execute: async ({ port, directory }) => { + const servePort = port || 8080; + const serveDir = directory || "/workspace"; + await sandbox.startProcess(`npx serve -l ${servePort} ${serveDir}`, { + processId: `serve-${servePort}`, + cwd: "/workspace", + }); + const exposed = await sandbox.exposePort(servePort, { + ...(hostname ? { hostname } : {}), + } as any); + const previewUrl = exposed.url; + await db.updateTask(database, "", taskId, { preview_url: previewUrl } as any); + return { preview_url: previewUrl, port: servePort }; + }, + }), + }; + + // Add git tools when working on a cloned repo + if (gitContext) { + tools.create_pull_request = tool({ + description: "Commit all changes, push to GitHub, and create a pull request. Call this after making your code changes.", + inputSchema: z.object({ + title: z.string().describe("PR title"), + body: z.string().optional().describe("PR description"), + }), + execute: async ({ title, body }) => { + // Get current branch + const branchResult = await sandbox.exec("git rev-parse --abbrev-ref HEAD", { cwd: "/workspace" } as any); + const branch = branchResult.stdout?.trim(); + if (!branch) return { error: "Could not determine current branch" }; + + // Stage and commit + await sandbox.exec("git add -A", { cwd: "/workspace" } as any); + const commitResult = await sandbox.exec( + `git commit -m "${title.replace(/"/g, '\\"')}"`, + { cwd: "/workspace" } as any, + ); + if (!commitResult.success && !commitResult.stderr?.includes("nothing to commit")) { + return { error: `Commit failed: ${commitResult.stderr}` }; + } + + // Push + const pushResult = await sandbox.exec(`git push -u origin ${branch}`, { cwd: "/workspace" } as any); + if (!pushResult.success) { + return { error: `Push failed: ${pushResult.stderr}` }; + } + + // Create PR via GitHub API + const [owner, repo] = gitContext.repo.split("/"); + const prResponse = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls`, { + method: "POST", + headers: { + Authorization: `Bearer ${gitContext.token}`, + "Content-Type": "application/json", + "User-Agent": "TaskYou-Pilot", + }, + body: JSON.stringify({ + title, + body: body || `Created by TaskYou (task #${taskId})`, + head: branch, + base: gitContext.defaultBranch, + }), + }); + + if (!prResponse.ok) { + const errText = await prResponse.text(); + return { error: `Failed to create PR: ${errText}` }; + } + + const pr = await prResponse.json() as { html_url: string; number: number }; + return { success: true, pr_url: pr.html_url, pr_number: pr.number, branch }; + }, + }); + } + + return tools; +} + +export function buildTaskExecutionPrompt(project?: import("$lib/types").Project): string { + const hasRepo = !!project?.github_repo; + + let prompt = `You are a task execution agent with a sandbox environment. +You have access to a real Linux container with a filesystem.`; + + if (hasRepo) { + prompt += ` + +You are working on an existing codebase cloned from GitHub (${project!.github_repo}). +The code is at /workspace on branch taskyou/task-*. + +Guidelines: +- Use read_file and run_command to understand the existing code before making changes +- Make focused, minimal changes to accomplish the task +- Use run_command to run existing tests if available +- After making changes, use create_pull_request to commit, push, and open a PR +- Do NOT use write_file for files that already exist — use run_command with sed or similar, or read then write`; + } else { + prompt += ` + +When executing tasks, always produce output files: +- Use write_file to create deliverables (code, documents, reports) +- For web apps: write HTML/CSS/JS files, use run_command if a build step is needed, then call serve_app +- Use run_command for build steps (npm install, npm run build, etc.) +- Every task should produce at least one output file`; + } + + if (project?.instructions) { + prompt += `\n\nProject instructions:\n${project.instructions}`; + } + + prompt += `\n\nBe thorough but concise. Write clean, well-structured code.`; + + return prompt; +} diff --git a/pilot/src/lib/server/auth.ts b/pilot/src/lib/server/auth.ts new file mode 100644 index 00000000..9c01591d --- /dev/null +++ b/pilot/src/lib/server/auth.ts @@ -0,0 +1,156 @@ +import { findOrCreateUser } from './db'; + +// Session management using KV +const SESSION_TTL = 60 * 60 * 24 * 30; // 30 days + +export async function createSession( + kv: KVNamespace, + userId: string, +): Promise { + const sessionId = crypto.randomUUID(); + await kv.put(`session:${sessionId}`, userId, { expirationTtl: SESSION_TTL }); + return sessionId; +} + +export async function getSessionUserId( + kv: KVNamespace, + sessionId: string, +): Promise { + return kv.get(`session:${sessionId}`); +} + +export async function deleteSession(kv: KVNamespace, sessionId: string): Promise { + await kv.delete(`session:${sessionId}`); +} + +// OAuth helpers +interface GoogleUserInfo { + id: string; + email: string; + name: string; + picture: string; +} + +interface GitHubUserInfo { + id: number; + login: string; + name: string | null; + email: string | null; + avatar_url: string; +} + +export async function exchangeGoogleCode( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise { + // Exchange code for token + const tokenRes = await fetch('https://oauth2.googleapis.com/token', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + code, + client_id: clientId, + client_secret: clientSecret, + redirect_uri: redirectUri, + grant_type: 'authorization_code', + }), + }); + + if (!tokenRes.ok) throw new Error('Failed to exchange Google OAuth code'); + const tokenData = (await tokenRes.json()) as { access_token: string }; + + // Get user info + const userRes = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { + headers: { Authorization: `Bearer ${tokenData.access_token}` }, + }); + + if (!userRes.ok) throw new Error('Failed to get Google user info'); + return userRes.json() as Promise; +} + +export async function exchangeGitHubCode( + code: string, + clientId: string, + clientSecret: string, +): Promise { + // Exchange code for token + const tokenRes = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: clientId, + client_secret: clientSecret, + code, + }), + }); + + if (!tokenRes.ok) throw new Error('Failed to exchange GitHub OAuth code'); + const tokenData = (await tokenRes.json()) as { access_token: string }; + + // Get user info + const userRes = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + 'User-Agent': 'TaskYou-Pilot', + }, + }); + + if (!userRes.ok) throw new Error('Failed to get GitHub user info'); + const user = (await userRes.json()) as GitHubUserInfo; + + // Get primary email if not public + if (!user.email) { + const emailRes = await fetch('https://api.github.com/user/emails', { + headers: { + Authorization: `Bearer ${tokenData.access_token}`, + 'User-Agent': 'TaskYou-Pilot', + }, + }); + if (emailRes.ok) { + const emails = (await emailRes.json()) as Array<{ email: string; primary: boolean }>; + const primary = emails.find((e) => e.primary); + if (primary) user.email = primary.email; + } + } + + return user; +} + +export async function handleGoogleCallback( + db: D1Database, + kv: KVNamespace, + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise<{ sessionId: string; user: { id: string; email: string; name: string; avatar_url: string } }> { + const googleUser = await exchangeGoogleCode(code, clientId, clientSecret, redirectUri); + const user = await findOrCreateUser(db, 'google', googleUser.id, googleUser.email, googleUser.name, googleUser.picture); + const sessionId = await createSession(kv, user.id); + return { sessionId, user }; +} + +export async function handleGitHubCallback( + db: D1Database, + kv: KVNamespace, + code: string, + clientId: string, + clientSecret: string, +): Promise<{ sessionId: string; user: { id: string; email: string; name: string; avatar_url: string } }> { + const ghUser = await exchangeGitHubCode(code, clientId, clientSecret); + const user = await findOrCreateUser( + db, + 'github', + String(ghUser.id), + ghUser.email || `${ghUser.login}@github`, + ghUser.name || ghUser.login, + ghUser.avatar_url, + ); + const sessionId = await createSession(kv, user.id); + return { sessionId, user }; +} diff --git a/pilot/src/lib/server/db.ts b/pilot/src/lib/server/db.ts new file mode 100644 index 00000000..f8fa1d5f --- /dev/null +++ b/pilot/src/lib/server/db.ts @@ -0,0 +1,685 @@ +import type { Task, TaskFile, Chat, Message, Workspace, TaskStatus, TaskLog, Project, Model, Integration } from '$lib/types'; + +export async function initHostDB(db: D1Database): Promise { + await db.batch([ + db.prepare(` + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT NOT NULL DEFAULT '', + avatar_url TEXT NOT NULL DEFAULT '', + provider TEXT NOT NULL, + provider_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + owner_id TEXT NOT NULL, + autonomous_enabled INTEGER NOT NULL DEFAULT 0, + weekly_budget_cents INTEGER NOT NULL DEFAULT 10000, + budget_spent_cents INTEGER NOT NULL DEFAULT 0, + polling_interval INTEGER NOT NULL DEFAULT 30, + brand_voice TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (owner_id) REFERENCES users(id) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS memberships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + workspace_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'member', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, workspace_id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(id) + ) + `), + // Projects — organizational containers for tasks + db.prepare(` + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT 'default', + user_id TEXT NOT NULL DEFAULT 'dev-user', + name TEXT NOT NULL DEFAULT 'Default', + instructions TEXT NOT NULL DEFAULT '', + color TEXT NOT NULL DEFAULT '#888888', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL DEFAULT 'default', + user_id TEXT NOT NULL, + title TEXT NOT NULL, + body TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'backlog', + type TEXT NOT NULL DEFAULT 'code', + project_id TEXT, + chat_id TEXT, + parent_task_id INTEGER, + subtasks_json TEXT, + cost_cents INTEGER NOT NULL DEFAULT 0, + output TEXT, + summary TEXT, + approval_status TEXT, + dangerous_mode INTEGER NOT NULL DEFAULT 0, + scheduled_at TEXT, + recurrence TEXT, + last_run_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + completed_at TEXT, + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (parent_task_id) REFERENCES tasks(id), + FOREIGN KEY (project_id) REFERENCES projects(id), + FOREIGN KEY (chat_id) REFERENCES chats(id) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS task_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + line_type TEXT NOT NULL DEFAULT 'text', + content TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS chats ( + id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL DEFAULT 'default', + user_id TEXT NOT NULL, + project_id TEXT, + title TEXT NOT NULL DEFAULT 'New Chat', + model_id TEXT NOT NULL DEFAULT 'claude-sonnet-4-5-20250929', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (project_id) REFERENCES projects(id) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS messages ( + id TEXT PRIMARY KEY, + chat_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL DEFAULT '', + input_tokens INTEGER NOT NULL DEFAULT 0, + output_tokens INTEGER NOT NULL DEFAULT 0, + model_id TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (chat_id) REFERENCES chats(id) ON DELETE CASCADE + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS integrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL DEFAULT 'default', + provider TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'disconnected', + external_id TEXT, + access_token_encrypted TEXT, + refresh_token_encrypted TEXT, + token_expires_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS models ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + provider TEXT NOT NULL, + api_id TEXT NOT NULL, + context_window INTEGER NOT NULL DEFAULT 200000, + input_price_per_million REAL NOT NULL DEFAULT 0, + output_price_per_million REAL NOT NULL DEFAULT 0, + supports_tools INTEGER NOT NULL DEFAULT 1, + supports_streaming INTEGER NOT NULL DEFAULT 1 + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS settings ( + user_id TEXT NOT NULL, + key TEXT NOT NULL, + value TEXT NOT NULL DEFAULT '', + PRIMARY KEY (user_id, key), + FOREIGN KEY (user_id) REFERENCES users(id) + ) + `), + db.prepare(` + CREATE TABLE IF NOT EXISTS task_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + task_id INTEGER NOT NULL, + path TEXT NOT NULL, + mime_type TEXT NOT NULL DEFAULT 'text/plain', + size_bytes INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + UNIQUE(task_id, path) + ) + `), + ]); + + // Add preview_url column if missing (safe to run repeatedly) + await db.prepare(`ALTER TABLE tasks ADD COLUMN preview_url TEXT`).run().catch(() => {}); + + // Add GitHub repo fields to projects (safe to run repeatedly) + await db.prepare(`ALTER TABLE projects ADD COLUMN github_repo TEXT`).run().catch(() => {}); + await db.prepare(`ALTER TABLE projects ADD COLUMN github_branch TEXT`).run().catch(() => {}); + + // Seed default models + await db.prepare(`INSERT OR IGNORE INTO models (id, name, provider, api_id, context_window, input_price_per_million, output_price_per_million) VALUES + ('claude-sonnet-4-5-20250929', 'Claude Sonnet 4.5', 'anthropic', 'claude-sonnet-4-5-20250929', 200000, 3, 15), + ('claude-haiku-4-5-20251001', 'Claude Haiku 4.5', 'anthropic', 'claude-haiku-4-5-20251001', 200000, 0.8, 4), + ('claude-opus-4-6', 'Claude Opus 4.6', 'anthropic', 'claude-opus-4-6', 200000, 15, 75) + `).run(); + + // Seed default workspace for dev + await db.prepare(`INSERT OR IGNORE INTO workspaces (id, name, owner_id) VALUES ('default', 'Personal', 'dev-user')`).run(); +} + +// ── User operations ── + +export async function findOrCreateUser( + db: D1Database, + provider: string, + providerId: string, + email: string, + name: string, + avatarUrl: string, +): Promise<{ id: string; email: string; name: string; avatar_url: string }> { + const id = `${provider}:${providerId}`; + const existing = await db + .prepare('SELECT id, email, name, avatar_url FROM users WHERE id = ?') + .bind(id) + .first<{ id: string; email: string; name: string; avatar_url: string }>(); + + if (existing) { + await db + .prepare("UPDATE users SET name = ?, avatar_url = ?, updated_at = datetime('now') WHERE id = ?") + .bind(name, avatarUrl, id) + .run(); + return { ...existing, name, avatar_url: avatarUrl }; + } + + await db + .prepare('INSERT INTO users (id, email, name, avatar_url, provider, provider_id) VALUES (?, ?, ?, ?, ?, ?)') + .bind(id, email, name, avatarUrl, provider, providerId) + .run(); + + return { id, email, name, avatar_url: avatarUrl }; +} + +export async function getUserById( + db: D1Database, + userId: string, +): Promise<{ id: string; email: string; name: string; avatar_url: string } | null> { + return db + .prepare('SELECT id, email, name, avatar_url FROM users WHERE id = ?') + .bind(userId) + .first(); +} + +// ── Task operations ── + +export async function listTasks( + db: D1Database, + userId: string, + options: { status?: string; project_id?: string; type?: string; includeClosed?: boolean } = {}, +): Promise { + let query = 'SELECT * FROM tasks WHERE user_id = ?'; + const params: (string | number)[] = [userId]; + + if (options.status) { + query += ' AND status = ?'; + params.push(options.status); + } else if (!options.includeClosed) { + query += " AND status NOT IN ('done', 'failed')"; + } + + if (options.project_id) { + query += ' AND project_id = ?'; + params.push(options.project_id); + } + + if (options.type) { + query += ' AND type = ?'; + params.push(options.type); + } + + query += ' ORDER BY updated_at DESC'; + + const result = await db.prepare(query).bind(...params).all(); + return (result.results || []).map(rowToTask); +} + +export async function getTask(db: D1Database, userId: string, taskId: number): Promise { + const row = await db + .prepare('SELECT * FROM tasks WHERE id = ? AND user_id = ?') + .bind(taskId, userId) + .first(); + return row ? rowToTask(row) : null; +} + +export async function createTask( + db: D1Database, + userId: string, + data: { title: string; body?: string; type?: string; project_id?: string; chat_id?: string }, +): Promise { + const result = await db + .prepare( + 'INSERT INTO tasks (user_id, title, body, type, project_id, chat_id) VALUES (?, ?, ?, ?, ?, ?) RETURNING *', + ) + .bind(userId, data.title, data.body || '', data.type || 'code', data.project_id || null, data.chat_id || null) + .first(); + + return rowToTask(result!); +} + +export async function updateTask( + db: D1Database, + userId: string, + taskId: number, + data: { title?: string; body?: string; status?: TaskStatus; type?: string; project_id?: string; output?: string; summary?: string; preview_url?: string }, +): Promise { + const sets: string[] = []; + const params: (string | number)[] = []; + + if (data.title !== undefined) { sets.push('title = ?'); params.push(data.title); } + if (data.body !== undefined) { sets.push('body = ?'); params.push(data.body); } + if (data.status !== undefined) { sets.push('status = ?'); params.push(data.status); } + if (data.type !== undefined) { sets.push('type = ?'); params.push(data.type); } + if (data.project_id !== undefined) { sets.push('project_id = ?'); params.push(data.project_id); } + if (data.output !== undefined) { sets.push('output = ?'); params.push(data.output); } + if (data.summary !== undefined) { sets.push('summary = ?'); params.push(data.summary); } + if (data.preview_url !== undefined) { sets.push('preview_url = ?'); params.push(data.preview_url); } + + if (sets.length === 0) return getTask(db, userId, taskId); + + sets.push("updated_at = datetime('now')"); + + if (data.status === 'processing' || data.status === 'queued') { + sets.push("started_at = COALESCE(started_at, datetime('now'))"); + } + if (data.status === 'done') { + sets.push("completed_at = datetime('now')"); + } + + params.push(taskId, userId); + + const row = await db + .prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ? AND user_id = ? RETURNING *`) + .bind(...params) + .first(); + + return row ? rowToTask(row) : null; +} + +export async function deleteTask(db: D1Database, userId: string, taskId: number): Promise { + const result = await db + .prepare('DELETE FROM tasks WHERE id = ? AND user_id = ?') + .bind(taskId, userId) + .run(); + return (result.meta?.changes ?? 0) > 0; +} + +// ── Chat operations ── + +export async function listChats(db: D1Database, userId: string): Promise { + const result = await db + .prepare('SELECT * FROM chats WHERE user_id = ? ORDER BY updated_at DESC') + .bind(userId) + .all(); + return result.results || []; +} + +export async function getChat(db: D1Database, userId: string, chatId: string): Promise { + return db + .prepare('SELECT * FROM chats WHERE id = ? AND user_id = ?') + .bind(chatId, userId) + .first(); +} + +export async function createChat( + db: D1Database, + userId: string, + data: { title?: string; model_id?: string; project_id?: string }, +): Promise { + const id = crypto.randomUUID(); + const result = await db + .prepare('INSERT INTO chats (id, user_id, title, model_id, project_id) VALUES (?, ?, ?, ?, ?) RETURNING *') + .bind(id, userId, data.title || 'New Chat', data.model_id || 'claude-sonnet-4-5-20250929', data.project_id || null) + .first(); + return result!; +} + +export async function updateChat( + db: D1Database, + userId: string, + chatId: string, + data: { title?: string; model_id?: string }, +): Promise { + const sets: string[] = []; + const params: (string)[] = []; + + if (data.title !== undefined) { sets.push('title = ?'); params.push(data.title); } + if (data.model_id !== undefined) { sets.push('model_id = ?'); params.push(data.model_id); } + + if (sets.length === 0) return getChat(db, userId, chatId); + + sets.push("updated_at = datetime('now')"); + params.push(chatId, userId); + + const row = await db + .prepare(`UPDATE chats SET ${sets.join(', ')} WHERE id = ? AND user_id = ? RETURNING *`) + .bind(...params) + .first(); + + return row; +} + +export async function deleteChat(db: D1Database, userId: string, chatId: string): Promise { + const result = await db + .prepare('DELETE FROM chats WHERE id = ? AND user_id = ?') + .bind(chatId, userId) + .run(); + return (result.meta?.changes ?? 0) > 0; +} + +// ── Message operations ── + +export async function listMessages(db: D1Database, chatId: string): Promise { + const result = await db + .prepare('SELECT * FROM messages WHERE chat_id = ? ORDER BY created_at ASC') + .bind(chatId) + .all(); + return result.results || []; +} + +export async function createMessage( + db: D1Database, + data: { chat_id: string; role: string; content: string; model_id?: string; input_tokens?: number; output_tokens?: number }, +): Promise { + const id = crypto.randomUUID(); + const result = await db + .prepare('INSERT INTO messages (id, chat_id, role, content, model_id, input_tokens, output_tokens) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *') + .bind(id, data.chat_id, data.role, data.content, data.model_id || null, data.input_tokens || 0, data.output_tokens || 0) + .first(); + return result!; +} + +// ── Model operations ── + +export async function listModels(db: D1Database): Promise { + const result = await db.prepare('SELECT * FROM models ORDER BY name').all(); + return (result.results || []).map(m => ({ + ...m, + supports_tools: Boolean(m.supports_tools), + supports_streaming: Boolean(m.supports_streaming), + })); +} + +// ── Workspace operations ── + +export async function listWorkspaces(db: D1Database, userId: string): Promise { + const result = await db + .prepare( + `SELECT w.* FROM workspaces w + LEFT JOIN memberships m ON m.workspace_id = w.id AND m.user_id = ? + WHERE w.owner_id = ? OR m.user_id = ? + ORDER BY w.name`, + ) + .bind(userId, userId, userId) + .all(); + return result.results || []; +} + +export async function getWorkspace(db: D1Database, id: string): Promise { + return db.prepare('SELECT * FROM workspaces WHERE id = ?').bind(id).first(); +} + +export async function createWorkspace( + db: D1Database, + ownerId: string, + data: { name: string }, +): Promise { + const id = crypto.randomUUID(); + const result = await db + .prepare('INSERT INTO workspaces (id, name, owner_id) VALUES (?, ?, ?) RETURNING *') + .bind(id, data.name, ownerId) + .first(); + await db + .prepare('INSERT INTO memberships (user_id, workspace_id, role) VALUES (?, ?, ?)') + .bind(ownerId, id, 'owner') + .run(); + return result!; +} + +export async function updateWorkspace( + db: D1Database, + id: string, + data: { name?: string; autonomous_enabled?: boolean; weekly_budget_cents?: number }, +): Promise { + const sets: string[] = []; + const params: (string | number)[] = []; + + if (data.name !== undefined) { sets.push('name = ?'); params.push(data.name); } + if (data.autonomous_enabled !== undefined) { sets.push('autonomous_enabled = ?'); params.push(data.autonomous_enabled ? 1 : 0); } + if (data.weekly_budget_cents !== undefined) { sets.push('weekly_budget_cents = ?'); params.push(data.weekly_budget_cents); } + + if (sets.length === 0) return null; + + sets.push("updated_at = datetime('now')"); + params.push(id); + + return db + .prepare(`UPDATE workspaces SET ${sets.join(', ')} WHERE id = ? RETURNING *`) + .bind(...params) + .first(); +} + +export async function deleteWorkspace(db: D1Database, id: string): Promise { + const result = await db.prepare('DELETE FROM workspaces WHERE id = ?').bind(id).run(); + return (result.meta?.changes ?? 0) > 0; +} + +// ── Integration operations ── + +export async function listIntegrations(db: D1Database): Promise { + const result = await db + .prepare('SELECT id, workspace_id, provider, status, external_id, created_at, updated_at FROM integrations ORDER BY provider') + .all(); + return result.results || []; +} + +// ── Project operations ── + +export async function listProjects(db: D1Database, userId: string): Promise { + const result = await db + .prepare('SELECT * FROM projects WHERE user_id = ? ORDER BY created_at DESC') + .bind(userId) + .all(); + return result.results || []; +} + +export async function createProject( + db: D1Database, + userId: string, + data: { name?: string; instructions?: string; color?: string; github_repo?: string; github_branch?: string }, +): Promise { + const id = crypto.randomUUID(); + const result = await db + .prepare('INSERT INTO projects (id, user_id, name, instructions, color, github_repo, github_branch) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING *') + .bind(id, userId, data.name || 'Default', data.instructions || '', data.color || '#888888', data.github_repo || null, data.github_branch || null) + .first(); + return result!; +} + +export async function getProjectById( + db: D1Database, + projectId: string, +): Promise { + return db + .prepare('SELECT * FROM projects WHERE id = ?') + .bind(projectId) + .first(); +} + +export async function updateProject( + db: D1Database, + projectId: string, + data: { name?: string; instructions?: string; color?: string; github_repo?: string; github_branch?: string }, +): Promise { + const sets: string[] = []; + const params: (string | number | null)[] = []; + + if (data.name !== undefined) { sets.push('name = ?'); params.push(data.name); } + if (data.instructions !== undefined) { sets.push('instructions = ?'); params.push(data.instructions); } + if (data.color !== undefined) { sets.push('color = ?'); params.push(data.color); } + if (data.github_repo !== undefined) { sets.push('github_repo = ?'); params.push(data.github_repo || null); } + if (data.github_branch !== undefined) { sets.push('github_branch = ?'); params.push(data.github_branch || null); } + + if (sets.length === 0) return getProjectById(db, projectId); + + sets.push("updated_at = datetime('now')"); + params.push(projectId); + + return db + .prepare(`UPDATE projects SET ${sets.join(', ')} WHERE id = ? RETURNING *`) + .bind(...params) + .first(); +} + +export async function deleteProject(db: D1Database, projectId: string): Promise { + const result = await db + .prepare('DELETE FROM projects WHERE id = ?') + .bind(projectId) + .run(); + return (result.meta?.changes ?? 0) > 0; +} + +// ── Task logs ── + +export async function getTaskLogs( + db: D1Database, + userId: string, + taskId: number, + limit = 200, +): Promise { + const result = await db + .prepare( + `SELECT tl.* FROM task_logs tl + JOIN tasks t ON t.id = tl.task_id + WHERE tl.task_id = ? AND t.user_id = ? + ORDER BY tl.id DESC LIMIT ?`, + ) + .bind(taskId, userId, limit) + .all(); + return result.results || []; +} + +export async function addTaskLog( + db: D1Database, + taskId: number, + lineType: string, + content: string, +): Promise { + const result = await db + .prepare( + 'INSERT INTO task_logs (task_id, line_type, content) VALUES (?, ?, ?) RETURNING *', + ) + .bind(taskId, lineType, content) + .first(); + return result!; +} + +// ── Settings ── + +export async function getSettings(db: D1Database, userId: string): Promise> { + const result = await db + .prepare('SELECT key, value FROM settings WHERE user_id = ?') + .bind(userId) + .all<{ key: string; value: string }>(); + + const settings: Record = {}; + for (const row of result.results || []) { + settings[row.key] = row.value; + } + return settings; +} + +export async function updateSettings( + db: D1Database, + userId: string, + data: Record, +): Promise { + const stmts = Object.entries(data).map(([key, value]) => + db + .prepare('INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, ?, ?)') + .bind(userId, key, value), + ); + if (stmts.length > 0) { + await db.batch(stmts); + } +} + +// ── Task files ── + +export async function addTaskFile( + db: D1Database, + taskId: number, + path: string, + mimeType: string, + sizeBytes: number, +): Promise { + const result = await db + .prepare( + `INSERT INTO task_files (task_id, path, mime_type, size_bytes) + VALUES (?, ?, ?, ?) + ON CONFLICT(task_id, path) DO UPDATE SET mime_type = excluded.mime_type, size_bytes = excluded.size_bytes + RETURNING *`, + ) + .bind(taskId, path, mimeType, sizeBytes) + .first(); + return result!; +} + +export async function listTaskFiles( + db: D1Database, + userId: string, + taskId: number, +): Promise { + const result = await db + .prepare( + `SELECT tf.* FROM task_files tf + JOIN tasks t ON t.id = tf.task_id + WHERE tf.task_id = ? AND t.user_id = ? + ORDER BY tf.path ASC`, + ) + .bind(taskId, userId) + .all(); + return result.results || []; +} + +// ── Helpers ── + +function rowToTask(row: Task & { user_id?: string; dangerous_mode: number | boolean }): Task { + const { user_id, ...task } = row as Task & { user_id?: string }; + return { + ...task, + dangerous_mode: Boolean(task.dangerous_mode), + }; +} diff --git a/pilot/src/lib/server/workflow.ts b/pilot/src/lib/server/workflow.ts new file mode 100644 index 00000000..32d6be4a --- /dev/null +++ b/pilot/src/lib/server/workflow.ts @@ -0,0 +1,123 @@ +import { AgentWorkflow, type AgentWorkflowEvent, type AgentWorkflowStep } from "agents/workflows"; +import { generateText, stepCountIs } from "ai"; +import { createAnthropic } from "@ai-sdk/anthropic"; +import * as db from "./db"; +import { buildSandboxTools, buildTaskExecutionPrompt } from "./agent"; +import type { TaskYouAgent } from "./agent"; +import type { GitContext } from "./agent"; + +// Worker env type +interface WorkflowEnv { + DB: D1Database; + SANDBOX: DurableObjectNamespace; + SESSIONS?: KVNamespace; + STORAGE?: R2Bucket; + ANTHROPIC_API_KEY?: string; + [key: string]: unknown; +} + +type TaskParams = { taskId: number; userId: string }; + +type TaskProgress = { + step: string; + status: string; + taskId: number; + title?: string; +}; + +export class TaskExecutionWorkflow extends AgentWorkflow { + async run(event: AgentWorkflowEvent, step: AgentWorkflowStep) { + const { taskId, userId } = event.payload; + const env = this.env as unknown as WorkflowEnv; + + // Step 1: Load and mark task as processing + const task = await step.do("mark-processing", async () => { + const t = await db.getTask(env.DB, userId, taskId); + if (!t) throw new Error(`Task ${taskId} not found`); + await db.updateTask(env.DB, userId, taskId, { status: "processing" }); + return { id: t.id, title: t.title, body: t.body, type: t.type }; + }); + + await this.reportProgress({ step: "processing", status: "running", taskId, title: task.title }); + + // Step 2: Execute with AI + sandbox + const result = await step.do("ai-execute", { retries: { limit: 2, delay: "5 seconds", backoff: "linear" } }, async () => { + const anthropic = createAnthropic({ + apiKey: env.ANTHROPIC_API_KEY, + }); + + // Create sandbox for this task (dynamic import to avoid Vite bundling @cloudflare/sandbox) + const { getSandbox } = await import("@cloudflare/sandbox"); + const sandbox = getSandbox(env.SANDBOX as any, `task-${taskId}`, { + normalizeId: true, + sleepAfter: "15m", + }); + + // Load project and check for GitHub repo + let gitContext: GitContext | undefined; + let project: import("$lib/types").Project | null = null; + const loadedTask = await db.getTask(env.DB, userId, taskId); + if (loadedTask?.project_id) { + project = await db.getProjectById(env.DB, loadedTask.project_id); + if (project?.github_repo && env.SESSIONS) { + const token = await env.SESSIONS.get(`github-token:${userId}`); + if (token) { + const branch = project.github_branch || "main"; + + // Clone repo + const cloneResult = await sandbox.exec( + `git clone --depth=50 --branch ${branch} https://x-access-token:${token}@github.com/${project.github_repo}.git /workspace`, + { cwd: "/" } as any, + ); + if (cloneResult.success) { + await sandbox.exec('git config user.name "TaskYou Bot"', { cwd: "/workspace" } as any); + await sandbox.exec('git config user.email "bot@taskyou.dev"', { cwd: "/workspace" } as any); + await sandbox.exec(`git checkout -b taskyou/task-${taskId}`, { cwd: "/workspace" } as any); + await sandbox.exec( + `git config credential.helper '!f() { echo "username=x-access-token"; echo "password=${token}"; }; f'`, + { cwd: "/workspace" } as any, + ); + gitContext = { token, repo: project.github_repo, defaultBranch: branch }; + } + } + } + } + + const sandboxTools = buildSandboxTools(sandbox, env.DB, taskId, undefined, env.STORAGE, gitContext); + + const prompt = `Execute this task:\nTitle: ${task.title}\n${task.body ? `Details: ${task.body}` : ""}\n${task.type ? `Type: ${task.type}` : ""}\n\nComplete this task thoroughly.${gitContext ? " The repo is already cloned at /workspace. Read existing code before making changes. After making changes, commit, push, and create a PR." : " Use write_file to create output files. For web apps, use serve_app after writing files."}`; + + const response = await generateText({ + model: anthropic("claude-sonnet-4-5-20250929"), + system: buildTaskExecutionPrompt(project || undefined), + prompt, + tools: sandboxTools, + stopWhen: stepCountIs(15), + }); + + const usage = response.usage as { promptTokens?: number; completionTokens?: number } | undefined; + return { + output: response.text, + inputTokens: usage?.promptTokens || 0, + outputTokens: usage?.completionTokens || 0, + }; + }); + + await this.reportProgress({ step: "saving", status: "running", taskId }); + + // Step 3: Save results and mark done + await step.do("save-results", async () => { + await db.updateTask(env.DB, userId, taskId, { + status: "done", + output: result.output, + summary: result.output.slice(0, 200), + }); + await db.addTaskLog(env.DB, taskId, "output", result.output); + }); + + // Notify agent to sync state + await this.reportProgress({ step: "complete", status: "done", taskId }); + + return result; + } +} diff --git a/pilot/src/lib/stores/agent-ws.ts b/pilot/src/lib/stores/agent-ws.ts new file mode 100644 index 00000000..5d422828 --- /dev/null +++ b/pilot/src/lib/stores/agent-ws.ts @@ -0,0 +1,49 @@ +// WebSocket send helpers for the agent connection. +// The WebSocket itself is created in +page.svelte and stored on globalThis.__agentWs. + +import type { AgentChatMessage } from '$lib/types'; + +function getWs(): WebSocket | null { + const G = globalThis as any; + return G.__agentWs?.ws ?? null; +} + +export function sendChatViaWebSocket(messages: AgentChatMessage[]): string | null { + const ws = getWs(); + if (!ws || ws.readyState !== WebSocket.OPEN) { + console.error('[agent-ws] WebSocket not connected'); + return null; + } + + const requestId = crypto.randomUUID(); + + ws.send(JSON.stringify({ + type: 'cf_agent_use_chat_request', + id: requestId, + init: { + method: 'POST', + body: JSON.stringify({ + messages: messages.map(m => ({ + id: m.id, + role: m.role, + content: m.content, + createdAt: m.createdAt, + })), + }), + }, + })); + + return requestId; +} + +export function sendAgentMessage(message: unknown) { + const ws = getWs(); + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(message)); + } +} + +export function isConnected(): boolean { + const G = globalThis as any; + return G.__agentWs?.connected ?? false; +} diff --git a/pilot/src/lib/stores/agent.svelte.ts b/pilot/src/lib/stores/agent.svelte.ts new file mode 100644 index 00000000..6331b828 --- /dev/null +++ b/pilot/src/lib/stores/agent.svelte.ts @@ -0,0 +1,195 @@ +// Svelte 5 reactive state for agent connection. +// WebSocket logic lives in Dashboard.svelte (inline) and agent-ws.ts. +// This file manages reactive state updates. + +import type { AgentChatMessage } from '$lib/types'; + +// Messages restored from agent DO storage on WebSocket connect +export let restoredMessages: AgentChatMessage[] = []; + +// Callback for when messages are restored (set by chat store to avoid circular dep) +let onMessagesRestored: (() => void) | null = null; +export function setOnMessagesRestored(cb: () => void) { onMessagesRestored = cb; } + +// Callback for when agent syncs tasks (triggers fetchTasks in tasks store) +let onTasksUpdated: (() => void) | null = null; +export function setOnTasksUpdated(cb: () => void) { onTasksUpdated = cb; } + +/** Extract text content from AI SDK UI message format (which uses `parts`) */ +function extractTextContent(msg: any): string { + // AI SDK UI messages have `parts` array with type/text entries + if (msg.parts && Array.isArray(msg.parts)) { + return msg.parts + .filter((p: any) => p.type === 'text') + .map((p: any) => p.text) + .join(''); + } + // Fallback: plain content string + if (typeof msg.content === 'string') return msg.content; + return ''; +} + +export const agentState = $state({ + connected: false, + tasks: [] as Array<{ + id: number; + title: string; + status: string; + type: string; + project_id: string; + updated_at: string; + }>, + lastSync: '', +}); + +export const workflowState = $state({ + activeWorkflows: {} as Record, +}); + +export const agentChatStream = $state({ + streaming: false, + streamingContent: '', + lastError: null as string | null, + completedMessage: null as AgentChatMessage | null, +}); + +/** + * Handle incoming WebSocket messages and route to reactive state. + * Called by Dashboard.svelte's inline WebSocket handler. + */ +export function handleAgentMessage(data: any) { + if (data.type === '_connected') { + agentState.connected = true; + return; + } + if (data.type === '_disconnected') { + agentState.connected = false; + return; + } + + // Handle state sync from agent + if (data.type === 'cf_agent_state' || data.type === 'cf_agent_state_update') { + const state = data.state || data; + if (state.tasks) { + agentState.tasks = state.tasks; + onTasksUpdated?.(); + } + if (state.lastSync) { + agentState.lastSync = state.lastSync; + } + return; + } + + // Handle chat streaming response (AIChatAgent protocol) + if (data.type === 'cf_agent_use_chat_response') { + handleChatResponse(data); + return; + } + + // Handle workflow lifecycle messages + if (data.type === 'workflow-progress') { + const id = data.workflowId; + if (id) { + workflowState.activeWorkflows[id] = { + status: 'running', + progress: data.progress, + }; + } + return; + } + if (data.type === 'workflow-complete') { + const id = data.workflowId; + if (id) { + workflowState.activeWorkflows[id] = { + status: 'complete', + result: data.result, + }; + } + return; + } + if (data.type === 'workflow-error') { + const id = data.workflowId; + if (id) { + workflowState.activeWorkflows[id] = { + status: 'error', + error: data.error, + }; + } + return; + } + + // Restore persisted chat messages from the agent's DO storage + if (data.type === 'cf_agent_chat_messages') { + if (data.messages && Array.isArray(data.messages)) { + restoredMessages = data.messages + .map((m: any) => ({ + id: m.id || crypto.randomUUID(), + role: m.role, + content: extractTextContent(m), + createdAt: m.createdAt || new Date().toISOString(), + })) + .filter((m: any) => m.content.trim() !== ''); + onMessagesRestored?.(); + } + return; + } + + // Silently ignore protocol messages + if (data.type === 'cf_agent_identity' || data.type === 'cf_agent_mcp_servers') { + return; + } +} + +/** + * Reset chat-related streaming state when switching chats. + * Called before connecting to a new agent instance. + */ +export function resetChatState() { + agentChatStream.streaming = false; + agentChatStream.streamingContent = ''; + agentChatStream.completedMessage = null; + agentChatStream.lastError = null; + agentState.connected = false; + restoredMessages = []; +} + +function handleChatResponse(data: { id: string; body: string; done: boolean; error?: boolean; continuation?: boolean }) { + if (data.error) { + agentChatStream.streaming = false; + agentChatStream.streamingContent = ''; + agentChatStream.lastError = data.body || 'Unknown error'; + return; + } + + if (data.body) { + try { + const parsed = JSON.parse(data.body); + switch (parsed.type) { + case 'text-delta': + agentChatStream.streamingContent += parsed.delta; + break; + case 'error': + agentChatStream.lastError = parsed.errorText || 'Unknown error'; + agentChatStream.streaming = false; + agentChatStream.streamingContent = ''; + return; + // Ignore: start, start-step, text-start, text-end, finish-step, finish + } + } catch { + // Body is not JSON — might be empty string or old SSE format + } + } + + if (data.done) { + if (agentChatStream.streamingContent) { + agentChatStream.completedMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: agentChatStream.streamingContent, + createdAt: new Date().toISOString(), + }; + } + agentChatStream.streaming = false; + agentChatStream.streamingContent = ''; + } +} diff --git a/pilot/src/lib/stores/auth.svelte.ts b/pilot/src/lib/stores/auth.svelte.ts new file mode 100644 index 00000000..f3072fd3 --- /dev/null +++ b/pilot/src/lib/stores/auth.svelte.ts @@ -0,0 +1,27 @@ +import type { User } from '$lib/types'; +import { auth as authApi } from '$lib/api/client'; + +export const authState = $state({ + user: null as User | null, + loading: true, +}); + +export async function fetchUser() { + authState.loading = true; + try { + authState.user = await authApi.getMe(); + } catch { + authState.user = null; + } finally { + authState.loading = false; + } +} + +export async function logout() { + try { + await authApi.logout(); + } catch { + // ignore + } + authState.user = null; +} diff --git a/pilot/src/lib/stores/chat.svelte.ts b/pilot/src/lib/stores/chat.svelte.ts new file mode 100644 index 00000000..2bdc6e78 --- /dev/null +++ b/pilot/src/lib/stores/chat.svelte.ts @@ -0,0 +1,143 @@ +import type { Chat, Message, AgentChatMessage } from '$lib/types'; +import { chats as chatsApi, messages as messagesApi } from '$lib/api/client'; +import { sendChatViaWebSocket } from './agent-ws'; +import { agentState, agentChatStream, restoredMessages, setOnMessagesRestored } from './agent.svelte'; + +export const chatState = $state({ + chats: [] as Chat[], + activeChat: null as Chat | null, + messages: [] as Message[], + // Agent chat messages (from WebSocket) + agentMessages: [] as AgentChatMessage[], + loading: false, +}); + +// Derived streaming state from agent store (single source of truth) +export function isStreaming() { + return agentChatStream.streaming; +} + +export function getStreamingContent() { + return agentChatStream.streamingContent; +} + +export async function fetchChats() { + try { + chatState.chats = await chatsApi.list(); + } catch (e) { + console.error('Failed to fetch chats:', e); + } +} + +export function selectChat(chat: Chat) { + chatState.activeChat = chat; + chatState.messages = []; + chatState.agentMessages = []; + // Agent DO handles persistence — messages restored via cf_agent_chat_messages on WS connect +} + +export async function createNewChat(projectId?: string, modelId?: string): Promise { + const chat = await chatsApi.create({ model_id: modelId, project_id: projectId }); + chatState.chats = [chat, ...chatState.chats]; + chatState.activeChat = chat; + chatState.messages = []; + chatState.agentMessages = []; + return chat; +} + +export function getChatsByProject(projectId: string): Chat[] { + return chatState.chats.filter(c => c.project_id === projectId); +} + +export function getScratchChats(): Chat[] { + return chatState.chats.filter(c => !c.project_id); +} + +export async function deleteChat(chatId: string) { + await chatsApi.delete(chatId); + chatState.chats = chatState.chats.filter(c => c.id !== chatId); + if (chatState.activeChat?.id === chatId) { + chatState.activeChat = null; + chatState.messages = []; + chatState.agentMessages = []; + } +} + +/** + * Load persisted messages from the agent's DO storage. + * Called automatically when cf_agent_chat_messages is received via WebSocket. + */ +export function loadRestoredMessages() { + if (restoredMessages.length > 0) { + chatState.agentMessages = [...restoredMessages]; + } +} + +// NOTE: setOnMessagesRestored(loadRestoredMessages) must be called from +page.svelte +// to survive Vite tree-shaking. See memory note on tree-shaking. + +/** + * Send a message to the agent via WebSocket (AIChatAgent protocol). + * The agent handles persistence, streaming, and tool calling. + */ +export async function sendAgentChatMessage(content: string, _userId: string) { + if (!content.trim() || agentChatStream.streaming) return; + + if (!agentState.connected) { + const errorMsg: AgentChatMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: '**Error:** Not connected to agent. Please wait for connection...', + createdAt: new Date().toISOString(), + }; + chatState.agentMessages = [...chatState.agentMessages, errorMsg]; + return; + } + + if (!chatState.activeChat) { + const errorMsg: AgentChatMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: '**Error:** No active chat. Please create or select a chat first.', + createdAt: new Date().toISOString(), + }; + chatState.agentMessages = [...chatState.agentMessages, errorMsg]; + return; + } + + // Add user message optimistically + const userMsg: AgentChatMessage = { + id: crypto.randomUUID(), + role: 'user', + content: content.trim(), + createdAt: new Date().toISOString(), + }; + chatState.agentMessages = [...chatState.agentMessages, userMsg]; + if (chatState.agentMessages.filter(m => m.role === 'user').length === 1 && chatState.activeChat) { + const title = content.trim().slice(0, 60) + (content.length > 60 ? '...' : ''); + chatState.activeChat = { ...chatState.activeChat, title }; + chatState.chats = chatState.chats.map(c => + c.id === chatState.activeChat!.id ? { ...c, title } : c + ); + } + + // Set streaming state before sending + agentChatStream.streaming = true; + agentChatStream.streamingContent = ''; + agentChatStream.lastError = null; + agentChatStream.completedMessage = null; + + // Send all messages via WebSocket using AIChatAgent protocol + const requestId = sendChatViaWebSocket(chatState.agentMessages); + + if (!requestId) { + agentChatStream.streaming = false; + const errorMsg: AgentChatMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: '**Error:** Failed to send message. WebSocket not connected.', + createdAt: new Date().toISOString(), + }; + chatState.agentMessages = [...chatState.agentMessages, errorMsg]; + } +} diff --git a/pilot/src/lib/stores/nav.svelte.ts b/pilot/src/lib/stores/nav.svelte.ts new file mode 100644 index 00000000..2ed8ed34 --- /dev/null +++ b/pilot/src/lib/stores/nav.svelte.ts @@ -0,0 +1,83 @@ +import type { NavView } from '$lib/types'; + +function loadBool(key: string, fallback: boolean): boolean { + if (typeof localStorage === 'undefined') return fallback; + const v = localStorage.getItem(key); + if (v === null) return fallback; + return v === 'true'; +} + +function loadNumber(key: string, fallback: number): number { + if (typeof localStorage === 'undefined') return fallback; + const v = localStorage.getItem(key); + if (v === null) return fallback; + const n = parseFloat(v); + return isNaN(n) ? fallback : n; +} + +function loadString(key: string, fallback: string): string { + if (typeof localStorage === 'undefined') return fallback; + return localStorage.getItem(key) ?? fallback; +} + +export const navState = $state({ + view: 'dashboard' as NavView, + activeProjectId: loadString('ui:active-project', '') || null as string | null, + sidebarCollapsed: loadBool('ui:sidebar-collapsed', false), + sidebarMobileOpen: false, + chatPanelOpen: loadBool('ui:chat-panel-open', true), + boardWidth: loadNumber('ui:board-width', 60), + focusedColumn: loadNumber('ui:focused-column', 0), + focusedRow: loadNumber('ui:focused-row', 0), +}); + +// Persist helpers — write on change +function persist(key: string, value: string) { + if (typeof localStorage !== 'undefined') localStorage.setItem(key, value); +} + +export function navigate(view: NavView) { + navState.view = view; + navState.sidebarMobileOpen = false; +} + +export function setActiveProject(projectId: string | null) { + navState.activeProjectId = projectId; + navState.view = 'dashboard'; + navState.sidebarMobileOpen = false; + if (projectId) { + persist('ui:active-project', projectId); + } else if (typeof localStorage !== 'undefined') { + localStorage.removeItem('ui:active-project'); + } +} + +export function toggleSidebar() { + navState.sidebarCollapsed = !navState.sidebarCollapsed; + persist('ui:sidebar-collapsed', String(navState.sidebarCollapsed)); +} + +export function toggleMobileSidebar() { + navState.sidebarMobileOpen = !navState.sidebarMobileOpen; +} + +export function closeMobileSidebar() { + navState.sidebarMobileOpen = false; +} + +export function toggleChatPanel() { + navState.chatPanelOpen = !navState.chatPanelOpen; + persist('ui:chat-panel-open', String(navState.chatPanelOpen)); +} + +export function setBoardWidth(width: number) { + navState.boardWidth = width; + persist('ui:board-width', String(width)); +} + +export function setFocus(column: number, row: number) { + navState.focusedColumn = column; + navState.focusedRow = row; + persist('ui:focused-column', String(column)); + persist('ui:focused-row', String(row)); +} diff --git a/pilot/src/lib/stores/projects.svelte.ts b/pilot/src/lib/stores/projects.svelte.ts new file mode 100644 index 00000000..9d4e5dff --- /dev/null +++ b/pilot/src/lib/stores/projects.svelte.ts @@ -0,0 +1,55 @@ +import type { Project, CreateProjectRequest, UpdateProjectRequest } from '$lib/types'; +import { projects as projectsApi } from '$lib/api/client'; +import { navState } from './nav.svelte'; + +export const projectState = $state({ + projects: [] as Project[], + loading: false, + expandedProjects: new Set(), +}); + +export async function fetchProjects() { + projectState.loading = true; + try { + projectState.projects = await projectsApi.list(); + } catch (e) { + console.error('Failed to fetch projects:', e); + } finally { + projectState.loading = false; + } +} + +export async function createProject(data: CreateProjectRequest): Promise { + const project = await projectsApi.create(data); + projectState.projects = [project, ...projectState.projects]; + return project; +} + +export async function updateProject(id: string, data: UpdateProjectRequest): Promise { + const updated = await projectsApi.update(id, data); + projectState.projects = projectState.projects.map(p => p.id === id ? updated : p); + return updated; +} + +export async function deleteProject(id: string): Promise { + await projectsApi.delete(id); + projectState.projects = projectState.projects.filter(p => p.id !== id); + if (navState.activeProjectId === id) { + navState.activeProjectId = null; + } +} + +export function toggleProjectExpanded(id: string) { + const next = new Set(projectState.expandedProjects); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + projectState.expandedProjects = next; +} + +export function getActiveProject(): Project | null { + if (!navState.activeProjectId) return null; + return projectState.projects.find(p => p.id === navState.activeProjectId) ?? null; +} diff --git a/pilot/src/lib/stores/taskChat.svelte.ts b/pilot/src/lib/stores/taskChat.svelte.ts new file mode 100644 index 00000000..af75efc5 --- /dev/null +++ b/pilot/src/lib/stores/taskChat.svelte.ts @@ -0,0 +1,258 @@ +// Per-task chat store with WebSocket connection management. +// Independent from the main chat — uses module-level WebSocket (not globalThis) +// because task chat is component-scoped (connect on TaskDetail open, disconnect on close). + +import type { AgentChatMessage } from '$lib/types'; + +let taskWs: WebSocket | null = null; +let reconnectTimer: ReturnType | null = null; + +// Track connection params for reconnect +let currentUserId: string | null = null; +let currentTaskId: number | null = null; + +export const taskChatState = $state({ + taskId: null as number | null, + connected: false, + messages: [] as AgentChatMessage[], + streaming: false, + streamingContent: '', + error: null as string | null, + completedMessage: null as AgentChatMessage | null, +}); + +/** Extract text content from AI SDK UI message format (which uses `parts`) */ +function extractTextContent(msg: any): string { + if (msg.parts && Array.isArray(msg.parts)) { + return msg.parts + .filter((p: any) => p.type === 'text') + .map((p: any) => p.text) + .join(''); + } + if (typeof msg.content === 'string') return msg.content; + return ''; +} + +/** + * Connect to a task-scoped agent DO instance via WebSocket. + * The agent instance name is `{userId}:task-{taskId}`. + */ +export function connectTaskChat(userId: string, taskId: number) { + // Already connected to this task + if (taskWs && taskWs.readyState === WebSocket.OPEN && currentTaskId === taskId) { + return; + } + + // Clear messages if switching to a different task + const switchingTask = currentTaskId !== null && currentTaskId !== taskId; + + // Clean up any existing connection + disconnectTaskChat(); + + if (switchingTask) { + taskChatState.messages = []; + } + + currentUserId = userId; + currentTaskId = taskId; + taskChatState.taskId = taskId; + taskChatState.error = null; + + const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const agentName = encodeURIComponent(`${userId}:task-${taskId}`); + const url = `${protocol}//${location.host}/agents/taskyou-agent/${agentName}`; + + const ws = new WebSocket(url); + taskWs = ws; + + ws.addEventListener('open', () => { + if (taskWs !== ws) return; // Stale socket + taskChatState.connected = true; + taskChatState.error = null; + }); + + ws.addEventListener('message', (event) => { + if (taskWs !== ws) return; // Stale socket + try { + const data = JSON.parse(event.data); + handleTaskChatMessage(data); + } catch { + // Non-JSON message, ignore + } + }); + + ws.addEventListener('close', () => { + if (taskWs !== ws) return; // Stale socket — don't corrupt current state + taskChatState.connected = false; + taskWs = null; + + // Auto-reconnect after 3s if we still have connection params + if (currentUserId && currentTaskId) { + reconnectTimer = setTimeout(() => { + if (currentUserId && currentTaskId) { + connectTaskChat(currentUserId, currentTaskId); + } + }, 3000); + } + }); + + ws.addEventListener('error', () => { + if (taskWs !== ws) return; // Stale socket + taskChatState.error = 'WebSocket connection error'; + }); +} + +/** + * Disconnect from the task chat WebSocket and reset state. + */ +export function disconnectTaskChat() { + currentUserId = null; + currentTaskId = null; + + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + if (taskWs) { + const ws = taskWs; + taskWs = null; // Clear reference BEFORE closing so close handler ignores + ws.close(); + } + + taskChatState.taskId = null; + taskChatState.connected = false; + taskChatState.streaming = false; + taskChatState.streamingContent = ''; + taskChatState.error = null; + taskChatState.completedMessage = null; + // Note: messages are NOT cleared here — they persist until a new task connects + // or the server restores them via cf_agent_chat_messages on reconnect +} + +/** + * Handle incoming WebSocket messages for the task chat. + * Routes protocol messages to update reactive state. + */ +export function handleTaskChatMessage(data: any) { + // Chat streaming response (AIChatAgent protocol) + if (data.type === 'cf_agent_use_chat_response') { + handleChatResponse(data); + return; + } + + // Restore persisted chat messages from the agent's DO storage + if (data.type === 'cf_agent_chat_messages') { + if (data.messages && Array.isArray(data.messages)) { + taskChatState.messages = data.messages + .map((m: any) => ({ + id: m.id || crypto.randomUUID(), + role: m.role, + content: extractTextContent(m), + createdAt: m.createdAt || new Date().toISOString(), + })) + .filter((m: any) => m.content.trim() !== ''); + } + return; + } + + // Silently ignore other protocol messages + if ( + data.type === 'cf_agent_identity' || + data.type === 'cf_agent_mcp_servers' || + data.type === 'cf_agent_state' || + data.type === 'cf_agent_state_update' + ) { + return; + } +} + +function handleChatResponse(data: { id: string; body: string; done: boolean; error?: boolean }) { + if (data.error) { + taskChatState.streaming = false; + taskChatState.streamingContent = ''; + taskChatState.error = data.body || 'Unknown error'; + return; + } + + if (data.body) { + try { + const parsed = JSON.parse(data.body); + switch (parsed.type) { + case 'text-delta': + taskChatState.streamingContent += parsed.delta; + break; + case 'error': + taskChatState.error = parsed.errorText || 'Unknown error'; + taskChatState.streaming = false; + taskChatState.streamingContent = ''; + return; + } + } catch { + // Body is not JSON — ignore + } + } + + if (data.done) { + if (taskChatState.streamingContent) { + taskChatState.completedMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: taskChatState.streamingContent, + createdAt: new Date().toISOString(), + }; + } + taskChatState.streaming = false; + taskChatState.streamingContent = ''; + } +} + +/** + * Send a chat message to the task-scoped agent via WebSocket. + * Adds the user message optimistically and sends full history. + */ +export function sendTaskChatMessage(content: string): string | null { + if (!content.trim()) return null; + + if (!taskWs || taskWs.readyState !== WebSocket.OPEN) { + taskChatState.error = 'Not connected to task agent'; + return null; + } + + // Add user message optimistically + const userMsg: AgentChatMessage = { + id: crypto.randomUUID(), + role: 'user', + content: content.trim(), + createdAt: new Date().toISOString(), + }; + taskChatState.messages = [...taskChatState.messages, userMsg]; + + // Set streaming state before sending + taskChatState.streaming = true; + taskChatState.streamingContent = ''; + taskChatState.error = null; + taskChatState.completedMessage = null; + + // Send full message history via AIChatAgent protocol + const requestId = crypto.randomUUID(); + taskWs.send( + JSON.stringify({ + type: 'cf_agent_use_chat_request', + id: requestId, + init: { + method: 'POST', + body: JSON.stringify({ + messages: taskChatState.messages.map((m) => ({ + id: m.id, + role: m.role, + content: m.content, + createdAt: m.createdAt, + })), + }), + }, + }) + ); + + return requestId; +} diff --git a/pilot/src/lib/stores/tasks.svelte.ts b/pilot/src/lib/stores/tasks.svelte.ts new file mode 100644 index 00000000..bbcccbf6 --- /dev/null +++ b/pilot/src/lib/stores/tasks.svelte.ts @@ -0,0 +1,89 @@ +import type { Task, CreateTaskRequest, UpdateTaskRequest } from '$lib/types'; +import { tasks as tasksApi } from '$lib/api/client'; +import { navState } from './nav.svelte'; + +export const taskState = $state<{ tasks: Task[]; loading: boolean }>({ + tasks: [], + loading: true, +}); + +// Derived getters for board columns +function byUpdatedDesc(a: Task, b: Task) { + return new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime(); +} + +function filterByProject(tasks: Task[]): Task[] { + const pid = navState.activeProjectId; + if (!pid) return tasks; + return tasks.filter(t => t.project_id === pid); +} + +export function getBacklogTasks(): Task[] { + return filterByProject(taskState.tasks.filter((t) => t.status === 'backlog')).sort(byUpdatedDesc); +} + +export function getInProgressTasks(): Task[] { + return filterByProject(taskState.tasks + .filter((t) => t.status === 'queued' || t.status === 'processing')) + .sort((a, b) => { + if (a.status === 'processing' && b.status !== 'processing') return -1; + if (b.status === 'processing' && a.status !== 'processing') return 1; + return byUpdatedDesc(a, b); + }); +} + +export function getBlockedTasks(): Task[] { + return filterByProject(taskState.tasks.filter((t) => t.status === 'blocked')).sort(byUpdatedDesc); +} + +export function getDoneTasks(): Task[] { + return filterByProject(taskState.tasks.filter((t) => t.status === 'done')).sort(byUpdatedDesc); +} + +export function getFailedTasks(): Task[] { + return filterByProject(taskState.tasks.filter((t) => t.status === 'failed')).sort(byUpdatedDesc); +} + +export async function fetchTasks() { + taskState.loading = true; + try { + const data = await tasksApi.list({ all: true }); + taskState.tasks = data; + } catch (e) { + console.error('Failed to fetch tasks:', e); + } finally { + taskState.loading = false; + } +} + +export async function createTask(data: CreateTaskRequest): Promise { + const task = await tasksApi.create(data); + taskState.tasks = [...taskState.tasks, task]; + return task; +} + +export async function updateTask(id: number, data: UpdateTaskRequest): Promise { + const task = await tasksApi.update(id, data); + taskState.tasks = taskState.tasks.map((t) => (t.id === id ? task : t)); + return task; +} + +export async function deleteTask(id: number): Promise { + await tasksApi.delete(id); + taskState.tasks = taskState.tasks.filter((t) => t.id !== id); +} + +// Periodic refresh (fallback when agent WebSocket is not connected) +let pollInterval: ReturnType | null = null; + +export function startPolling(interval = 10000) { + stopPolling(); + pollInterval = setInterval(fetchTasks, interval); +} + +export function stopPolling() { + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } +} diff --git a/pilot/src/lib/types.ts b/pilot/src/lib/types.ts new file mode 100644 index 00000000..a55fb22b --- /dev/null +++ b/pilot/src/lib/types.ts @@ -0,0 +1,191 @@ +// User and authentication types +export interface User { + id: string; + email: string; + name: string; + avatar_url: string; +} + +// Workspace types +export interface Workspace { + id: string; + name: string; + owner_id: string; + autonomous_enabled: boolean; + weekly_budget_cents: number; + budget_spent_cents: number; + polling_interval: number; + brand_voice: string; + created_at: string; + updated_at: string; +} + +export interface Membership { + id: number; + user_id: string; + workspace_id: string; + role: 'owner' | 'admin' | 'member'; + created_at: string; +} + +// Task types +export type TaskStatus = 'backlog' | 'queued' | 'processing' | 'blocked' | 'done' | 'failed'; + +export interface Task { + id: number; + title: string; + body: string; + status: TaskStatus; + type: string; + project_id: string; + chat_id?: string; + workspace_id: string; + parent_task_id?: number; + subtasks_json?: string; + cost_cents: number; + output?: string; + summary?: string; + preview_url?: string; + approval_status?: 'pending_review' | 'approved' | 'rejected'; + dangerous_mode: boolean; + scheduled_at?: string; + recurrence?: string; + last_run_at?: string; + created_at: string; + updated_at: string; + started_at?: string; + completed_at?: string; +} + +export interface TaskFile { + id: number; + task_id: number; + path: string; + mime_type: string; + size_bytes: number; + created_at: string; +} + +export interface CreateTaskRequest { + title: string; + body?: string; + type?: string; + project_id?: string; + chat_id?: string; +} + +export interface UpdateTaskRequest { + title?: string; + body?: string; + status?: TaskStatus; + type?: string; + project_id?: string; +} + +// Project types — organizational containers for tasks +export interface Project { + id: string; + workspace_id: string; + user_id: string; + name: string; + instructions: string; + color: string; + github_repo?: string; + github_branch?: string; + created_at: string; + updated_at: string; +} + +export interface CreateProjectRequest { + name: string; + instructions?: string; + color?: string; + github_repo?: string; + github_branch?: string; +} + +export interface UpdateProjectRequest { + name?: string; + instructions?: string; + color?: string; + github_repo?: string; + github_branch?: string; +} + +// Chat types +export interface Chat { + id: string; + workspace_id: string; + user_id: string; + project_id?: string; + title: string; + model_id: string; + created_at: string; + updated_at: string; +} + +export type MessageRole = 'system' | 'user' | 'assistant' | 'tool'; + +export interface Message { + id: string; + chat_id: string; + role: MessageRole; + content: string; + input_tokens: number; + output_tokens: number; + model_id?: string; + created_at: string; +} + +// Integration types +export interface Integration { + id: number; + workspace_id: string; + provider: 'github' | 'gmail' | 'slack' | 'linear'; + status: 'connected' | 'disconnected' | 'error'; + external_id?: string; + created_at: string; + updated_at: string; +} + +// Model types +export interface Model { + id: string; + name: string; + provider: string; + api_id: string; + context_window: number; + input_price_per_million: number; + output_price_per_million: number; + supports_tools: boolean; + supports_streaming: boolean; +} + +// Task log types +export interface TaskLog { + id: number; + task_id: number; + line_type: 'system' | 'text' | 'tool' | 'error' | 'output'; + content: string; + created_at: string; +} + +// Agent chat message (from AI SDK) +export interface AgentChatMessage { + id: string; + role: 'user' | 'assistant'; + content: string; + createdAt?: string; + toolInvocations?: ToolInvocation[]; +} + +export interface ToolInvocation { + toolCallId: string; + toolName: string; + args: Record; + state: 'call' | 'result' | 'partial-call'; + result?: unknown; +} + +// Navigation +export type NavView = 'dashboard' | 'workspaces' | 'integrations' | 'approvals' | 'settings'; diff --git a/pilot/src/routes/+layout.svelte b/pilot/src/routes/+layout.svelte new file mode 100644 index 00000000..dcb1eb48 --- /dev/null +++ b/pilot/src/routes/+layout.svelte @@ -0,0 +1,7 @@ + + +{@render children()} diff --git a/pilot/src/routes/+layout.ts b/pilot/src/routes/+layout.ts new file mode 100644 index 00000000..ef995480 --- /dev/null +++ b/pilot/src/routes/+layout.ts @@ -0,0 +1,2 @@ +// Disable SSR - this is a client-side SPA that requires browser APIs and auth +export const ssr = false; diff --git a/pilot/src/routes/+page.svelte b/pilot/src/routes/+page.svelte new file mode 100644 index 00000000..6c6f0704 --- /dev/null +++ b/pilot/src/routes/+page.svelte @@ -0,0 +1,187 @@ + + +{#if authState.loading} +
+
+
+

Loading...

+
+
+{:else if !authState.user} + +{:else} + + + + +
+ +
+ + TaskYou +
+ + +
+ {#if navState.view === 'dashboard'} + + {:else if navState.view === 'settings'} + (navState.view = 'dashboard')} /> + {:else if navState.view === 'approvals'} + + {:else if navState.view === 'integrations'} + + {/if} +
+
+{/if} diff --git a/pilot/src/routes/api/auth/+server.ts b/pilot/src/routes/api/auth/+server.ts new file mode 100644 index 00000000..49ae1fd7 --- /dev/null +++ b/pilot/src/routes/api/auth/+server.ts @@ -0,0 +1,20 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { deleteSession } from '$lib/server/auth'; + +// GET /api/auth - Get current user +export const GET: RequestHandler = async ({ locals }) => { + if (!locals.user) { + return json({ error: 'Unauthorized' }, { status: 401 }); + } + return json(locals.user); +}; + +// POST /api/auth/logout +export const POST: RequestHandler = async ({ locals, cookies, platform }) => { + if (locals.sessionId && platform?.env?.SESSIONS) { + await deleteSession(platform.env.SESSIONS, locals.sessionId); + } + cookies.delete('session', { path: '/' }); + return json({ success: true }); +}; diff --git a/pilot/src/routes/api/auth/github/+server.ts b/pilot/src/routes/api/auth/github/+server.ts new file mode 100644 index 00000000..7747775e --- /dev/null +++ b/pilot/src/routes/api/auth/github/+server.ts @@ -0,0 +1,41 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { handleGitHubCallback } from '$lib/server/auth'; + +// GET /api/auth/github - Start GitHub OAuth flow +export const GET: RequestHandler = async ({ url, platform, cookies }) => { + const env = platform?.env; + if (!env?.GITHUB_CLIENT_ID || !env?.GITHUB_CLIENT_SECRET) { + return new Response('GitHub OAuth not configured', { status: 500 }); + } + + const code = url.searchParams.get('code'); + + if (!code) { + // Redirect to GitHub OAuth + const authUrl = new URL('https://github.com/login/oauth/authorize'); + authUrl.searchParams.set('client_id', env.GITHUB_CLIENT_ID); + authUrl.searchParams.set('redirect_uri', `${url.origin}/api/auth/github`); + authUrl.searchParams.set('scope', 'user:email'); + throw redirect(302, authUrl.toString()); + } + + // Handle callback + const { sessionId } = await handleGitHubCallback( + env.DB, + env.SESSIONS, + code, + env.GITHUB_CLIENT_ID, + env.GITHUB_CLIENT_SECRET, + ); + + cookies.set('session', sessionId, { + path: '/', + httpOnly: true, + secure: url.protocol === 'https:', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, + }); + + throw redirect(302, '/'); +}; diff --git a/pilot/src/routes/api/auth/github/device/+server.ts b/pilot/src/routes/api/auth/github/device/+server.ts new file mode 100644 index 00000000..318a86b1 --- /dev/null +++ b/pilot/src/routes/api/auth/github/device/+server.ts @@ -0,0 +1,43 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +// POST /api/auth/github/device - Start GitHub device flow +export const POST: RequestHandler = async ({ locals, platform }) => { + if (!locals.user) throw error(401); + + const clientId = platform?.env?.GITHUB_CLIENT_ID; + if (!clientId) throw error(500, 'GitHub OAuth not configured'); + + const response = await fetch('https://github.com/login/device/code', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: clientId, + scope: 'repo', + }), + }); + + if (!response.ok) { + const text = await response.text(); + throw error(502, `GitHub device flow failed: ${text}`); + } + + const data = await response.json() as { + device_code: string; + user_code: string; + verification_uri: string; + expires_in: number; + interval: number; + }; + + return json({ + device_code: data.device_code, + user_code: data.user_code, + verification_uri: data.verification_uri, + expires_in: data.expires_in, + interval: data.interval, + }); +}; diff --git a/pilot/src/routes/api/auth/github/device/poll/+server.ts b/pilot/src/routes/api/auth/github/device/poll/+server.ts new file mode 100644 index 00000000..5f9f93f6 --- /dev/null +++ b/pilot/src/routes/api/auth/github/device/poll/+server.ts @@ -0,0 +1,61 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +// POST /api/auth/github/device/poll - Poll for device flow completion +export const POST: RequestHandler = async ({ request, locals, platform }) => { + if (!locals.user) throw error(401); + + const env = platform?.env; + const clientId = env?.GITHUB_CLIENT_ID; + if (!clientId) throw error(500, 'GitHub OAuth not configured'); + + const { device_code } = await request.json() as { device_code: string }; + if (!device_code) throw error(400, 'device_code required'); + + const response = await fetch('https://github.com/login/oauth/access_token', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify({ + client_id: clientId, + device_code, + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }), + }); + + if (!response.ok) { + throw error(502, 'GitHub token exchange failed'); + } + + const data = await response.json() as { + access_token?: string; + token_type?: string; + scope?: string; + error?: string; + error_description?: string; + }; + + if (data.error) { + // authorization_pending and slow_down are expected during polling + if (data.error === 'authorization_pending' || data.error === 'slow_down') { + return json({ status: 'pending' }); + } + return json({ status: 'error', error: data.error, error_description: data.error_description }); + } + + if (!data.access_token) { + return json({ status: 'error', error: 'no_token' }); + } + + // Store token in KV keyed by user ID + const sessions = env?.SESSIONS as KVNamespace | undefined; + if (!sessions) throw error(500, 'KV not available'); + + await sessions.put(`github-token:${locals.user.id}`, data.access_token, { + expirationTtl: 365 * 24 * 60 * 60, // 1 year + }); + + return json({ status: 'complete' }); +}; diff --git a/pilot/src/routes/api/auth/github/status/+server.ts b/pilot/src/routes/api/auth/github/status/+server.ts new file mode 100644 index 00000000..35e1b59a --- /dev/null +++ b/pilot/src/routes/api/auth/github/status/+server.ts @@ -0,0 +1,32 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +// GET /api/auth/github/status - Check if user has valid GitHub token +export const GET: RequestHandler = async ({ locals, platform }) => { + if (!locals.user) throw error(401); + + const sessions = platform?.env?.SESSIONS as KVNamespace | undefined; + if (!sessions) throw error(500, 'KV not available'); + + const token = await sessions.get(`github-token:${locals.user.id}`); + if (!token) { + return json({ connected: false }); + } + + // Validate token against GitHub API + const response = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${token}`, + 'User-Agent': 'TaskYou-Pilot', + }, + }); + + if (!response.ok) { + // Token is invalid or expired — clean up + await sessions.delete(`github-token:${locals.user.id}`); + return json({ connected: false }); + } + + const user = await response.json() as { login: string; avatar_url: string }; + return json({ connected: true, login: user.login, avatar_url: user.avatar_url }); +}; diff --git a/pilot/src/routes/api/auth/google/+server.ts b/pilot/src/routes/api/auth/google/+server.ts new file mode 100644 index 00000000..fd6839f9 --- /dev/null +++ b/pilot/src/routes/api/auth/google/+server.ts @@ -0,0 +1,45 @@ +import { redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { handleGoogleCallback } from '$lib/server/auth'; + +// GET /api/auth/google - Start Google OAuth flow +export const GET: RequestHandler = async ({ url, platform, cookies }) => { + const env = platform?.env; + if (!env?.GOOGLE_CLIENT_ID || !env?.GOOGLE_CLIENT_SECRET) { + return new Response('Google OAuth not configured', { status: 500 }); + } + + const code = url.searchParams.get('code'); + const redirectUri = `${url.origin}/api/auth/google`; + + if (!code) { + // Redirect to Google OAuth + const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth'); + authUrl.searchParams.set('client_id', env.GOOGLE_CLIENT_ID); + authUrl.searchParams.set('redirect_uri', redirectUri); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('scope', 'openid email profile'); + authUrl.searchParams.set('access_type', 'offline'); + throw redirect(302, authUrl.toString()); + } + + // Handle callback + const { sessionId } = await handleGoogleCallback( + env.DB, + env.SESSIONS, + code, + env.GOOGLE_CLIENT_ID, + env.GOOGLE_CLIENT_SECRET, + redirectUri, + ); + + cookies.set('session', sessionId, { + path: '/', + httpOnly: true, + secure: url.protocol === 'https:', + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, // 30 days + }); + + throw redirect(302, '/'); +}; diff --git a/pilot/src/routes/api/chats/+server.ts b/pilot/src/routes/api/chats/+server.ts new file mode 100644 index 00000000..caebb1d9 --- /dev/null +++ b/pilot/src/routes/api/chats/+server.ts @@ -0,0 +1,20 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listChats, createChat } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ locals, platform }) => { + const user = locals.user; + if (!user || !platform?.env?.DB) return json([], { status: 401 }); + + const chats = await listChats(platform.env.DB, user.id); + return json(chats); +}; + +export const POST: RequestHandler = async ({ locals, platform, request }) => { + const user = locals.user; + if (!user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 }); + + const data = await request.json() as { title?: string; model_id?: string }; + const chat = await createChat(platform.env.DB, user.id, data); + return json(chat, { status: 201 }); +}; diff --git a/pilot/src/routes/api/chats/[id]/+server.ts b/pilot/src/routes/api/chats/[id]/+server.ts new file mode 100644 index 00000000..286ab0bc --- /dev/null +++ b/pilot/src/routes/api/chats/[id]/+server.ts @@ -0,0 +1,30 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getChat, updateChat, deleteChat } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ locals, platform, params }) => { + const user = locals.user; + if (!user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 }); + + const chat = await getChat(platform.env.DB, user.id, params.id); + if (!chat) return json({ error: 'Not found' }, { status: 404 }); + return json(chat); +}; + +export const PUT: RequestHandler = async ({ locals, platform, params, request }) => { + const user = locals.user; + if (!user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 }); + + const data = await request.json() as { title?: string; model_id?: string }; + const chat = await updateChat(platform.env.DB, user.id, params.id, data); + if (!chat) return json({ error: 'Not found' }, { status: 404 }); + return json(chat); +}; + +export const DELETE: RequestHandler = async ({ locals, platform, params }) => { + const user = locals.user; + if (!user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 }); + + await deleteChat(platform.env.DB, user.id, params.id); + return new Response(null, { status: 204 }); +}; diff --git a/pilot/src/routes/api/chats/[id]/messages/+server.ts b/pilot/src/routes/api/chats/[id]/messages/+server.ts new file mode 100644 index 00000000..b5bcbf8c --- /dev/null +++ b/pilot/src/routes/api/chats/[id]/messages/+server.ts @@ -0,0 +1,15 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getChat, listMessages } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ locals, platform, params }) => { + const user = locals.user; + if (!user || !platform?.env?.DB) return json([], { status: 401 }); + + // Verify chat belongs to user + const chat = await getChat(platform.env.DB, user.id, params.id); + if (!chat) return json({ error: 'Not found' }, { status: 404 }); + + const messages = await listMessages(platform.env.DB, params.id); + return json(messages); +}; diff --git a/pilot/src/routes/api/integrations/+server.ts b/pilot/src/routes/api/integrations/+server.ts new file mode 100644 index 00000000..30217bef --- /dev/null +++ b/pilot/src/routes/api/integrations/+server.ts @@ -0,0 +1,10 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listIntegrations } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ locals, platform }) => { + if (!locals.user || !platform?.env?.DB) return json([], { status: 401 }); + + const integrations = await listIntegrations(platform.env.DB); + return json(integrations); +}; diff --git a/pilot/src/routes/api/models/+server.ts b/pilot/src/routes/api/models/+server.ts new file mode 100644 index 00000000..aecb5138 --- /dev/null +++ b/pilot/src/routes/api/models/+server.ts @@ -0,0 +1,10 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listModels } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ platform }) => { + if (!platform?.env?.DB) return json([]); + + const models = await listModels(platform.env.DB); + return json(models); +}; diff --git a/pilot/src/routes/api/projects/+server.ts b/pilot/src/routes/api/projects/+server.ts new file mode 100644 index 00000000..a7544b9b --- /dev/null +++ b/pilot/src/routes/api/projects/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listProjects, createProject, updateProject } from '$lib/server/db'; + +// GET /api/projects +export const GET: RequestHandler = async ({ locals, platform }) => { + if (!locals.user || !platform?.env?.DB) return json([], { status: 401 }); + const projects = await listProjects(platform.env.DB, locals.user.id); + return json(projects); +}; + +// POST /api/projects — create project record (user clicks Start to provision) +export const POST: RequestHandler = async ({ locals, platform, request }) => { + if (!locals.user || !platform?.env?.DB) return json({ error: 'Unauthorized' }, { status: 401 }); + + const data = (await request.json()) as { name?: string; instructions?: string; color?: string; github_repo?: string; github_branch?: string }; + const project = await createProject(platform.env.DB, locals.user.id, data); + return json(project, { status: 201 }); +}; diff --git a/pilot/src/routes/api/projects/[id]/+server.ts b/pilot/src/routes/api/projects/[id]/+server.ts new file mode 100644 index 00000000..4904b296 --- /dev/null +++ b/pilot/src/routes/api/projects/[id]/+server.ts @@ -0,0 +1,31 @@ +import { json, error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getProjectById, updateProject, deleteProject } from '$lib/server/db'; + +// GET /api/projects/:id +export const GET: RequestHandler = async ({ params, locals, platform }) => { + if (!locals.user || !platform?.env?.DB) throw error(401); + + const project = await getProjectById(platform.env.DB, params.id); + if (!project) throw error(404, 'Project not found'); + return json(project); +}; + +// PUT /api/projects/:id +export const PUT: RequestHandler = async ({ params, request, locals, platform }) => { + if (!locals.user || !platform?.env?.DB) throw error(401); + + const data = await request.json() as { name?: string; instructions?: string; color?: string; github_repo?: string; github_branch?: string }; + const project = await updateProject(platform.env.DB, params.id, data); + if (!project) throw error(404, 'Project not found'); + return json(project); +}; + +// DELETE /api/projects/:id +export const DELETE: RequestHandler = async ({ params, locals, platform }) => { + if (!locals.user || !platform?.env?.DB) throw error(401); + + const deleted = await deleteProject(platform.env.DB, params.id); + if (!deleted) throw error(404, 'Project not found'); + return new Response(null, { status: 204 }); +}; diff --git a/pilot/src/routes/api/settings/+server.ts b/pilot/src/routes/api/settings/+server.ts new file mode 100644 index 00000000..671f1bba --- /dev/null +++ b/pilot/src/routes/api/settings/+server.ts @@ -0,0 +1,21 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getSettings, updateSettings } from '$lib/server/db'; + +// GET /api/settings +export const GET: RequestHandler = async ({ locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const settings = await getSettings(db, user.id); + return json(settings); +}; + +// PUT /api/settings +export const PUT: RequestHandler = async ({ request, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const data = await request.json() as Record; + + await updateSettings(db, user.id, data); + return json({ success: true }); +}; diff --git a/pilot/src/routes/api/tasks/+server.ts b/pilot/src/routes/api/tasks/+server.ts new file mode 100644 index 00000000..bc3d4b3b --- /dev/null +++ b/pilot/src/routes/api/tasks/+server.ts @@ -0,0 +1,46 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listTasks, createTask } from '$lib/server/db'; + +// GET /api/tasks +export const GET: RequestHandler = async ({ url, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + + const options = { + status: url.searchParams.get('status') || undefined, + project_id: url.searchParams.get('project_id') || undefined, + type: url.searchParams.get('type') || undefined, + includeClosed: url.searchParams.get('all') === 'true', + }; + + const tasks = await listTasks(db, user.id, options); + return json(tasks); +}; + +// POST /api/tasks +export const POST: RequestHandler = async ({ request, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + + const data = await request.json() as { title?: string; body?: string; type?: string; project_id?: string; chat_id?: string }; + + if (!data.title) { + return json({ error: 'Title is required' }, { status: 400 }); + } + + try { + const task = await createTask(db, user.id, { + title: data.title, + body: data.body, + type: data.type, + project_id: data.project_id, + chat_id: data.chat_id, + }); + + return json(task, { status: 201 }); + } catch (e) { + console.error('Task creation failed:', e); + return json({ error: e instanceof Error ? e.message : String(e) }, { status: 500 }); + } +}; diff --git a/pilot/src/routes/api/tasks/[id]/+server.ts b/pilot/src/routes/api/tasks/[id]/+server.ts new file mode 100644 index 00000000..faa22aaf --- /dev/null +++ b/pilot/src/routes/api/tasks/[id]/+server.ts @@ -0,0 +1,67 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getTask, updateTask, deleteTask } from '$lib/server/db'; + +// GET /api/tasks/:id +export const GET: RequestHandler = async ({ params, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + + const task = await getTask(db, user.id, taskId); + if (!task) { + return json({ error: 'Task not found' }, { status: 404 }); + } + + return json(task); +}; + +// PUT /api/tasks/:id +export const PUT: RequestHandler = async ({ params, request, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + + const data = await request.json() as { title?: string; body?: string; status?: import('$lib/types').TaskStatus; type?: string; project_id?: string }; + const task = await updateTask(db, user.id, taskId, data); + if (!task) { + return json({ error: 'Task not found' }, { status: 404 }); + } + + // Trigger execution when task is queued + if (data.status === 'queued') { + const env = platform!.env as any; + const apiKey = env.ANTHROPIC_API_KEY as string; + if (apiKey) { + platform!.context.waitUntil( + import('$lib/server/agent').then(({ executeTask }) => + executeTask({ + db: env.DB, + sandbox: env.SANDBOX, + sessions: env.SESSIONS, + storage: env.STORAGE, + apiKey, + userId: user.id, + taskId, + }) + ) + ); + } + } + + return json(task); +}; + +// DELETE /api/tasks/:id +export const DELETE: RequestHandler = async ({ params, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + + const deleted = await deleteTask(db, user.id, taskId); + if (!deleted) { + return json({ error: 'Task not found' }, { status: 404 }); + } + + return new Response(null, { status: 204 }); +}; diff --git a/pilot/src/routes/api/tasks/[id]/file/+server.ts b/pilot/src/routes/api/tasks/[id]/file/+server.ts new file mode 100644 index 00000000..3a252a2b --- /dev/null +++ b/pilot/src/routes/api/tasks/[id]/file/+server.ts @@ -0,0 +1,42 @@ +import type { RequestHandler } from './$types'; +import { getTask } from '$lib/server/db'; + +// GET /api/tasks/:id/file?path=foo.js — read file content from R2 storage +export const GET: RequestHandler = async ({ params, url, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + const filePath = url.searchParams.get('path'); + + if (!filePath) { + return new Response(JSON.stringify({ error: 'path parameter required' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // Verify task belongs to user + const task = await getTask(db, user.id, taskId); + if (!task) { + return new Response(JSON.stringify({ error: 'Task not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const storage = platform!.env.STORAGE as R2Bucket; + const r2Key = `tasks/${taskId}/${filePath}`; + const object = await storage.get(r2Key); + + if (!object) { + return new Response(JSON.stringify({ error: 'File not found' }), { + status: 404, + headers: { 'Content-Type': 'application/json' }, + }); + } + + const contentType = object.httpMetadata?.contentType || 'text/plain'; + return new Response(object.body, { + headers: { 'Content-Type': `${contentType}; charset=utf-8` }, + }); +}; diff --git a/pilot/src/routes/api/tasks/[id]/files/+server.ts b/pilot/src/routes/api/tasks/[id]/files/+server.ts new file mode 100644 index 00000000..af203339 --- /dev/null +++ b/pilot/src/routes/api/tasks/[id]/files/+server.ts @@ -0,0 +1,13 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listTaskFiles } from '$lib/server/db'; + +// GET /api/tasks/:id/files — list files for a task +export const GET: RequestHandler = async ({ params, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + + const files = await listTaskFiles(db, user.id, taskId); + return json(files); +}; diff --git a/pilot/src/routes/api/tasks/[id]/logs/+server.ts b/pilot/src/routes/api/tasks/[id]/logs/+server.ts new file mode 100644 index 00000000..95591610 --- /dev/null +++ b/pilot/src/routes/api/tasks/[id]/logs/+server.ts @@ -0,0 +1,14 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getTaskLogs } from '$lib/server/db'; + +// GET /api/tasks/:id/logs +export const GET: RequestHandler = async ({ params, url, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const taskId = parseInt(params.id); + const limit = parseInt(url.searchParams.get('limit') || '200'); + + const logs = await getTaskLogs(db, user.id, taskId, limit); + return json(logs); +}; diff --git a/pilot/src/routes/api/workspaces/+server.ts b/pilot/src/routes/api/workspaces/+server.ts new file mode 100644 index 00000000..b159f1bb --- /dev/null +++ b/pilot/src/routes/api/workspaces/+server.ts @@ -0,0 +1,23 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { listWorkspaces, createWorkspace } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const workspaces = await listWorkspaces(db, user.id); + return json(workspaces); +}; + +export const POST: RequestHandler = async ({ request, locals, platform }) => { + const user = locals.user!; + const db = platform!.env.DB; + const data = await request.json() as { name?: string }; + + if (!data.name) { + return json({ error: 'Name is required' }, { status: 400 }); + } + + const workspace = await createWorkspace(db, user.id, { name: data.name }); + return json(workspace, { status: 201 }); +}; diff --git a/pilot/src/routes/api/workspaces/[id]/+server.ts b/pilot/src/routes/api/workspaces/[id]/+server.ts new file mode 100644 index 00000000..9e1f5477 --- /dev/null +++ b/pilot/src/routes/api/workspaces/[id]/+server.ts @@ -0,0 +1,37 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getWorkspace, updateWorkspace, deleteWorkspace } from '$lib/server/db'; + +export const GET: RequestHandler = async ({ params, platform }) => { + const db = platform!.env.DB; + const workspace = await getWorkspace(db, params.id); + if (!workspace) { + return json({ error: 'Workspace not found' }, { status: 404 }); + } + return json(workspace); +}; + +export const PUT: RequestHandler = async ({ params, request, platform }) => { + const db = platform!.env.DB; + const data = await request.json() as { name?: string; autonomous_enabled?: boolean; weekly_budget_cents?: number }; + + const workspace = await updateWorkspace(db, params.id, data); + if (!workspace) { + return json({ error: 'Workspace not found' }, { status: 404 }); + } + return json(workspace); +}; + +export const DELETE: RequestHandler = async ({ params, platform }) => { + const db = platform!.env.DB; + + if (params.id === 'default') { + return json({ error: 'Cannot delete the default workspace' }, { status: 400 }); + } + + const deleted = await deleteWorkspace(db, params.id); + if (!deleted) { + return json({ error: 'Workspace not found' }, { status: 404 }); + } + return new Response(null, { status: 204 }); +}; diff --git a/pilot/src/routes/preview/tasks/[id]/[...path]/+server.ts b/pilot/src/routes/preview/tasks/[id]/[...path]/+server.ts new file mode 100644 index 00000000..279270fd --- /dev/null +++ b/pilot/src/routes/preview/tasks/[id]/[...path]/+server.ts @@ -0,0 +1,52 @@ +import type { RequestHandler } from './$types'; + +// GET /preview/tasks/:id/* — serve task files from R2 with correct MIME types +// This enables iframe previews where relative paths (style.css, script.js) just work. +export const GET: RequestHandler = async ({ params, platform }) => { + const taskId = params.id; + const filePath = params.path || 'index.html'; + + const storage = platform!.env.STORAGE as R2Bucket; + const r2Key = `tasks/${taskId}/${filePath}`; + const object = await storage.get(r2Key); + + if (!object) { + // Try index.html for directory-style requests + if (!filePath.includes('.')) { + const indexKey = `tasks/${taskId}/${filePath}/index.html`; + const indexObject = await storage.get(indexKey); + if (indexObject) { + return new Response(indexObject.body, { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-cache', + }, + }); + } + } + return new Response('Not found', { status: 404 }); + } + + const contentType = object.httpMetadata?.contentType || guessMime(filePath); + return new Response(object.body, { + headers: { + 'Content-Type': `${contentType}; charset=utf-8`, + 'Cache-Control': 'no-cache', + }, + }); +}; + +function guessMime(path: string): string { + const ext = path.split('.').pop()?.toLowerCase() || ''; + const types: Record = { + html: 'text/html', htm: 'text/html', css: 'text/css', + js: 'application/javascript', mjs: 'application/javascript', + json: 'application/json', svg: 'image/svg+xml', + png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', + gif: 'image/gif', webp: 'image/webp', ico: 'image/x-icon', + woff: 'font/woff', woff2: 'font/woff2', ttf: 'font/ttf', + md: 'text/markdown', txt: 'text/plain', xml: 'application/xml', + py: 'text/plain', rb: 'text/plain', sh: 'text/plain', + }; + return types[ext] || 'application/octet-stream'; +} diff --git a/pilot/static/icon.svg b/pilot/static/icon.svg new file mode 100644 index 00000000..30b2ef13 --- /dev/null +++ b/pilot/static/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/pilot/svelte.config.js b/pilot/svelte.config.js new file mode 100644 index 00000000..a7823b30 --- /dev/null +++ b/pilot/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-cloudflare'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/pilot/tsconfig.json b/pilot/tsconfig.json new file mode 100644 index 00000000..a8f10c8e --- /dev/null +++ b/pilot/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/pilot/vite.config.ts b/pilot/vite.config.ts new file mode 100644 index 00000000..6312f75b --- /dev/null +++ b/pilot/vite.config.ts @@ -0,0 +1,16 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import tailwindcss from '@tailwindcss/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [tailwindcss(), sveltekit()], + resolve: { + conditions: ['browser', 'workerd', 'worker'], + }, + ssr: { + resolve: { + conditions: ['workerd', 'worker', 'node'], + externalConditions: ['workerd', 'worker', 'node'], + }, + }, +}); diff --git a/pilot/worker-entry.js b/pilot/worker-entry.js new file mode 100644 index 00000000..57b212d2 --- /dev/null +++ b/pilot/worker-entry.js @@ -0,0 +1,145 @@ + +import { routeAgentRequest } from "agents"; +import { proxyToSandbox } from "@cloudflare/sandbox"; +// src/worker.js +import { Server } from "./.svelte-kit/output/server/index.js"; +import { manifest, prerendered, base_path } from "./.svelte-kit/cloudflare-tmp/manifest.js"; +import { env } from "cloudflare:workers"; + +// ../../node_modules/.pnpm/worktop@0.8.0-next.18/node_modules/worktop/cache/index.mjs +async function e(e3, t2) { + let n2 = "string" != typeof t2 && "HEAD" === t2.method; + n2 && (t2 = new Request(t2, { method: "GET" })); + let r3 = await e3.match(t2); + return n2 && r3 && (r3 = new Response(null, r3)), r3; +} +function t(e3, t2, n2, o2) { + return ("string" == typeof t2 || "GET" === t2.method) && r(n2) && (n2.headers.has("Set-Cookie") && (n2 = new Response(n2.body, n2)).headers.append("Cache-Control", "private=Set-Cookie"), o2.waitUntil(e3.put(t2, n2.clone()))), n2; +} +var n = /* @__PURE__ */ new Set([200, 203, 204, 300, 301, 404, 405, 410, 414, 501]); +function r(e3) { + if (!n.has(e3.status)) return false; + if (~(e3.headers.get("Vary") || "").indexOf("*")) return false; + let t2 = e3.headers.get("Cache-Control") || ""; + return !/(private|no-cache|no-store)/i.test(t2); +} +function o(n2) { + return async function(r3, o2) { + let a = await e(n2, r3); + if (a) return a; + o2.defer(((e3) => { + t(n2, r3, e3, o2); + })); + }; +} + +// ../../node_modules/.pnpm/worktop@0.8.0-next.18/node_modules/worktop/cfw.cache/index.mjs +var s = caches.default; +var c = t.bind(0, s); +var r2 = e.bind(0, s); +var e2 = o.bind(0, s); + +// src/worker.js +var server = new Server(manifest); +var app_path = `/${manifest.appPath}`; +var immutable = `${app_path}/immutable/`; +var version_file = `${app_path}/version.json`; +var origin; +var initialized = server.init({ + // @ts-expect-error env contains environment variables and bindings + env, + read: async (file) => { + const url = `${origin}/${file}`; + const response = await /** @type {{ ASSETS: { fetch: typeof fetch } }} */ + env.ASSETS.fetch( + url + ); + if (!response.ok) { + throw new Error( + `read(...) failed: could not fetch ${url} (${response.status} ${response.statusText})` + ); + } + return response.body; + } +}); +var worker_default = { + /** + * @param {Request} req + * @param {{ ASSETS: { fetch: typeof fetch } }} env + * @param {ExecutionContext} ctx + * @returns {Promise} + */ + async fetch(req, env2, ctx) { + // Route agent WebSocket/HTTP requests before SvelteKit + const agentResponse = await routeAgentRequest(req, env2); + if (agentResponse) return agentResponse; + + // Proxy sandbox preview URLs (after agent routing) + // Only relevant with custom domains for preview URL subdomains + try { + const sandboxResponse = await proxyToSandbox(req, env2); + if (sandboxResponse && sandboxResponse.status !== 404) return sandboxResponse; + } catch (e) { + // Sandbox proxy not available — continue to SvelteKit + } + if (!origin) { + origin = new URL(req.url).origin; + } + await initialized; + let pragma = req.headers.get("cache-control") || ""; + let res = !pragma.includes("no-cache") && await r2(req); + if (res) return res; + let { pathname, search } = new URL(req.url); + try { + pathname = decodeURIComponent(pathname); + } catch { + } + const stripped_pathname = pathname.replace(/\/$/, ""); + let is_static_asset = false; + const filename = stripped_pathname.slice(base_path.length + 1); + if (filename) { + is_static_asset = manifest.assets.has(filename) || manifest.assets.has(filename + "/index.html") || filename in manifest._.server_assets || filename + "/index.html" in manifest._.server_assets; + } + let location = pathname.at(-1) === "/" ? stripped_pathname : pathname + "/"; + if (is_static_asset || prerendered.has(pathname) || pathname === version_file || pathname.startsWith(immutable)) { + res = await env2.ASSETS.fetch(req); + } else if (location && prerendered.has(location)) { + if (search) location += search; + res = new Response("", { + status: 308, + headers: { + location + } + }); + } else { + res = await server.respond(req, { + platform: { + env: env2, + ctx, + context: ctx, + // deprecated in favor of ctx + // @ts-expect-error webworker types from worktop are not compatible with Cloudflare Workers types + caches, + // @ts-expect-error the type is correct but ts is confused because platform.cf uses the type from index.ts while req.cf uses the type from index.d.ts + cf: req.cf + }, + getClientAddress() { + return ( + /** @type {string} */ + req.headers.get("cf-connecting-ip") + ); + } + }); + } + pragma = res.headers.get("cache-control") || ""; + return pragma && res.status < 400 ? c(req, res, ctx) : res; + } +}; +export { + worker_default as default +}; + +// Re-export agent classes for Durable Object, Workflow, and Container bindings +export { TaskYouAgent } from "./src/lib/server/agent.ts"; +export { TaskExecutionWorkflow } from "./src/lib/server/workflow.ts"; +export { Sandbox } from "@cloudflare/sandbox"; diff --git a/pilot/wrangler.jsonc b/pilot/wrangler.jsonc new file mode 100644 index 00000000..0c61f43e --- /dev/null +++ b/pilot/wrangler.jsonc @@ -0,0 +1,73 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "taskyou-pilot", + "main": "worker-entry.js", + "compatibility_date": "2025-12-01", + "compatibility_flags": ["nodejs_compat"], + "assets": { + "directory": ".svelte-kit/cloudflare", + "binding": "ASSETS" + }, + "workflows": [ + { + "name": "task-execution", + "binding": "TASK_WORKFLOW", + "class_name": "TaskExecutionWorkflow" + } + ], + "containers": [ + { + "class_name": "Sandbox", + "image": "./Dockerfile.sandbox", + "instance_type": "lite", + "max_instances": 5 + } + ], + "durable_objects": { + "bindings": [ + { + "name": "TASKYOU_AGENT", + "class_name": "TaskYouAgent" + }, + { + "name": "SANDBOX", + "class_name": "Sandbox" + } + ] + }, + "migrations": [ + { + "tag": "v1", + "new_sqlite_classes": ["Sandbox"] + }, + { + "tag": "v2", + "new_sqlite_classes": ["TaskYouAgent"] + } + ], + "d1_databases": [ + { + "binding": "DB", + "database_name": "taskyou-pilot-db", + "database_id": "00fd9e62-cb4c-411a-a1ca-6e3777cba0fd" + } + ], + "kv_namespaces": [ + { + "binding": "SESSIONS", + "id": "97030b57bfd9439cad190747ad778b81" + } + ], + "vars": { + "ENVIRONMENT": "production" + }, + // Secrets (set via `wrangler secret put`): + // ANTHROPIC_API_KEY, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, + // GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET + "r2_buckets": [ + { + "binding": "STORAGE", + "bucket_name": "taskyou-pilot-storage" + } + ] +}