diff --git a/ee/apps/den-controller/package.json b/ee/apps/den-controller/package.json index 842b3511..66123480 100644 --- a/ee/apps/den-controller/package.json +++ b/ee/apps/den-controller/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "dev": "npm run build:den-db && OPENWORK_DEV_MODE=1 tsx watch src/index.ts", + "dev:local": "sh -lc 'pnpm run build:den-db && OPENWORK_DEV_MODE=1 PORT=${DEN_CONTROLLER_PORT:-8788} WORKER_PROXY_PORT=${DEN_WORKER_PROXY_PORT:-8789} tsx watch src/index.ts'", "build": "npm run build:den-db && tsc -p tsconfig.json", "build:den-db": "pnpm --filter @openwork-ee/den-db build", "start": "node dist/index.js", diff --git a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx index bac68140..3495d92b 100644 --- a/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx +++ b/ee/apps/den-web/app/(den)/_providers/den-flow-provider.tsx @@ -1949,7 +1949,7 @@ export function DenFlowProvider({ children }: { children: ReactNode }) { } onboardingAutoLaunchKeyRef.current = autoLaunchKey; - void launchWorker({ source: "signup_auto", workerNameOverride: onboardingIntent?.workerName ?? DEFAULT_WORKER_NAME }); + markOnboardingComplete(); }, [billingSummary?.featureGateEnabled, billingSummary?.hasActivePlan, launchBusy, onboardingIntent?.workerName, onboardingPending, ownedWorkerCount, user?.id]); useEffect(() => { diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/background-agents-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/background-agents-screen.tsx index 33d3fd60..307410bd 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/background-agents-screen.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/background-agents-screen.tsx @@ -1,6 +1,9 @@ "use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { getWorkerStatusMeta } from "../../../../_lib/den-flow"; +import { useDenFlow } from "../../../../_providers/den-flow-provider"; import { getSharedSetupsRoute } from "../../../../_lib/den-org"; import { useOrgDashboard } from "../_providers/org-dashboard-provider"; @@ -12,13 +15,35 @@ const EXAMPLE_AGENTS = [ }, { name: "Renewal reminder agent", - status: "Paused", + status: "Active", detail: "Source: Customer success setup", }, ]; +function statusClass(bucket: ReturnType["bucket"]) { + switch (bucket) { + case "ready": + return "is-success"; + case "starting": + return "is-neutral"; + case "attention": + return "is-warning"; + default: + return "is-neutral"; + } +} + export function BackgroundAgentsScreen() { + const router = useRouter(); const { orgSlug } = useOrgDashboard(); + const { workers, workersBusy, workersError, launchBusy, launchWorker } = useDenFlow(); + + async function handleAddSandbox() { + const result = await launchWorker({ source: "manual" }); + if (result === "checkout") { + router.push("/checkout"); + } + } return (
@@ -35,9 +60,19 @@ export function BackgroundAgentsScreen() {

- - Open shared setups - +
+ + + Open shared setups + +
@@ -55,23 +90,44 @@ export function BackgroundAgentsScreen() {
+ {workersError ?
{workersError}
: null} +
-

Example workflows

+

{workers.length > 0 ? "Current sandboxes" : "Example workflows"}

Background workflows

- {EXAMPLE_AGENTS.map((agent) => ( -
-
-

{agent.name}

-

{agent.detail}

-
- {agent.status} -
- ))} + {workersBusy ? ( +
Loading sandboxes...
+ ) : workers.length > 0 ? ( + workers.map((worker) => { + const meta = getWorkerStatusMeta(worker.status); + return ( +
+
+

{worker.workerName}

+

+ Source: {worker.provider ? `${worker.provider} sandbox` : "Cloud sandbox"} +

+
+ {meta.label} +
+ ); + }) + ) : ( + EXAMPLE_AGENTS.map((agent) => ( +
+
+

{agent.name}

+

{agent.detail}

+
+ {agent.status} +
+ )) + )}
); diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx index fb66b93f..7b56c9fe 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/org-dashboard-shell.tsx @@ -66,8 +66,8 @@ export function OrgDashboardShell({ children }: { children: ReactNode }) { { href: activeOrg ? getMembersRoute(activeOrg.slug) : "#", label: "Members" }, { href: activeOrg ? getBackgroundAgentsRoute(activeOrg.slug) : "#", label: "Background agents", badge: "Alpha" }, { href: activeOrg ? getCustomLlmProvidersRoute(activeOrg.slug) : "#", label: "Custom LLM providers", badge: "Soon" }, - { href: "/checkout", label: "Billing" }, ]; + const billingNavItem = { href: "/checkout", label: "Billing" }; const dashboardHref = activeOrg ? getOrgDashboardRoute(activeOrg.slug) : "#"; return ( @@ -192,6 +192,19 @@ export function OrgDashboardShell({ children }: { children: ReactNode }) { ); })} + +
+ + {billingNavItem.label} + +
diff --git a/ee/apps/den-web/package.json b/ee/apps/den-web/package.json index e7c93eb5..ebb46a75 100644 --- a/ee/apps/den-web/package.json +++ b/ee/apps/den-web/package.json @@ -4,6 +4,7 @@ "version": "0.0.0", "scripts": { "dev": "OPENWORK_DEV_MODE=1 NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_API_KEY= next dev --hostname 0.0.0.0 --port 3005", + "dev:local": "sh -lc 'OPENWORK_DEV_MODE=1 NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_API_KEY= next dev --hostname 0.0.0.0 --port ${DEN_WEB_PORT:-3005}'", "build": "next build", "start": "next start --hostname 0.0.0.0 --port 3005", "lint": "next lint" diff --git a/ee/apps/den-worker-proxy/package.json b/ee/apps/den-worker-proxy/package.json index a569188a..6219d7dc 100644 --- a/ee/apps/den-worker-proxy/package.json +++ b/ee/apps/den-worker-proxy/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "dev": "npm run build:den-db && OPENWORK_DEV_MODE=1 tsx watch src/server.ts", + "dev:local": "sh -lc 'pnpm run build:den-db && OPENWORK_DEV_MODE=1 PORT=${DEN_WORKER_PROXY_PORT:-8789} tsx watch src/server.ts'", "build": "npm run build:den-db && tsc -p tsconfig.json", "build:den-db": "pnpm --filter @openwork-ee/den-db build", "start": "node dist/server.js" diff --git a/package.json b/package.json index d30eb96a..92650601 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,8 @@ "dev:ui": "OPENWORK_DEV_MODE=1 pnpm --filter @openwork/app dev", "dev:story": "OPENWORK_DEV_MODE=1 pnpm --filter @openwork/story-book dev", "dev:web": "OPENWORK_DEV_MODE=1 pnpm --filter @openwork-ee/den-web dev", - "dev:web-local": "OPENWORK_DEV_MODE=1 bash scripts/dev-web-local.sh", - "dev:den-local": "OPENWORK_DEV_MODE=1 bash scripts/dev-den-local.sh", + "dev:web-local": "pnpm dev:den-local", + "dev:den-local": "node scripts/dev-local.mjs", "dev:den-docker": "bash packaging/docker/den-dev-up.sh", "dev:headless-web": "OPENWORK_DEV_MODE=1 bun scripts/dev-headless-web.ts", "build": "node scripts/build.mjs", @@ -39,6 +39,9 @@ "release:ship:watch": "node scripts/release/ship.mjs --watch", "tauri": "pnpm --filter @openwork/desktop exec tauri" }, + "devDependencies": { + "turbo": "^2.5.5" + }, "pnpm": { "onlyBuiltDependencies": [ "@whiskeysockets/baileys", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ab35c21..8626dac0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,7 +11,11 @@ patchedDependencies: importers: - .: {} + .: + devDependencies: + turbo: + specifier: ^2.5.5 + version: 2.8.20 apps/app: dependencies: @@ -2724,6 +2728,36 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@turbo/darwin-64@2.8.20': + resolution: {integrity: sha512-FQ9EX1xMU5nbwjxXxM3yU88AQQ6Sqc6S44exPRroMcx9XZHqqppl5ymJF0Ig/z3nvQNwDmz1Gsnvxubo+nXWjQ==} + cpu: [x64] + os: [darwin] + + '@turbo/darwin-arm64@2.8.20': + resolution: {integrity: sha512-Gpyh9ATFGThD6/s9L95YWY54cizg/VRWl2B67h0yofG8BpHf67DFAh9nuJVKG7bY0+SBJDAo5cMur+wOl9YOYw==} + cpu: [arm64] + os: [darwin] + + '@turbo/linux-64@2.8.20': + resolution: {integrity: sha512-p2QxWUYyYUgUFG0b0kR+pPi8t7c9uaVlRtjTTI1AbCvVqkpjUfCcReBn6DgG/Hu8xrWdKLuyQFaLYFzQskZbcA==} + cpu: [x64] + os: [linux] + + '@turbo/linux-arm64@2.8.20': + resolution: {integrity: sha512-Gn5yjlZGLRZWarLWqdQzv0wMqyBNIdq1QLi48F1oY5Lo9kiohuf7BPQWtWxeNVS2NgJ1+nb/DzK1JduYC4AWOA==} + cpu: [arm64] + os: [linux] + + '@turbo/windows-64@2.8.20': + resolution: {integrity: sha512-vyaDpYk/8T6Qz5V/X+ihKvKFEZFUoC0oxYpC1sZanK6gaESJlmV3cMRT3Qhcg4D2VxvtC2Jjs9IRkrZGL+exLw==} + cpu: [x64] + os: [win32] + + '@turbo/windows-arm64@2.8.20': + resolution: {integrity: sha512-voicVULvUV5yaGXo0Iue13BcHGYW3u0VgqSbfQwBaHbpj1zLjYV4KIe+7fYIo6DO8FVUJzxFps3ODCQG/Wy2Qw==} + cpu: [arm64] + os: [win32] + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -4555,6 +4589,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + turbo@2.8.20: + resolution: {integrity: sha512-Rb4qk5YT8RUwwdXtkLpkVhNEe/lor6+WV7S5tTlLpxSz6MjV5Qi8jGNn4gS6NAvrYGA/rNrE6YUQM85sCZUDbQ==} + hasBin: true + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -7258,6 +7296,24 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@turbo/darwin-64@2.8.20': + optional: true + + '@turbo/darwin-arm64@2.8.20': + optional: true + + '@turbo/linux-64@2.8.20': + optional: true + + '@turbo/linux-arm64@2.8.20': + optional: true + + '@turbo/windows-64@2.8.20': + optional: true + + '@turbo/windows-arm64@2.8.20': + optional: true + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.6 @@ -9077,6 +9133,15 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + turbo@2.8.20: + optionalDependencies: + '@turbo/darwin-64': 2.8.20 + '@turbo/darwin-arm64': 2.8.20 + '@turbo/linux-64': 2.8.20 + '@turbo/linux-arm64': 2.8.20 + '@turbo/windows-64': 2.8.20 + '@turbo/windows-arm64': 2.8.20 + type-is@1.6.18: dependencies: media-typer: 0.3.0 diff --git a/scripts/dev-local.mjs b/scripts/dev-local.mjs new file mode 100644 index 00000000..9d894b5d --- /dev/null +++ b/scripts/dev-local.mjs @@ -0,0 +1,222 @@ +import { spawn } from "node:child_process" +import net from "node:net" +import os from "node:os" +import path from "node:path" +import process from "node:process" +import { fileURLToPath } from "node:url" + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const rootDir = path.resolve(__dirname, "..") +const composeFile = path.join(rootDir, "packaging", "docker", "docker-compose.web-local.yml") +const composeProject = "openwork-den-local" + +const controllerPort = process.env.DEN_CONTROLLER_PORT?.trim() || "8788" +const workerProxyPort = process.env.DEN_WORKER_PROXY_PORT?.trim() || "8789" +const webPort = process.env.DEN_WEB_PORT?.trim() || "3005" +const databaseUrl = process.env.DATABASE_URL?.trim() || "mysql://root:password@127.0.0.1:3306/openwork_den" + +function detectWebOrigins() { + const origins = new Set([ + `http://localhost:${webPort}`, + `http://127.0.0.1:${webPort}`, + ]) + + for (const entries of Object.values(os.networkInterfaces())) { + for (const entry of entries || []) { + if (!entry || entry.internal || entry.family !== "IPv4") { + continue + } + + origins.add(`http://${entry.address}:${webPort}`) + } + } + + return Array.from(origins).join(",") +} + +function parseDatabaseEndpoint(value) { + const parsed = new URL(value) + return { + host: parsed.hostname, + port: Number(parsed.port || "3306"), + } +} + +function canReachMysql(host, port) { + return new Promise((resolve) => { + const socket = net.createConnection({ host, port }) + + const finalize = (result) => { + socket.destroy() + resolve(result) + } + + socket.setTimeout(1500) + socket.once("connect", () => finalize(true)) + socket.once("error", () => finalize(false)) + socket.once("timeout", () => finalize(false)) + }) +} + +function canListenOnPort(port) { + return new Promise((resolve) => { + const server = net.createServer() + + const finalize = (result) => { + server.close(() => resolve(result)) + } + + server.once("error", () => resolve(false)) + server.once("listening", () => finalize(true)) + server.listen(port, "0.0.0.0") + }) +} + +function run(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: rootDir, + stdio: "inherit", + ...options, + }) + + child.once("error", reject) + child.once("exit", (code, signal) => { + if (code === 0) { + resolve() + return + } + + const detail = signal ? `signal ${signal}` : `exit code ${code ?? 1}` + reject(new Error(`${command} ${args.join(" ")} failed with ${detail}`)) + }) + }) +} + +let startedMysql = false +let turboChild = null +let cleaningUp = false + +function stopTurboChild() { + if (!turboChild || turboChild.exitCode !== null) { + return Promise.resolve() + } + + return new Promise((resolve) => { + turboChild.once("exit", resolve) + + try { + if (process.platform !== "win32") { + process.kill(-turboChild.pid, "SIGINT") + } else { + turboChild.kill("SIGINT") + } + } catch { + turboChild.kill("SIGINT") + } + }) +} + +async function cleanup(exitCode = 0) { + if (cleaningUp) { + return + } + + cleaningUp = true + + await stopTurboChild() + + if (startedMysql) { + await run("docker", ["compose", "-p", composeProject, "-f", composeFile, "down"], { + stdio: "inherit", + }).catch(() => {}) + } + + process.exit(exitCode) +} + +for (const signal of ["SIGINT", "SIGTERM"]) { + process.on(signal, () => { + void cleanup(0) + }) +} + +async function main() { + for (const [name, port] of [["den-web", webPort], ["den-controller", controllerPort], ["den-worker-proxy", workerProxyPort]]) { + const available = await canListenOnPort(Number(port)) + if (!available) { + throw new Error(`${name} local port ${port} is already in use. Stop the existing process or rerun with a different port env override.`) + } + } + + const { host, port } = parseDatabaseEndpoint(databaseUrl) + const mysqlAvailable = await canReachMysql(host, port) + + if (!mysqlAvailable) { + if (!(host === "127.0.0.1" || host === "localhost")) { + throw new Error(`MySQL at ${host}:${port} is not reachable, and auto-start only supports localhost`) + } + + console.log(`[den] MySQL not reachable at ${host}:${port}; starting Docker MySQL...`) + await run("docker", ["compose", "-p", composeProject, "-f", composeFile, "up", "-d", "--wait", "mysql"]) + startedMysql = true + } else { + console.log(`[den] Using existing MySQL at ${host}:${port}`) + } + + console.log("[den] Syncing Den schema...") + await run("bash", ["-lc", "pnpm --filter @openwork-ee/den-db build && pnpm --filter @openwork-ee/den-db exec node --import tsx ./node_modules/drizzle-kit/bin.cjs push --config drizzle.config.ts --force"], { + env: { + ...process.env, + DATABASE_URL: databaseUrl, + }, + }) + + const webOrigins = detectWebOrigins() + console.log(`[den] Allowed local web origins: ${webOrigins}`) + + turboChild = spawn( + "pnpm", + [ + "exec", + "turbo", + "run", + "dev:local", + "--output-logs=full", + "--filter=@openwork-ee/den-controller", + "--filter=@openwork-ee/den-worker-proxy", + "--filter=@openwork-ee/den-web", + ], + { + cwd: rootDir, + stdio: "inherit", + detached: process.platform !== "win32", + env: { + ...process.env, + OPENWORK_DEV_MODE: process.env.OPENWORK_DEV_MODE?.trim() || "1", + DATABASE_URL: databaseUrl, + BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET?.trim() || "local-dev-secret-not-for-production-use!!", + BETTER_AUTH_URL: process.env.BETTER_AUTH_URL?.trim() || `http://localhost:${webPort}`, + DEN_BETTER_AUTH_TRUSTED_ORIGINS: process.env.DEN_BETTER_AUTH_TRUSTED_ORIGINS?.trim() || webOrigins, + CORS_ORIGINS: process.env.CORS_ORIGINS?.trim() || webOrigins, + DEN_CONTROLLER_PORT: controllerPort, + DEN_WORKER_PROXY_PORT: workerProxyPort, + DEN_WEB_PORT: webPort, + DEN_API_BASE: process.env.DEN_API_BASE?.trim() || `http://127.0.0.1:${controllerPort}`, + DEN_AUTH_ORIGIN: process.env.DEN_AUTH_ORIGIN?.trim() || `http://localhost:${webPort}`, + DEN_AUTH_FALLBACK_BASE: process.env.DEN_AUTH_FALLBACK_BASE?.trim() || `http://127.0.0.1:${controllerPort}`, + PROVISIONER_MODE: process.env.PROVISIONER_MODE?.trim() || "stub", + }, + }, + ) + + turboChild.once("exit", (code, signal) => { + const exitCode = code ?? (signal ? 1 : 0) + void cleanup(exitCode) + }) +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)) + void cleanup(1) +}) diff --git a/turbo.json b/turbo.json new file mode 100644 index 00000000..1c48e375 --- /dev/null +++ b/turbo.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalEnv": [ + "OPENWORK_DEV_MODE", + "DATABASE_URL", + "BETTER_AUTH_SECRET", + "BETTER_AUTH_URL", + "DEN_BETTER_AUTH_TRUSTED_ORIGINS", + "CORS_ORIGINS", + "DEN_CONTROLLER_PORT", + "DEN_WORKER_PROXY_PORT", + "DEN_WEB_PORT", + "DEN_API_BASE", + "DEN_AUTH_ORIGIN", + "DEN_AUTH_FALLBACK_BASE", + "PROVISIONER_MODE" + ], + "tasks": { + "dev": { + "cache": false, + "persistent": true + }, + "dev:local": { + "cache": false, + "persistent": true + }, + "build": { + "dependsOn": ["^build"], + "outputs": ["dist/**", ".next/**"] + } + } +}