mirror of
https://github.com/different-ai/openwork
synced 2026-05-11 17:46:23 +02:00
* refactor(repo): move OpenWork apps into apps and ee layout Rebase the monorepo layout migration onto the latest dev changes so the moved app, desktop, share, and cloud surfaces keep working from their new paths. Carry the latest deeplink, token persistence, build, Vercel, and docs updates forward to avoid stale references and broken deploy tooling. * chore(repo): drop generated desktop artifacts Ignore the moved Tauri target and sidecar paths so local cargo checks do not pollute the branch. Remove the accidentally committed outputs from the repo while keeping the layout migration intact. * fix(release): drop built server cli artifact Stop tracking the locally built apps/server/cli binary so generated server outputs do not leak into commits. Also update the release workflow to check the published scoped package name for @openwork/server before deciding whether npm publish is needed. * fix(workspace): add stable CLI bin wrappers Point the server and router package bins at committed wrapper scripts so workspace installs can create shims before dist outputs exist. Keep the wrappers compatible with built binaries and source checkouts to avoid Vercel install warnings without changing runtime behavior.
490 lines
14 KiB
JavaScript
490 lines
14 KiB
JavaScript
import { randomUUID } from "node:crypto"
|
|
import { once } from "node:events"
|
|
import { existsSync } from "node:fs"
|
|
import net from "node:net"
|
|
import { dirname, join, resolve } from "node:path"
|
|
import { fileURLToPath } from "node:url"
|
|
import { setTimeout as delay } from "node:timers/promises"
|
|
import { spawn } from "node:child_process"
|
|
import dotenv from "dotenv"
|
|
import mysql from "mysql2/promise"
|
|
import { Daytona } from "@daytonaio/sdk"
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
const serviceDir = resolve(__dirname, "..")
|
|
const repoRoot = resolve(serviceDir, "..", "..")
|
|
|
|
function log(message) {
|
|
process.stdout.write(`${message}\n`)
|
|
}
|
|
|
|
function fail(message, detail) {
|
|
if (detail !== undefined) {
|
|
console.error(message, detail)
|
|
} else {
|
|
console.error(message)
|
|
}
|
|
process.exit(1)
|
|
}
|
|
|
|
function findUpwards(startDir, fileName, maxDepth = 8) {
|
|
let current = startDir
|
|
for (let depth = 0; depth <= maxDepth; depth += 1) {
|
|
const candidate = join(current, fileName)
|
|
if (existsSync(candidate)) {
|
|
return candidate
|
|
}
|
|
const parent = dirname(current)
|
|
if (parent === current) {
|
|
break
|
|
}
|
|
current = parent
|
|
}
|
|
return null
|
|
}
|
|
|
|
const daytonaEnvPath = process.env.OPENWORK_DAYTONA_ENV_PATH?.trim() || findUpwards(repoRoot, ".env.daytona")
|
|
if (daytonaEnvPath) {
|
|
dotenv.config({ path: daytonaEnvPath, override: false })
|
|
}
|
|
|
|
function slug(value) {
|
|
return value
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9-]+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-|-$/g, "")
|
|
}
|
|
|
|
function workerHint(workerId) {
|
|
return workerId.replace(/-/g, "").slice(0, 12)
|
|
}
|
|
|
|
function sandboxLabels(workerId) {
|
|
return {
|
|
"openwork.den.provider": "daytona",
|
|
"openwork.den.worker-id": workerId,
|
|
}
|
|
}
|
|
|
|
function workspaceVolumeName(workerId) {
|
|
const prefix = process.env.DAYTONA_VOLUME_NAME_PREFIX || "den-daytona-worker"
|
|
return slug(`${prefix}-${workerHint(workerId)}-workspace`).slice(0, 63)
|
|
}
|
|
|
|
function dataVolumeName(workerId) {
|
|
const prefix = process.env.DAYTONA_VOLUME_NAME_PREFIX || "den-daytona-worker"
|
|
return slug(`${prefix}-${workerHint(workerId)}-data`).slice(0, 63)
|
|
}
|
|
|
|
async function getFreePort() {
|
|
return await new Promise((resolvePort, reject) => {
|
|
const server = net.createServer()
|
|
server.listen(0, "127.0.0.1", () => {
|
|
const address = server.address()
|
|
if (!address || typeof address === "string") {
|
|
reject(new Error("failed_to_resolve_free_port"))
|
|
return
|
|
}
|
|
server.close((error) => (error ? reject(error) : resolvePort(address.port)))
|
|
})
|
|
server.on("error", reject)
|
|
})
|
|
}
|
|
|
|
function spawnCommand(command, args, options = {}) {
|
|
return spawn(command, args, {
|
|
cwd: serviceDir,
|
|
env: process.env,
|
|
stdio: "pipe",
|
|
...options,
|
|
})
|
|
}
|
|
|
|
async function runCommand(command, args, options = {}) {
|
|
const child = spawnCommand(command, args, options)
|
|
let stdout = ""
|
|
let stderr = ""
|
|
child.stdout?.on("data", (chunk) => {
|
|
stdout += chunk.toString()
|
|
})
|
|
child.stderr?.on("data", (chunk) => {
|
|
stderr += chunk.toString()
|
|
})
|
|
const [code] = await once(child, "exit")
|
|
if (code !== 0) {
|
|
throw new Error(`${command} ${args.join(" ")} failed\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`)
|
|
}
|
|
return { stdout, stderr }
|
|
}
|
|
|
|
async function waitForMysqlConnection(databaseUrl, attempts = 60) {
|
|
for (let index = 0; index < attempts; index += 1) {
|
|
try {
|
|
const connection = await mysql.createConnection(databaseUrl)
|
|
await connection.query("SELECT 1")
|
|
await connection.end()
|
|
return
|
|
} catch {
|
|
await delay(1000)
|
|
}
|
|
}
|
|
throw new Error("mysql_not_ready")
|
|
}
|
|
|
|
async function waitForHttp(url, attempts = 60, intervalMs = 500) {
|
|
for (let index = 0; index < attempts; index += 1) {
|
|
try {
|
|
const response = await fetch(url)
|
|
if (response.ok) {
|
|
return response
|
|
}
|
|
} catch {
|
|
// ignore until retries are exhausted
|
|
}
|
|
await delay(intervalMs)
|
|
}
|
|
throw new Error(`http_not_ready:${url}`)
|
|
}
|
|
|
|
async function waitForWorkerReady(baseUrl, workerId, auth, attempts = 180) {
|
|
for (let index = 0; index < attempts; index += 1) {
|
|
const result = await requestJson(baseUrl, `/v1/workers/${workerId}`, auth)
|
|
if (result.response.ok && result.payload?.instance?.url && result.payload?.worker?.status === "healthy") {
|
|
return result.payload
|
|
}
|
|
await delay(5000)
|
|
}
|
|
throw new Error(`worker_not_ready:${workerId}`)
|
|
}
|
|
|
|
async function waitForDaytonaCleanup(daytona, workerId, attempts = 60) {
|
|
for (let index = 0; index < attempts; index += 1) {
|
|
const sandboxes = await daytona.list(sandboxLabels(workerId), 1, 20)
|
|
const volumes = await daytona.volume.list()
|
|
const remainingVolumes = volumes.filter((volume) =>
|
|
[workspaceVolumeName(workerId), dataVolumeName(workerId)].includes(volume.name),
|
|
)
|
|
|
|
if (sandboxes.items.length === 0 && remainingVolumes.length === 0) {
|
|
return
|
|
}
|
|
|
|
await delay(5000)
|
|
}
|
|
|
|
throw new Error(`daytona_cleanup_incomplete:${workerId}`)
|
|
}
|
|
|
|
async function forceDeleteDaytonaResources(daytona, workerId) {
|
|
const sandboxes = await daytona.list(sandboxLabels(workerId), 1, 20)
|
|
for (const sandbox of sandboxes.items) {
|
|
await sandbox.delete(120).catch(() => {})
|
|
}
|
|
|
|
const volumes = await daytona.volume.list()
|
|
for (const volumeName of [workspaceVolumeName(workerId), dataVolumeName(workerId)]) {
|
|
const volume = volumes.find((entry) => entry.name === volumeName)
|
|
if (volume) {
|
|
await daytona.volume.delete(volume).catch(() => {})
|
|
}
|
|
}
|
|
}
|
|
|
|
function extractAuthToken(payload) {
|
|
if (!payload || typeof payload !== "object") {
|
|
return null
|
|
}
|
|
if (typeof payload.token === "string" && payload.token.trim()) {
|
|
return payload.token
|
|
}
|
|
if (payload.session && typeof payload.session === "object" && typeof payload.session.token === "string") {
|
|
return payload.session.token
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function requestJson(baseUrl, path, { method = "GET", body, token, cookie } = {}) {
|
|
const headers = new Headers()
|
|
const origin = process.env.DEN_BROWSER_ORIGIN?.trim() || new URL(baseUrl).origin
|
|
headers.set("Accept", "application/json")
|
|
headers.set("Origin", origin)
|
|
headers.set("Referer", `${origin}/`)
|
|
if (body !== undefined) {
|
|
headers.set("Content-Type", "application/json")
|
|
}
|
|
if (token) {
|
|
headers.set("Authorization", `Bearer ${token}`)
|
|
}
|
|
if (cookie) {
|
|
headers.set("Cookie", cookie)
|
|
}
|
|
|
|
const response = await fetch(`${baseUrl}${path}`, {
|
|
method,
|
|
headers,
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
})
|
|
|
|
const text = await response.text()
|
|
let payload = null
|
|
if (text) {
|
|
try {
|
|
payload = JSON.parse(text)
|
|
} catch {
|
|
payload = text
|
|
}
|
|
}
|
|
|
|
return {
|
|
response,
|
|
payload,
|
|
cookie: response.headers.get("set-cookie"),
|
|
}
|
|
}
|
|
|
|
async function main() {
|
|
if (!process.env.DAYTONA_API_KEY) {
|
|
fail("DAYTONA_API_KEY is required. Add it to .env.daytona or export it before running the test.")
|
|
}
|
|
|
|
const existingBaseUrl = process.env.DEN_BASE_URL?.trim() || process.env.DEN_API_URL?.trim() || ""
|
|
const mysqlPort = existingBaseUrl ? null : await getFreePort()
|
|
const appPort = existingBaseUrl ? null : await getFreePort()
|
|
const containerName = existingBaseUrl
|
|
? null
|
|
: `openwork-den-daytona-${randomUUID().slice(0, 8)}`
|
|
const dbName = "openwork_den_daytona_e2e"
|
|
const dbPassword = "openwork-root"
|
|
const baseUrl = existingBaseUrl || `http://127.0.0.1:${appPort}`
|
|
const databaseUrl = mysqlPort
|
|
? `mysql://root:${dbPassword}@127.0.0.1:${mysqlPort}/${dbName}`
|
|
: null
|
|
const runtimeEnv = {
|
|
...process.env,
|
|
...(databaseUrl ? { DATABASE_URL: databaseUrl } : {}),
|
|
BETTER_AUTH_SECRET: "openwork-den-daytona-secret-0000000000",
|
|
BETTER_AUTH_URL: baseUrl,
|
|
...(appPort ? { PORT: String(appPort) } : {}),
|
|
CORS_ORIGINS: baseUrl,
|
|
PROVISIONER_MODE: "daytona",
|
|
POLAR_FEATURE_GATE_ENABLED: "false",
|
|
OPENWORK_DAYTONA_ENV_PATH: daytonaEnvPath || process.env.OPENWORK_DAYTONA_ENV_PATH || "",
|
|
}
|
|
|
|
const daytona = new Daytona({
|
|
apiKey: runtimeEnv.DAYTONA_API_KEY,
|
|
apiUrl: runtimeEnv.DAYTONA_API_URL,
|
|
...(runtimeEnv.DAYTONA_TARGET ? { target: runtimeEnv.DAYTONA_TARGET } : {}),
|
|
})
|
|
|
|
let serviceProcess = null
|
|
let workerId = null
|
|
|
|
const cleanup = async () => {
|
|
if (workerId) {
|
|
try {
|
|
await forceDeleteDaytonaResources(daytona, workerId)
|
|
} catch {
|
|
// cleanup best effort only
|
|
}
|
|
}
|
|
|
|
if (serviceProcess && !serviceProcess.killed) {
|
|
serviceProcess.kill("SIGINT")
|
|
await once(serviceProcess, "exit").catch(() => {})
|
|
}
|
|
|
|
if (containerName) {
|
|
await runCommand("docker", ["rm", "-f", containerName], { cwd: serviceDir }).catch(() => {})
|
|
}
|
|
}
|
|
|
|
process.on("SIGINT", async () => {
|
|
await cleanup()
|
|
process.exit(130)
|
|
})
|
|
|
|
try {
|
|
if (containerName && mysqlPort && databaseUrl && appPort) {
|
|
log("Starting disposable MySQL container...")
|
|
await runCommand("docker", [
|
|
"run",
|
|
"-d",
|
|
"--rm",
|
|
"--name",
|
|
containerName,
|
|
"-e",
|
|
`MYSQL_ROOT_PASSWORD=${dbPassword}`,
|
|
"-e",
|
|
`MYSQL_DATABASE=${dbName}`,
|
|
"-p",
|
|
`${mysqlPort}:3306`,
|
|
"mysql:8.4",
|
|
])
|
|
|
|
log("Waiting for MySQL...")
|
|
await waitForMysqlConnection(databaseUrl)
|
|
|
|
log("Running Den migrations...")
|
|
await runCommand("pnpm", ["db:migrate"], { cwd: serviceDir, env: runtimeEnv })
|
|
|
|
log("Starting Den service with Daytona provisioner...")
|
|
serviceProcess = spawn("pnpm", ["exec", "tsx", "src/index.ts"], {
|
|
cwd: serviceDir,
|
|
env: runtimeEnv,
|
|
stdio: "pipe",
|
|
})
|
|
|
|
let serviceOutput = ""
|
|
serviceProcess.stdout?.on("data", (chunk) => {
|
|
serviceOutput += chunk.toString()
|
|
})
|
|
serviceProcess.stderr?.on("data", (chunk) => {
|
|
serviceOutput += chunk.toString()
|
|
})
|
|
|
|
serviceProcess.on("exit", (code) => {
|
|
if (code !== 0) {
|
|
console.error(serviceOutput)
|
|
}
|
|
})
|
|
} else {
|
|
log(`Using existing Den API at ${baseUrl}`)
|
|
}
|
|
|
|
await waitForHttp(`${baseUrl}/health`)
|
|
|
|
const email = `den-daytona-${Date.now()}@example.com`
|
|
const password = "TestPass123!"
|
|
|
|
log("Creating account...")
|
|
const signup = await requestJson(baseUrl, "/api/auth/sign-up/email", {
|
|
method: "POST",
|
|
body: {
|
|
name: "Den Daytona E2E",
|
|
email,
|
|
password,
|
|
},
|
|
})
|
|
|
|
if (!signup.response.ok) {
|
|
fail("Signup failed", signup.payload)
|
|
}
|
|
|
|
const token = extractAuthToken(signup.payload)
|
|
const cookie = signup.cookie
|
|
if (!token && !cookie) {
|
|
fail("Signup did not return a bearer token or session cookie", signup.payload)
|
|
}
|
|
|
|
const auth = { token, cookie }
|
|
|
|
log("Validating authenticated session...")
|
|
const me = await requestJson(baseUrl, "/v1/me", auth)
|
|
if (!me.response.ok) {
|
|
fail("Session lookup failed", me.payload)
|
|
}
|
|
|
|
log("Creating Daytona-backed cloud worker...")
|
|
const createWorker = await requestJson(baseUrl, "/v1/workers", {
|
|
method: "POST",
|
|
...auth,
|
|
body: {
|
|
name: "daytona-worker",
|
|
destination: "cloud",
|
|
},
|
|
})
|
|
|
|
if (createWorker.response.status !== 202) {
|
|
fail("Worker creation did not return async launch", {
|
|
status: createWorker.response.status,
|
|
payload: createWorker.payload,
|
|
})
|
|
}
|
|
|
|
workerId = createWorker.payload?.worker?.id || null
|
|
if (!workerId) {
|
|
fail("Worker response did not include an id", createWorker.payload)
|
|
}
|
|
|
|
log("Waiting for worker provisioning to finish...")
|
|
const workerPayload = await waitForWorkerReady(baseUrl, workerId, auth)
|
|
if (workerPayload.instance.provider !== "daytona") {
|
|
fail("Worker instance did not report the Daytona provider", workerPayload)
|
|
}
|
|
|
|
log("Checking worker health endpoint...")
|
|
await waitForHttp(`${workerPayload.instance.url.replace(/\/$/, "")}/health`, 120, 5000)
|
|
|
|
log("Checking OpenWork connect metadata...")
|
|
const tokensResponse = await requestJson(baseUrl, `/v1/workers/${workerId}/tokens`, {
|
|
method: "POST",
|
|
...auth,
|
|
})
|
|
if (!tokensResponse.response.ok || !tokensResponse.payload?.connect?.openworkUrl) {
|
|
fail("Worker tokens/connect payload missing", tokensResponse.payload)
|
|
}
|
|
|
|
const clientToken = tokensResponse.payload.tokens?.client
|
|
if (!clientToken) {
|
|
fail("Client token missing from worker token payload", tokensResponse.payload)
|
|
}
|
|
|
|
const connectHeaders = {
|
|
Accept: "application/json",
|
|
Authorization: `Bearer ${clientToken}`,
|
|
}
|
|
const statusResponse = await fetch(`${tokensResponse.payload.connect.openworkUrl}/status`, {
|
|
headers: connectHeaders,
|
|
})
|
|
if (!statusResponse.ok) {
|
|
fail("Connected worker /status failed", await statusResponse.text())
|
|
}
|
|
|
|
const capabilitiesResponse = await fetch(`${tokensResponse.payload.connect.openworkUrl}/capabilities`, {
|
|
headers: connectHeaders,
|
|
})
|
|
if (!capabilitiesResponse.ok) {
|
|
fail("Connected worker /capabilities failed", await capabilitiesResponse.text())
|
|
}
|
|
|
|
log("Verifying Daytona resources exist...")
|
|
const sandboxes = await daytona.list(sandboxLabels(workerId), 1, 20)
|
|
if (sandboxes.items.length === 0) {
|
|
fail("Expected a Daytona sandbox for the worker but none were found")
|
|
}
|
|
const volumes = await daytona.volume.list()
|
|
const expectedVolumeNames = [workspaceVolumeName(workerId), dataVolumeName(workerId)]
|
|
const missingVolumes = expectedVolumeNames.filter(
|
|
(name) => !volumes.some((volume) => volume.name === name),
|
|
)
|
|
if (missingVolumes.length > 0) {
|
|
fail("Expected Daytona volumes were not created", missingVolumes)
|
|
}
|
|
|
|
log("Deleting worker and waiting for Daytona cleanup...")
|
|
const deleteResponse = await requestJson(baseUrl, `/v1/workers/${workerId}`, {
|
|
method: "DELETE",
|
|
...auth,
|
|
})
|
|
if (deleteResponse.response.status !== 204) {
|
|
fail("Worker deletion failed", {
|
|
status: deleteResponse.response.status,
|
|
payload: deleteResponse.payload,
|
|
})
|
|
}
|
|
|
|
await waitForDaytonaCleanup(daytona, workerId)
|
|
workerId = null
|
|
|
|
log("Daytona worker flow passed.")
|
|
} finally {
|
|
await cleanup()
|
|
}
|
|
}
|
|
|
|
main().catch((error) => {
|
|
fail(error instanceof Error ? error.message : String(error))
|
|
})
|