* update cf worker hostname
* set remote_url cookie from param
* ephemeral backends v1
* nit
* Run queue server
* ntis
* timeout
* better db process management
* commit hash and worktree
* nit use map
* nit
* err handling
* Revert "err handling"
This reverts commit 19de00c0c0.
* nits
* auto cleanup
* Ephemeral backend command action
* remove checkout
* checkout ee repo
* nits
* process.env.GIT_EE_DEPLOY_KEY_FILE
* resumeURLs logic
* nit
* use windmill flow for ephemeral backend action
* fixes
* new token
* worktree pools
* Delete GH secret on cleanup
* linux deploy
* nit
* nit
* unhandled promises
* nit
* fix docker bridge IP on linux
* pass cf_frontend_url to wmill flow
* git fetch
* release worktree when binary started
* send error
* logger
* logging
* logging 2
* delete log files periodically
* redirect to raw app with logs
* CORS
* MANAGER_AUTH_TOKEN
* Check organization membership
* nit
* bwrap
* nit
* return timeoutAt in resumeUrl
* nit
* Change password
* nit remove https
671 lines
20 KiB
JavaScript
671 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
import { spawn, exec } from "child_process";
|
||
import { promisify } from "util";
|
||
import * as readline from "readline";
|
||
import { WorktreePool, WorktreeInfo } from "./worktree-pool";
|
||
import { Logger } from "./logger";
|
||
|
||
const execAsync = promisify(exec);
|
||
|
||
export interface Config {
|
||
dbPort: number;
|
||
serverPort: number;
|
||
skipBuild: boolean;
|
||
commitHash: string;
|
||
worktreePool: WorktreePool;
|
||
adminPwd: string;
|
||
onCloudflaredUrl?: (url: string) => void;
|
||
onCleanup?: () => void;
|
||
}
|
||
|
||
interface SpawnedResources {
|
||
dbContainerId: string;
|
||
dbProcess?: any;
|
||
backendProcess?: any;
|
||
cloudflaredProcess?: any;
|
||
tunnelUrl?: string;
|
||
worktree?: WorktreeInfo;
|
||
eeWorktreePath?: string;
|
||
logger?: Logger;
|
||
}
|
||
|
||
// No default config needed since commitHash is always required
|
||
|
||
export class EphemeralBackend {
|
||
private config: Config;
|
||
private resources: SpawnedResources = { dbContainerId: "" };
|
||
|
||
getDbPort(): number {
|
||
return this.config.dbPort;
|
||
}
|
||
getServerPort(): number {
|
||
return this.config.serverPort;
|
||
}
|
||
getLogFilePath(): string | undefined {
|
||
return this.resources.logger?.getLogFilePath();
|
||
}
|
||
|
||
constructor(config: Config) {
|
||
this.config = config;
|
||
}
|
||
|
||
async spawn(): Promise<{
|
||
tunnelUrl: string;
|
||
}> {
|
||
try {
|
||
// Initialize logger for this ephemeral backend
|
||
this.resources.logger = new Logger(this.config.commitHash, "backend");
|
||
|
||
this.resources.logger.log("🚀 Starting ephemeral backend...");
|
||
this.resources.logger.log(`📊 Database port: ${this.config.dbPort}`);
|
||
this.resources.logger.log(`🌐 Server port: ${this.config.serverPort}`);
|
||
this.resources.logger.log(`📌 Commit hash: ${this.config.commitHash}`);
|
||
|
||
await this.startCloudflared();
|
||
if (!this.resources.tunnelUrl)
|
||
throw new Error("Cloudflare tunnel URL not available");
|
||
await this.acquireWorktree();
|
||
await this.setupEECode();
|
||
await this.spawnPostgres();
|
||
await this.waitForPostgres();
|
||
if (!this.config.skipBuild) {
|
||
await this.buildBackend();
|
||
} else {
|
||
this.resources.logger?.log(
|
||
"\n⏭️ Skipping backend build (using existing binary)"
|
||
);
|
||
}
|
||
await this.startBackend();
|
||
|
||
// Release the worktree back to the pool now that the backend is running
|
||
// The binary is already compiled and running, so other spawns can reuse this worktree
|
||
if (this.resources.worktree) {
|
||
this.resources.logger?.log(
|
||
"\n♻️ Releasing worktree back to pool (backend is now running)..."
|
||
);
|
||
await this.config.worktreePool.release(this.resources.worktree.id);
|
||
// Keep the reference for cleanup but mark it as released
|
||
this.resources.worktree = undefined;
|
||
}
|
||
|
||
this.resources.logger?.log("\n✅ Ephemeral backend is ready!");
|
||
this.resources.logger?.log(`📍 Tunnel URL: ${this.resources.tunnelUrl}`);
|
||
this.resources.logger?.log(
|
||
`📄 Log file: ${this.resources.logger.getLogFilePath()}`
|
||
);
|
||
|
||
return {
|
||
tunnelUrl: this.resources.tunnelUrl,
|
||
};
|
||
} catch (error) {
|
||
this.resources.logger?.error(
|
||
`❌ Error spawning ephemeral backend: ${error}`
|
||
);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
private async acquireWorktree(): Promise<void> {
|
||
this.resources.logger?.log("\n📂 Acquiring worktree from pool...");
|
||
|
||
// Acquire a worktree from the pool
|
||
this.resources.worktree = await this.config.worktreePool.acquire(
|
||
this.config.commitHash
|
||
);
|
||
|
||
this.resources.logger?.log(
|
||
`✓ Worktree acquired: ${this.resources.worktree.path}`
|
||
);
|
||
}
|
||
|
||
private async setupEECode(): Promise<void> {
|
||
this.resources.logger?.log("\n🔐 Setting up Enterprise Edition code...");
|
||
|
||
if (!this.resources.worktree) {
|
||
throw new Error("Worktree not acquired");
|
||
}
|
||
|
||
const worktreePath = this.resources.worktree.path;
|
||
const eeRefPath = `${worktreePath}/backend/ee-repo-ref.txt`;
|
||
const eeWorktreePath = this.config.worktreePool.getEEWorktreePath(
|
||
this.resources.worktree
|
||
);
|
||
this.resources.eeWorktreePath = eeWorktreePath;
|
||
|
||
// Read the EE commit hash from ee-repo-ref.txt
|
||
this.resources.logger?.log(
|
||
` Reading EE commit reference from ${eeRefPath}`
|
||
);
|
||
let eeCommitHash: string;
|
||
try {
|
||
const { stdout } = await execAsync(`cat ${eeRefPath}`);
|
||
eeCommitHash = stdout.trim();
|
||
if (!eeCommitHash) {
|
||
throw new Error("ee-repo-ref.txt is empty");
|
||
}
|
||
this.resources.logger?.log(` ✓ EE commit hash: ${eeCommitHash}`);
|
||
} catch (error) {
|
||
throw new Error(
|
||
`Failed to read ee-repo-ref.txt: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
}
|
||
|
||
// Remove existing EE private folder if it exists (from previous runs)
|
||
this.resources.logger?.log(` Cleaning up any existing EE repository...`);
|
||
try {
|
||
await execAsync(`rm -rf ${eeWorktreePath}`);
|
||
} catch (error) {
|
||
// Ignore errors if directory doesn't exist
|
||
}
|
||
|
||
// Clone the windmill-ee-private repo at the specific commit
|
||
this.resources.logger?.log(
|
||
` Cloning windmill-ee-private at commit ${eeCommitHash}`
|
||
);
|
||
try {
|
||
await execAsync(
|
||
`git clone git@github.com:windmill-labs/windmill-ee-private.git ${eeWorktreePath}`,
|
||
{
|
||
env: {
|
||
...process.env,
|
||
GIT_SSH_COMMAND: `ssh -i ${process.env.GIT_EE_DEPLOY_KEY_FILE} -o StrictHostKeyChecking=accept-new`,
|
||
},
|
||
}
|
||
);
|
||
this.resources.logger?.log(` ✓ Repository cloned to ${eeWorktreePath}`);
|
||
} catch (error) {
|
||
throw new Error(
|
||
`Failed to clone windmill-ee-private: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
}
|
||
|
||
// Checkout the specific commit
|
||
this.resources.logger?.log(` Checking out commit ${eeCommitHash}`);
|
||
try {
|
||
await execAsync(`git checkout ${eeCommitHash}`, {
|
||
cwd: eeWorktreePath,
|
||
});
|
||
this.resources.logger?.log(` ✓ Checked out commit ${eeCommitHash}`);
|
||
} catch (error) {
|
||
throw new Error(
|
||
`Failed to checkout EE commit: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
}
|
||
|
||
// Run the substitute_ee_code.sh script to copy EE files
|
||
this.resources.logger?.log(
|
||
` Running substitute_ee_code.sh to copy EE files`
|
||
);
|
||
try {
|
||
await execAsync(`./substitute_ee_code.sh --copy -d ${eeWorktreePath}`, {
|
||
cwd: `${worktreePath}/backend`,
|
||
});
|
||
this.resources.logger?.log(` ✓ EE code substituted successfully`);
|
||
} catch (error) {
|
||
throw new Error(
|
||
`Failed to substitute EE code: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
}
|
||
|
||
this.resources.logger?.log("✓ Enterprise Edition code setup complete");
|
||
}
|
||
|
||
private async spawnPostgres(): Promise<void> {
|
||
this.resources.logger?.log("\n🐘 Spawning PostgreSQL container...");
|
||
|
||
this.resources.dbProcess = spawn("docker", [
|
||
"run",
|
||
"--rm",
|
||
"-p",
|
||
`${this.config.dbPort}:5432`,
|
||
"-e",
|
||
"POSTGRES_PASSWORD=changeme",
|
||
"-e",
|
||
"POSTGRES_DB=windmill",
|
||
"postgres:16",
|
||
]);
|
||
|
||
// Capture and log postgres stdout
|
||
this.resources.dbProcess.stdout.on("data", (data: Buffer) => {
|
||
const output = data.toString().trim();
|
||
if (output) {
|
||
this.resources.logger?.log(`[postgres] ${output}`);
|
||
}
|
||
});
|
||
|
||
// Capture and log postgres stderr
|
||
this.resources.dbProcess.stderr.on("data", (data: Buffer) => {
|
||
const output = data.toString().trim();
|
||
if (output) {
|
||
this.resources.logger?.error(`[postgres] ${output}`);
|
||
}
|
||
});
|
||
|
||
this.resources.dbProcess.on("close", (code: number) => {
|
||
this.resources.logger?.log(`PostgreSQL process exited with code ${code}`);
|
||
});
|
||
}
|
||
|
||
private async waitForPostgres(): Promise<void> {
|
||
this.resources.logger?.log("⏳ Waiting for PostgreSQL to be ready...");
|
||
|
||
const maxAttempts = 30;
|
||
const delayMs = 1000;
|
||
|
||
// Determine the host to connect to
|
||
// On Linux, we need to use the host's IP or localhost
|
||
// On macOS/Windows, host.docker.internal works
|
||
const isLinux = process.platform === "linux";
|
||
const dbHost = isLinux ? "172.17.0.1" : "host.docker.internal";
|
||
|
||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||
try {
|
||
await execAsync(
|
||
`docker run --rm postgres:16 pg_isready -h ${dbHost} -p ${this.config.dbPort} -U postgres`
|
||
);
|
||
this.resources.logger?.log("✓ PostgreSQL is ready");
|
||
return;
|
||
} catch (error) {
|
||
if (attempt === maxAttempts) {
|
||
throw new Error("PostgreSQL failed to start in time");
|
||
}
|
||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||
}
|
||
}
|
||
}
|
||
|
||
private async buildBackend(): Promise<void> {
|
||
this.resources.logger?.log(
|
||
"\n🔨 Building backend (this may take a while)..."
|
||
);
|
||
|
||
if (!this.resources.worktree) {
|
||
throw new Error("Worktree not acquired");
|
||
}
|
||
|
||
// Detect OS to use correct deno_core feature
|
||
const isMacOS = process.platform === "darwin";
|
||
|
||
const env = { ...process.env, SQLX_OFFLINE: "true" };
|
||
|
||
const features = [
|
||
"enterprise",
|
||
"enterprise_saml",
|
||
"stripe",
|
||
"embedding",
|
||
"parquet",
|
||
"prometheus",
|
||
"openidconnect",
|
||
"cloud",
|
||
"jemalloc",
|
||
"agent_worker_server",
|
||
"tantivy",
|
||
"license",
|
||
"http_trigger",
|
||
"zip",
|
||
"oauth2",
|
||
"kafka",
|
||
"sqs_trigger",
|
||
"nats",
|
||
"otel",
|
||
"dind",
|
||
"postgres_trigger",
|
||
"mqtt_trigger",
|
||
"gcp_trigger",
|
||
"websocket",
|
||
"smtp",
|
||
"all_languages",
|
||
"private",
|
||
isMacOS ? "deno_core_mac" : "deno_core",
|
||
"mcp",
|
||
].join(",");
|
||
|
||
return new Promise((resolve, reject) => {
|
||
const backendDir = `${this.resources.worktree?.path}/backend`;
|
||
const buildProcess = spawn(
|
||
"cargo",
|
||
["build", "--features", features, "--release"],
|
||
{ cwd: backendDir, env }
|
||
);
|
||
|
||
buildProcess.stdout.on("data", (data) => {
|
||
const output = data.toString().trim();
|
||
if (output) {
|
||
this.resources.logger?.log(`[build] ${output}`);
|
||
}
|
||
});
|
||
|
||
buildProcess.stderr.on("data", (data) => {
|
||
const output = data.toString().trim();
|
||
if (output) {
|
||
this.resources.logger?.error(`[build] ${output}`);
|
||
}
|
||
});
|
||
|
||
buildProcess.on("close", (code) => {
|
||
if (code === 0) {
|
||
this.resources.logger?.log("✓ Backend built successfully");
|
||
resolve();
|
||
} else {
|
||
this.resources.logger?.error(`Build failed with code ${code}`);
|
||
reject(new Error(`Build failed with code ${code}`));
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
private async startBackend(): Promise<void> {
|
||
this.resources.logger?.log("\n🚀 Starting Windmill backend...");
|
||
|
||
if (!this.resources.worktree) {
|
||
throw new Error("Worktree not acquired");
|
||
}
|
||
|
||
const env = {
|
||
// DO NOT PASS the entire process.env to avoid leaking sensitive information
|
||
...(process.env.LICENSE_KEY && { LICENSE_KEY: process.env.LICENSE_KEY }),
|
||
DATABASE_URL: `postgres://postgres:changeme@localhost:${this.config.dbPort}/windmill?sslmode=disable`,
|
||
PORT: this.config.serverPort.toString(),
|
||
};
|
||
|
||
const releaseDir = `${this.resources.worktree.path}/backend/target/release`;
|
||
|
||
// Get sandbox user UID/GID for proper sandboxing
|
||
const sandboxUid = await this.getSandboxUid();
|
||
const sandboxGid = await this.getSandboxGid();
|
||
|
||
// Use bwrap for sandboxing the windmill process
|
||
this.resources.backendProcess = spawn(
|
||
"bwrap",
|
||
[
|
||
...["--ro-bind", "/", "/"],
|
||
...["--bind", "/home/sandbox", "/home/sandbox"],
|
||
...["--bind", "/tmp", "/tmp"],
|
||
...["--dev", "/dev"],
|
||
...["--proc", "/proc"],
|
||
"--unshare-user",
|
||
...["--uid", sandboxUid],
|
||
...["--gid", sandboxGid],
|
||
`${releaseDir}/windmill`,
|
||
],
|
||
{
|
||
env,
|
||
}
|
||
);
|
||
|
||
// Wait for backend to be ready by watching for "Windmill Enterprise Edition" in output
|
||
const backendReady = new Promise<void>((resolve, reject) => {
|
||
// Capture and log backend stdout, watching for ready signal
|
||
this.resources.backendProcess.stdout.on("data", (data: any) => {
|
||
const output = data.toString().trim();
|
||
const timeout = setTimeout(() => {
|
||
reject(new Error("Backend failed to start in time"));
|
||
}, 30000); // 30 seconds timeout
|
||
if (output) {
|
||
this.resources.logger?.log(`[backend] ${output}`);
|
||
if (output.includes("Windmill Enterprise Edition")) {
|
||
clearTimeout(timeout);
|
||
resolve();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
// Capture and log backend stderr
|
||
this.resources.backendProcess.stderr.on("data", (data: Buffer) => {
|
||
const output = data.toString().trim();
|
||
if (output) {
|
||
this.resources.logger?.error(`[backend] ${output}`);
|
||
}
|
||
});
|
||
|
||
this.resources.backendProcess.on("close", (code: number) => {
|
||
this.resources.logger?.log(`Backend process exited with code ${code}`);
|
||
});
|
||
|
||
// Wait for backend to be ready
|
||
await backendReady;
|
||
this.resources.logger?.log("✓ Backend is running");
|
||
|
||
// Wait 2 additional seconds to be sure
|
||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||
|
||
// Setup admin password
|
||
await this.setupAdminPassword();
|
||
}
|
||
|
||
private async startCloudflared(): Promise<void> {
|
||
if (process.env.SKIP_CLOUDFLARED) {
|
||
this.config.onCloudflaredUrl?.("SKIP_CLOUDFLARED");
|
||
return;
|
||
}
|
||
this.resources.logger?.log("\n🌐 Starting Cloudflare tunnel...");
|
||
|
||
return new Promise((resolve, reject) => {
|
||
this.resources.cloudflaredProcess = spawn("cloudflared", [
|
||
"tunnel",
|
||
"--url",
|
||
`http://localhost:${this.config.serverPort}`,
|
||
"--config",
|
||
"/dev/null",
|
||
]);
|
||
|
||
const rl = readline.createInterface({
|
||
input: this.resources.cloudflaredProcess.stdout,
|
||
});
|
||
|
||
rl.on("line", (line: string) => {
|
||
if (line.trim()) {
|
||
this.resources.logger?.log(`[cloudflared] ${line}`);
|
||
}
|
||
});
|
||
|
||
this.resources.cloudflaredProcess.stderr.on("data", (data: Buffer) => {
|
||
const line = data.toString();
|
||
if (line.trim()) {
|
||
this.resources.logger?.error(`[cloudflared] ${line.trim()}`);
|
||
}
|
||
const match = line.match(/https:\/\/([a-z0-9-]+\.trycloudflare\.com)/);
|
||
if (match) {
|
||
this.resources.tunnelUrl = match[1];
|
||
this.resources.logger?.log(
|
||
`✓ Tunnel URL: ${this.resources.tunnelUrl}`
|
||
);
|
||
this.config.onCloudflaredUrl?.(this.resources.tunnelUrl);
|
||
resolve();
|
||
}
|
||
});
|
||
|
||
this.resources.cloudflaredProcess.on("close", (code: number) => {
|
||
this.resources.logger?.log(
|
||
`Cloudflared process exited with code ${code}`
|
||
);
|
||
});
|
||
|
||
// Timeout if we can't find the URL in 30 seconds
|
||
setTimeout(() => {
|
||
if (!this.resources.tunnelUrl) {
|
||
this.resources.logger?.error(
|
||
"Failed to extract Cloudflare tunnel URL (timeout)"
|
||
);
|
||
reject(new Error("Failed to extract Cloudflare tunnel URL"));
|
||
}
|
||
}, 30000);
|
||
});
|
||
}
|
||
|
||
private async getSandboxUid(): Promise<string> {
|
||
try {
|
||
const { stdout } = await execAsync("id -u sandbox");
|
||
return stdout.trim();
|
||
} catch (error) {
|
||
this.resources.logger?.error(
|
||
`Failed to get sandbox UID: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
throw new Error("Failed to get sandbox user UID");
|
||
}
|
||
}
|
||
|
||
private async getSandboxGid(): Promise<string> {
|
||
try {
|
||
const { stdout } = await execAsync("id -g sandbox");
|
||
return stdout.trim();
|
||
} catch (error) {
|
||
this.resources.logger?.error(
|
||
`Failed to get sandbox GID: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
throw new Error("Failed to get sandbox user GID");
|
||
}
|
||
}
|
||
|
||
private async setupAdminPassword(): Promise<void> {
|
||
this.resources.logger?.log("\n🔑 Setting up admin password...");
|
||
|
||
const baseUrl = `http://localhost:${this.config.serverPort}`;
|
||
|
||
try {
|
||
// Login with default credentials
|
||
this.resources.logger?.log(" Logging in with default credentials...");
|
||
const loginResponse = await fetch(`${baseUrl}/api/auth/login`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
},
|
||
body: JSON.stringify({
|
||
email: "admin@windmill.dev",
|
||
password: "changeme",
|
||
}),
|
||
});
|
||
|
||
if (!loginResponse.ok) {
|
||
throw new Error(
|
||
`Login failed with status ${
|
||
loginResponse.status
|
||
}: ${await loginResponse.text()}`
|
||
);
|
||
}
|
||
|
||
const token = await loginResponse.text();
|
||
this.resources.logger?.log(" ✓ Logged in successfully");
|
||
|
||
// Set new admin password
|
||
this.resources.logger?.log(" Setting new admin password...");
|
||
const setPasswordResponse = await fetch(
|
||
`${baseUrl}/api/users/setpassword`,
|
||
{
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify({
|
||
password: this.config.adminPwd,
|
||
}),
|
||
}
|
||
);
|
||
|
||
if (!setPasswordResponse.ok) {
|
||
throw new Error(
|
||
`Set password failed with status ${
|
||
setPasswordResponse.status
|
||
}: ${await setPasswordResponse.text()}`
|
||
);
|
||
}
|
||
|
||
this.resources.logger?.log("✓ Admin password configured successfully");
|
||
} catch (error) {
|
||
this.resources.logger?.error(
|
||
`Failed to setup admin password: ${
|
||
error instanceof Error ? error.message : String(error)
|
||
}`
|
||
);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
async cleanup(): Promise<void> {
|
||
this.resources.logger?.log("\n🧹 Cleaning up resources...");
|
||
|
||
// Kill backend process
|
||
if (this.resources.backendProcess) {
|
||
this.resources.logger?.log(" Stopping backend...");
|
||
try {
|
||
this.resources.backendProcess.kill("SIGTERM");
|
||
// Give it a moment to gracefully shutdown
|
||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||
// Force kill if still running
|
||
this.resources.backendProcess.kill("SIGKILL");
|
||
} catch (error) {
|
||
// Process might already be dead
|
||
}
|
||
}
|
||
|
||
// Kill cloudflared process
|
||
if (this.resources.cloudflaredProcess) {
|
||
this.resources.logger?.log(" Stopping cloudflared...");
|
||
try {
|
||
this.resources.cloudflaredProcess.kill("SIGTERM");
|
||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||
this.resources.cloudflaredProcess.kill("SIGKILL");
|
||
} catch (error) {
|
||
// Process might already be dead
|
||
}
|
||
}
|
||
|
||
// Kill PostgreSQL process
|
||
if (this.resources.dbProcess) {
|
||
this.resources.logger?.log(" Stopping PostgreSQL container...");
|
||
try {
|
||
this.resources.dbProcess.kill("SIGTERM");
|
||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||
this.resources.dbProcess.kill("SIGKILL");
|
||
this.resources.logger?.log(" ✓ PostgreSQL container stopped");
|
||
} catch (error) {
|
||
console.error(" Failed to stop PostgreSQL container:", error);
|
||
}
|
||
}
|
||
|
||
// Remove EE private repository clone
|
||
if (this.resources.eeWorktreePath) {
|
||
this.resources.logger?.log(" Removing EE private repository clone...");
|
||
try {
|
||
await execAsync(`rm -rf ${this.resources.eeWorktreePath}`);
|
||
this.resources.logger?.log(" ✓ EE private repository clone removed");
|
||
} catch (error) {
|
||
console.error(" Failed to remove EE private repository clone:", error);
|
||
}
|
||
}
|
||
|
||
// Release git worktree back to pool (do not delete it)
|
||
// Note: worktree might already be released if backend started successfully
|
||
if (this.resources.worktree) {
|
||
this.resources.logger?.log(" Releasing worktree back to pool...");
|
||
try {
|
||
await this.config.worktreePool.release(this.resources.worktree.id);
|
||
this.resources.logger?.log(" ✓ Worktree released for reuse");
|
||
} catch (error) {
|
||
console.error(" Failed to release worktree:", error);
|
||
}
|
||
} else {
|
||
this.resources.logger?.log(" ✓ Worktree already released");
|
||
}
|
||
|
||
this.config.onCleanup?.();
|
||
|
||
this.resources.logger?.log("✅ Cleanup complete");
|
||
}
|
||
}
|