diff --git a/README.md b/README.md index 6774a5dbd..8bfc644fd 100644 --- a/README.md +++ b/README.md @@ -126,3 +126,42 @@ Clean up Docker containers: } } ``` + +## Workspace Environment Variables + +Array automatically sets environment variables in all workspace terminals and scripts. These are available in `init`, `start`, and `destroy` scripts, as well as any terminal sessions opened within a workspace. + +| Variable | Description | Example | +|----------|-------------|---------| +| `ARRAY_WORKSPACE_NAME` | Worktree name, or folder name in root mode | `my-feature-branch` | +| `ARRAY_WORKSPACE_PATH` | Absolute path to the workspace | `/Users/dev/.array/worktrees/repo/my-feature` | +| `ARRAY_ROOT_PATH` | Absolute path to the repository root | `/Users/dev/repos/my-project` | +| `ARRAY_DEFAULT_BRANCH` | Default branch detected from git | `main` | +| `ARRAY_WORKSPACE_BRANCH` | Initial branch when workspace was created | `array/my-feature` | +| `ARRAY_WORKSPACE_PORTS` | Comma-separated list of allocated ports | `50000,50001,...,50019` | +| `ARRAY_WORKSPACE_PORTS_RANGE` | Number of ports allocated | `20` | +| `ARRAY_WORKSPACE_PORTS_START` | First port in the range | `50000` | +| `ARRAY_WORKSPACE_PORTS_END` | Last port in the range | `50019` | + +Note: `ARRAY_WORKSPACE_BRANCH` reflects the branch at workspace creation time. If you or the agent checks out a different branch, this variable will still show the original branch name. + +### Port Allocation + +Each workspace is assigned a unique range of 20 ports starting from port 50000. The allocation is deterministic based on the task ID, so the same workspace always receives the same ports across restarts. + +### Usage Examples + +Use ports in your start scripts: +```json +{ + "scripts": { + "start": "npm run dev -- --port $ARRAY_WORKSPACE_PORTS_START" + } +} +``` + +Reference the workspace path: +```bash +echo "Working in: $ARRAY_WORKSPACE_NAME" +echo "Root repo: $ARRAY_ROOT_PATH" +``` diff --git a/apps/array/src/main/lib/shellManager.ts b/apps/array/src/main/lib/shellManager.ts index a0d351a7b..502268f96 100644 --- a/apps/array/src/main/lib/shellManager.ts +++ b/apps/array/src/main/lib/shellManager.ts @@ -21,7 +21,9 @@ function getDefaultShell(): string { return process.env.SHELL || "/bin/bash"; } -function buildShellEnv(): Record { +function buildShellEnv( + additionalEnv?: Record, +): Record { const env = { ...process.env } as Record; if (os.platform() === "darwin" && !process.env.LC_ALL) { @@ -38,6 +40,10 @@ function buildShellEnv(): Record { env.COLORTERM = "truecolor"; env.FORCE_COLOR = "3"; + if (additionalEnv) { + Object.assign(env, additionalEnv); + } + return env; } @@ -46,13 +52,15 @@ export interface CreateSessionOptions { webContents: WebContents; cwd?: string; initialCommand?: string; + additionalEnv?: Record; } class ShellManagerImpl { private sessions = new Map(); createSession(options: CreateSessionOptions): ShellSession { - const { sessionId, webContents, cwd, initialCommand } = options; + const { sessionId, webContents, cwd, initialCommand, additionalEnv } = + options; const existing = this.sessions.get(sessionId); if (existing) { @@ -74,7 +82,7 @@ class ShellManagerImpl { `Creating shell session ${sessionId}: shell=${shell}, cwd=${workingDir}`, ); - const env = buildShellEnv(); + const env = buildShellEnv(additionalEnv); const ptyProcess = pty.spawn(shell, ["-l"], { name: "xterm-256color", cols: 80, diff --git a/apps/array/src/main/preload.ts b/apps/array/src/main/preload.ts index 8a59343e3..53869f172 100644 --- a/apps/array/src/main/preload.ts +++ b/apps/array/src/main/preload.ts @@ -302,9 +302,12 @@ contextBridge.exposeInMainWorld("electronAPI", { createVoidIpcListener("updates:ready", listener), installUpdate: (): Promise<{ installed: boolean }> => ipcRenderer.invoke("updates:install"), - // Shell API - shellCreate: (sessionId: string, cwd?: string): Promise => - ipcRenderer.invoke("shell:create", sessionId, cwd), + shellCreate: ( + sessionId: string, + cwd?: string, + taskId?: string, + ): Promise => + ipcRenderer.invoke("shell:create", sessionId, cwd, taskId), shellWrite: (sessionId: string, data: string): Promise => ipcRenderer.invoke("shell:write", sessionId, data), shellResize: (sessionId: string, cols: number, rows: number): Promise => diff --git a/apps/array/src/main/services/git.ts b/apps/array/src/main/services/git.ts index 14a57ebfc..756a7bfb5 100644 --- a/apps/array/src/main/services/git.ts +++ b/apps/array/src/main/services/git.ts @@ -77,7 +77,7 @@ export const getRemoteUrl = async ( } }; -const getCurrentBranch = async ( +export const getCurrentBranch = async ( directoryPath: string, ): Promise => { try { @@ -90,7 +90,9 @@ const getCurrentBranch = async ( } }; -const getDefaultBranch = async (directoryPath: string): Promise => { +export const getDefaultBranch = async ( + directoryPath: string, +): Promise => { try { // Try to get the default branch from origin const { stdout } = await execAsync( diff --git a/apps/array/src/main/services/shell.ts b/apps/array/src/main/services/shell.ts index 44a271fe8..f1bf4c008 100644 --- a/apps/array/src/main/services/shell.ts +++ b/apps/array/src/main/services/shell.ts @@ -1,16 +1,38 @@ import { createIpcHandler } from "../lib/ipcHandler"; import { shellManager } from "../lib/shellManager"; +import { foldersStore } from "./store"; +import { buildWorkspaceEnv } from "./workspace/workspaceEnv"; const handle = createIpcHandler("shell"); export function registerShellIpc(): void { - handle("shell:create", (event, sessionId: string, cwd?: string) => { - shellManager.createSession({ - sessionId, - webContents: event.sender, - cwd, - }); - }); + handle( + "shell:create", + async (event, sessionId: string, cwd?: string, taskId?: string) => { + let additionalEnv: Record | undefined; + + if (taskId) { + const associations = foldersStore.get("taskAssociations", []); + const association = associations.find((a) => a.taskId === taskId); + if (association && association.mode !== "cloud") { + additionalEnv = await buildWorkspaceEnv({ + taskId, + folderPath: association.folderPath, + worktreePath: association.worktree?.worktreePath ?? null, + worktreeName: association.worktree?.worktreeName ?? null, + mode: association.mode, + }); + } + } + + shellManager.createSession({ + sessionId, + webContents: event.sender, + cwd, + additionalEnv, + }); + }, + ); handle("shell:write", (_event, sessionId: string, data: string) => { shellManager.write(sessionId, data); diff --git a/apps/array/src/main/services/workspace/scriptRunner.ts b/apps/array/src/main/services/workspace/scriptRunner.ts index f8bbe32df..e994c9313 100644 --- a/apps/array/src/main/services/workspace/scriptRunner.ts +++ b/apps/array/src/main/services/workspace/scriptRunner.ts @@ -33,7 +33,7 @@ export class ScriptRunner { scripts: string | string[], scriptType: "init" | "start", cwd: string, - options: { failFast?: boolean } = {}, + options: { failFast?: boolean; workspaceEnv?: Record } = {}, ): Promise { const commands = Array.isArray(scripts) ? scripts : [scripts]; const terminalSessionIds: string[] = []; @@ -67,6 +67,7 @@ export class ScriptRunner { webContents: mainWindow.webContents, cwd, initialCommand: command, + additionalEnv: options.workspaceEnv, }); terminalSessionIds.push(sessionId); @@ -112,14 +113,19 @@ export class ScriptRunner { async executeScriptsSilent( scripts: string | string[], cwd: string, + workspaceEnv?: Record, ): Promise<{ success: boolean; errors: string[] }> { const commands = Array.isArray(scripts) ? scripts : [scripts]; const errors: string[] = []; + const execEnv = workspaceEnv + ? { ...process.env, ...workspaceEnv } + : undefined; + for (const command of commands) { log.info(`Running destroy script silently: ${command}`); try { - await execAsync(command, { cwd, timeout: 60000 }); + await execAsync(command, { cwd, timeout: 60000, env: execEnv }); log.info(`Destroy script completed: ${command}`); } catch (error) { const errorMessage = diff --git a/apps/array/src/main/services/workspace/workspaceEnv.ts b/apps/array/src/main/services/workspace/workspaceEnv.ts new file mode 100644 index 000000000..448b756d2 --- /dev/null +++ b/apps/array/src/main/services/workspace/workspaceEnv.ts @@ -0,0 +1,73 @@ +import path from "node:path"; +import type { WorkspaceMode } from "@shared/types"; +import { getCurrentBranch, getDefaultBranch } from "../git"; + +export interface WorkspaceEnvContext { + taskId: string; + folderPath: string; + worktreePath: string | null; + worktreeName: string | null; + mode: WorkspaceMode; +} + +const PORT_BASE = 50000; +const PORTS_PER_WORKSPACE = 20; +const MAX_WORKSPACES = 1000; + +function hashTaskId(taskId: string): number { + let hash = 0; + for (let i = 0; i < taskId.length; i++) { + const char = taskId.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; + } + return Math.abs(hash); +} + +function allocateWorkspacePorts(taskId: string): { + start: number; + end: number; + ports: number[]; +} { + const workspaceIndex = hashTaskId(taskId) % MAX_WORKSPACES; + const start = PORT_BASE + workspaceIndex * PORTS_PER_WORKSPACE; + const end = start + PORTS_PER_WORKSPACE - 1; + + const ports: number[] = []; + for (let port = start; port <= end; port++) { + ports.push(port); + } + + return { start, end, ports }; +} + +export async function buildWorkspaceEnv( + context: WorkspaceEnvContext, +): Promise> { + if (context.mode === "cloud") { + return {}; + } + + const workspaceName = + context.worktreeName ?? path.basename(context.folderPath); + const workspacePath = context.worktreePath ?? context.folderPath; + const rootPath = context.folderPath; + + const defaultBranch = await getDefaultBranch(rootPath); + + const workspaceBranch = (await getCurrentBranch(workspacePath)) ?? ""; + + const portAllocation = allocateWorkspacePorts(context.taskId); + + return { + ARRAY_WORKSPACE_NAME: workspaceName, + ARRAY_WORKSPACE_PATH: workspacePath, + ARRAY_ROOT_PATH: rootPath, + ARRAY_DEFAULT_BRANCH: defaultBranch, + ARRAY_WORKSPACE_BRANCH: workspaceBranch, + ARRAY_WORKSPACE_PORTS: portAllocation.ports.join(","), + ARRAY_WORKSPACE_PORTS_RANGE: String(PORTS_PER_WORKSPACE), + ARRAY_WORKSPACE_PORTS_START: String(portAllocation.start), + ARRAY_WORKSPACE_PORTS_END: String(portAllocation.end), + }; +} diff --git a/apps/array/src/main/services/workspace/workspaceService.ts b/apps/array/src/main/services/workspace/workspaceService.ts index 81d3d3787..4504422bf 100644 --- a/apps/array/src/main/services/workspace/workspaceService.ts +++ b/apps/array/src/main/services/workspace/workspaceService.ts @@ -19,6 +19,7 @@ import { foldersStore } from "../store"; import { deleteWorktreeIfExists } from "../worktreeUtils"; import { loadConfig, normalizeScripts } from "./configLoader"; import { cleanupWorkspaceSessions, ScriptRunner } from "./scriptRunner"; +import { buildWorkspaceEnv } from "./workspaceEnv"; const execAsync = promisify(exec); @@ -139,6 +140,14 @@ export class WorkspaceService { ); let terminalSessionIds: string[] = []; + const workspaceEnv = await buildWorkspaceEnv({ + taskId, + folderPath, + worktreePath: null, + worktreeName: null, + mode, + }); + // Run init scripts const initScripts = normalizeScripts(config?.scripts?.init); if (initScripts.length > 0) { @@ -150,7 +159,7 @@ export class WorkspaceService { initScripts, "init", folderPath, - { failFast: true }, + { failFast: true, workspaceEnv }, ); terminalSessionIds = initResult.terminalSessionIds; @@ -173,7 +182,7 @@ export class WorkspaceService { startScripts, "start", folderPath, - { failFast: false }, + { failFast: false, workspaceEnv }, ); terminalSessionIds = [ ...terminalSessionIds, @@ -261,6 +270,14 @@ export class WorkspaceService { let terminalSessionIds: string[] = []; + const workspaceEnv = await buildWorkspaceEnv({ + taskId, + folderPath, + worktreePath: worktree.worktreePath, + worktreeName: worktree.worktreeName, + mode, + }); + if (initScripts.length > 0) { log.info( `Running ${initScripts.length} init script(s) for task ${taskId}`, @@ -270,7 +287,7 @@ export class WorkspaceService { initScripts, "init", worktree.worktreePath, - { failFast: true }, + { failFast: true, workspaceEnv }, ); terminalSessionIds = initResult.terminalSessionIds; @@ -298,7 +315,7 @@ export class WorkspaceService { startScripts, "start", worktree.worktreePath, - { failFast: false }, + { failFast: false, workspaceEnv }, ); terminalSessionIds = [ @@ -365,9 +382,19 @@ export class WorkspaceService { log.info( `Running ${destroyScripts.length} destroy script(s) for task ${taskId}`, ); + + const workspaceEnv = await buildWorkspaceEnv({ + taskId, + folderPath, + worktreePath: association.worktree?.worktreePath ?? null, + worktreeName: association.worktree?.worktreeName ?? null, + mode: association.mode, + }); + const destroyResult = await this.scriptRunner.executeScriptsSilent( destroyScripts, scriptPath, + workspaceEnv, ); if (!destroyResult.success) { @@ -489,12 +516,21 @@ export class WorkspaceService { return { success: true, terminalSessionIds: [] }; } + const association = findTaskAssociation(taskId); + const workspaceEnv = await buildWorkspaceEnv({ + taskId, + folderPath: association?.folderPath ?? worktreePath, + worktreePath, + worktreeName, + mode: association?.mode ?? "worktree", + }); + const result = await this.scriptRunner.executeScriptsWithTerminal( taskId, startScripts, "start", worktreePath, - { failFast: false }, + { failFast: false, workspaceEnv }, ); if (!result.success) { diff --git a/apps/array/src/renderer/features/task-detail/components/TaskShellPanel.tsx b/apps/array/src/renderer/features/task-detail/components/TaskShellPanel.tsx index 39cd8e8e3..20f78baec 100644 --- a/apps/array/src/renderer/features/task-detail/components/TaskShellPanel.tsx +++ b/apps/array/src/renderer/features/task-detail/components/TaskShellPanel.tsx @@ -45,7 +45,7 @@ export function TaskShellPanel({ taskId, task, shellId }: TaskShellPanelProps) { return ( - + ); } diff --git a/apps/array/src/renderer/features/terminal/components/ShellTerminal.tsx b/apps/array/src/renderer/features/terminal/components/ShellTerminal.tsx index 0f3a33ef1..78f9886ad 100644 --- a/apps/array/src/renderer/features/terminal/components/ShellTerminal.tsx +++ b/apps/array/src/renderer/features/terminal/components/ShellTerminal.tsx @@ -6,9 +6,10 @@ import { Terminal } from "./Terminal"; interface ShellTerminalProps { cwd?: string; stateKey?: string; + taskId?: string; } -export function ShellTerminal({ cwd, stateKey }: ShellTerminalProps) { +export function ShellTerminal({ cwd, stateKey, taskId }: ShellTerminalProps) { const persistenceKey = stateKey || cwd || "default"; const savedState = useTerminalStore( @@ -30,6 +31,7 @@ export function ShellTerminal({ cwd, stateKey }: ShellTerminalProps) { persistenceKey={persistenceKey} cwd={cwd} initialState={savedState?.serializedState ?? undefined} + taskId={taskId} /> ); } diff --git a/apps/array/src/renderer/features/terminal/components/Terminal.tsx b/apps/array/src/renderer/features/terminal/components/Terminal.tsx index 7f05fd204..c7ea7e017 100644 --- a/apps/array/src/renderer/features/terminal/components/Terminal.tsx +++ b/apps/array/src/renderer/features/terminal/components/Terminal.tsx @@ -9,6 +9,7 @@ export interface TerminalProps { persistenceKey: string; cwd?: string; initialState?: string; + taskId?: string; onReady?: () => void; onExit?: (exitCode?: number) => void; } @@ -18,6 +19,7 @@ export function Terminal({ persistenceKey, cwd, initialState, + taskId, onReady, onExit, }: TerminalProps) { @@ -32,9 +34,10 @@ export function Terminal({ persistenceKey, cwd, initialState, + taskId, }); } - }, [sessionId, persistenceKey, cwd, initialState]); + }, [sessionId, persistenceKey, cwd, initialState, taskId]); // Attach/detach from DOM useEffect(() => { diff --git a/apps/array/src/renderer/features/terminal/services/TerminalManager.ts b/apps/array/src/renderer/features/terminal/services/TerminalManager.ts index 0ed869ce5..de1964638 100644 --- a/apps/array/src/renderer/features/terminal/services/TerminalManager.ts +++ b/apps/array/src/renderer/features/terminal/services/TerminalManager.ts @@ -37,6 +37,7 @@ export interface TerminalInstance { saveTimeout: number | null; persistenceKey: string; cwd?: string; + taskId?: string; } export interface CreateOptions { @@ -44,6 +45,7 @@ export interface CreateOptions { persistenceKey: string; cwd?: string; initialState?: string; + taskId?: string; } type ReadyPayload = { sessionId: string; persistenceKey: string }; @@ -151,7 +153,7 @@ class TerminalManagerImpl { } create(options: CreateOptions): TerminalInstance { - const { sessionId, persistenceKey, cwd, initialState } = options; + const { sessionId, persistenceKey, cwd, initialState, taskId } = options; const existing = this.instances.get(sessionId); if (existing) { @@ -187,6 +189,7 @@ class TerminalManagerImpl { saveTimeout: null, persistenceKey, cwd, + taskId, }; // Write initial state if provided (before opening) @@ -198,7 +201,7 @@ class TerminalManagerImpl { this.setupIPC(sessionId, instance); // Initialize shell session - this.initializeSession(sessionId, instance, cwd); + this.initializeSession(sessionId, instance, cwd, taskId); this.instances.set(sessionId, instance); return instance; @@ -208,11 +211,12 @@ class TerminalManagerImpl { sessionId: string, instance: TerminalInstance, cwd?: string, + taskId?: string, ): Promise { try { const sessionExists = await window.electronAPI?.shellCheck(sessionId); if (!sessionExists) { - await window.electronAPI?.shellCreate(sessionId, cwd); + await window.electronAPI?.shellCreate(sessionId, cwd, taskId); } instance.isReady = true; diff --git a/apps/array/src/renderer/types/electron.d.ts b/apps/array/src/renderer/types/electron.d.ts index a039f64fc..3b51d55ab 100644 --- a/apps/array/src/renderer/types/electron.d.ts +++ b/apps/array/src/renderer/types/electron.d.ts @@ -215,7 +215,11 @@ declare global { onUpdateReady: (listener: () => void) => () => void; installUpdate: () => Promise<{ installed: boolean }>; // Shell API - shellCreate: (sessionId: string, cwd?: string) => Promise; + shellCreate: ( + sessionId: string, + cwd?: string, + taskId?: string, + ) => Promise; shellWrite: (sessionId: string, data: string) => Promise; shellResize: ( sessionId: string, diff --git a/packages/agent/src/worktree-manager.ts b/packages/agent/src/worktree-manager.ts index ad1bc8397..0ef7b557a 100644 --- a/packages/agent/src/worktree-manager.ts +++ b/packages/agent/src/worktree-manager.ts @@ -643,7 +643,7 @@ export class WorktreeManager { // Generate unique worktree name const worktreeName = await this.generateUniqueWorktreeName(); const worktreePath = this.getWorktreePath(worktreeName); - const branchName = `posthog/${worktreeName}`; + const branchName = `array/${worktreeName}`; const baseBranch = await this.getDefaultBranch(); this.logger.info("Creating worktree", { @@ -769,13 +769,10 @@ export class WorktreeManager { // 1. Are in our worktree folder (external or in-repo) // 2. Have a posthog/ branch prefix (our naming convention) const isInWorktreeFolder = worktreePath?.startsWith(worktreeFolderPath); - const isPosthogBranch = branchName?.startsWith("posthog/"); + const isArrayBranch = + branchName?.startsWith("array/") || branchName?.startsWith("posthog/"); - if ( - worktreePath && - branchName && - (isInWorktreeFolder || isPosthogBranch) - ) { + if (worktreePath && branchName && (isInWorktreeFolder || isArrayBranch)) { const worktreeName = path.basename(worktreePath); worktrees.push({ worktreePath,