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/**"]
+ }
+ }
+}