mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
* fix(vm): verify provisioned root ownership * test(vm): retry transient removal menu * test(vm): stabilize provisioned root teardown * fix(vm): clarify recipe-owned cleanup * fix(vm): pin provisioned root source commit * fix(vm): make runtime cleanup user-cancellable
211 lines
6.2 KiB
TypeScript
211 lines
6.2 KiB
TypeScript
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
|
|
import type { EphemeralVmRecipeContext } from './ephemeral-vm-recipe-runner'
|
|
|
|
const DEFAULT_MAX_CAPTURE_BYTES = 1024 * 1024
|
|
const CANCEL_FORCE_KILL_DELAY_MS = 5_000
|
|
|
|
export type ProcessRunResult = {
|
|
stdout: string
|
|
stderr: string
|
|
exitCode: number | null
|
|
signal: NodeJS.Signals | null
|
|
aborted?: true
|
|
}
|
|
|
|
export function quoteShellToken(value: string): string {
|
|
if (process.platform === 'win32') {
|
|
// Inside cmd.exe double quotes, `^` is literal; an embedded `"` is escaped
|
|
// by doubling it. This token is only displayed for manual cleanup, so it
|
|
// must be valid when pasted into cmd.exe.
|
|
return `"${value.replace(/"/g, '""')}"`
|
|
}
|
|
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
}
|
|
|
|
export async function runRecipeCommand(args: {
|
|
command: string
|
|
repoPath: string
|
|
context: EphemeralVmRecipeContext
|
|
mode: 'create' | 'suspend' | 'resume' | 'destroy'
|
|
resultSchemaVersion: 1 | 2
|
|
stdin?: string
|
|
env?: NodeJS.ProcessEnv
|
|
maxCaptureBytes?: number
|
|
signal?: AbortSignal
|
|
onStdout?: (chunk: string) => void
|
|
onStderr?: (chunk: string) => void
|
|
spawnCommand?: typeof spawn
|
|
}): Promise<ProcessRunResult> {
|
|
const maxBytes = args.maxCaptureBytes ?? DEFAULT_MAX_CAPTURE_BYTES
|
|
const spawnCommand = args.spawnCommand ?? spawn
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let child: ChildProcessWithoutNullStreams
|
|
try {
|
|
child = spawnCommand(args.command, {
|
|
cwd: args.repoPath,
|
|
detached: process.platform !== 'win32',
|
|
env: buildRecipeEnv(args.env, args.mode, args.context, args.resultSchemaVersion),
|
|
shell: true,
|
|
windowsHide: true
|
|
}) as ChildProcessWithoutNullStreams
|
|
} catch (error) {
|
|
reject(error)
|
|
return
|
|
}
|
|
|
|
let stdout = ''
|
|
let stderr = ''
|
|
let settled = false
|
|
let aborted = false
|
|
let forceKillTimer: ReturnType<typeof setTimeout> | undefined
|
|
const finish = (result: ProcessRunResult): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
if (forceKillTimer) {
|
|
clearTimeout(forceKillTimer)
|
|
}
|
|
args.signal?.removeEventListener('abort', abort)
|
|
resolve(result)
|
|
}
|
|
const fail = (error: Error): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
if (forceKillTimer) {
|
|
clearTimeout(forceKillTimer)
|
|
}
|
|
args.signal?.removeEventListener('abort', abort)
|
|
reject(error)
|
|
}
|
|
const abort = (): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
aborted = true
|
|
forceKillTimer = setTimeout(() => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
killRecipeProcess(child, true)
|
|
finish({ stdout, stderr, exitCode: null, signal: null, aborted: true })
|
|
child.stdin.destroy()
|
|
child.stdout.destroy()
|
|
child.stderr.destroy()
|
|
child.unref()
|
|
}, CANCEL_FORCE_KILL_DELAY_MS)
|
|
forceKillTimer.unref()
|
|
killRecipeProcess(child)
|
|
}
|
|
|
|
child.stdout.setEncoding('utf8')
|
|
child.stderr.setEncoding('utf8')
|
|
child.stdout.on('data', (chunk: string) => {
|
|
stdout = appendBounded(stdout, chunk, maxBytes)
|
|
args.onStdout?.(chunk)
|
|
})
|
|
child.stderr.on('data', (chunk: string) => {
|
|
stderr = appendBounded(stderr, chunk, maxBytes)
|
|
args.onStderr?.(chunk)
|
|
})
|
|
child.on('error', (error) => {
|
|
fail(error)
|
|
})
|
|
child.on('close', (exitCode, signal) => {
|
|
finish({ stdout, stderr, exitCode, signal, ...(aborted ? { aborted: true } : {}) })
|
|
})
|
|
|
|
if (args.signal?.aborted) {
|
|
abort()
|
|
} else {
|
|
args.signal?.addEventListener('abort', abort, { once: true })
|
|
}
|
|
|
|
if (args.stdin) {
|
|
child.stdin.end(args.stdin)
|
|
} else {
|
|
child.stdin.end()
|
|
}
|
|
})
|
|
}
|
|
|
|
function killRecipeProcess(child: ChildProcessWithoutNullStreams, force = false): void {
|
|
const signal = force ? 'SIGKILL' : 'SIGTERM'
|
|
if (process.platform === 'win32') {
|
|
// Recipes run through `cmd.exe /c` (shell: true), so child.kill() would only
|
|
// terminate the wrapper and orphan the actual recipe subprocess (e.g. a cloud
|
|
// CLI mid-provision). taskkill /T walks and kills the whole tree.
|
|
if (child.pid) {
|
|
const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], {
|
|
windowsHide: true,
|
|
stdio: 'ignore'
|
|
})
|
|
killer.on('error', () => child.kill(signal))
|
|
return
|
|
}
|
|
child.kill(signal)
|
|
return
|
|
}
|
|
if (child.pid) {
|
|
try {
|
|
// Recipes run through a shell; kill the process group so shell children do not linger.
|
|
process.kill(-child.pid, signal)
|
|
return
|
|
} catch {
|
|
// Fall back to killing the direct child if the process group is already gone.
|
|
}
|
|
}
|
|
child.kill(signal)
|
|
}
|
|
|
|
function buildRecipeEnv(
|
|
env: NodeJS.ProcessEnv | undefined,
|
|
mode: 'create' | 'suspend' | 'resume' | 'destroy',
|
|
context: EphemeralVmRecipeContext,
|
|
resultSchemaVersion: 1 | 2
|
|
): NodeJS.ProcessEnv {
|
|
return {
|
|
...process.env,
|
|
...env,
|
|
ORCA_VM_MODE: mode,
|
|
ORCA_VM_INSTANCE_ID: context.instanceId ?? '',
|
|
ORCA_RECIPE_ID: context.recipeId,
|
|
ORCA_PROJECT_ID: context.projectId ?? '',
|
|
ORCA_WORKSPACE_ID: context.workspaceId ?? '',
|
|
ORCA_WORKSPACE_NAME: context.workspaceName ?? '',
|
|
ORCA_REPO_PATH: context.repoPath,
|
|
ORCA_REPO_URL: context.repoUrl ?? '',
|
|
ORCA_REPO_BRANCH: context.branch ?? '',
|
|
ORCA_REPO_REF: context.ref ?? '',
|
|
ORCA_REPO_REF_HEAD: context.expectedRefHead ?? '',
|
|
ORCA_RECIPE_RESULT_SCHEMA_VERSION: String(resultSchemaVersion),
|
|
ORCA_VERSION: context.orcaVersion ?? ''
|
|
}
|
|
}
|
|
|
|
function appendBounded(current: string, chunk: string, maxBytes: number): string {
|
|
if (maxBytes <= 0) {
|
|
return ''
|
|
}
|
|
const chunkBytes = Buffer.byteLength(chunk, 'utf8')
|
|
if (chunkBytes >= maxBytes) {
|
|
return utf8Tail(chunk, maxBytes)
|
|
}
|
|
return utf8Tail(current, maxBytes - chunkBytes) + chunk
|
|
}
|
|
|
|
function utf8Tail(value: string, maxBytes: number): string {
|
|
const bytes = Buffer.from(value, 'utf8')
|
|
if (bytes.byteLength <= maxBytes) {
|
|
return value
|
|
}
|
|
let start = bytes.byteLength - maxBytes
|
|
while (start < bytes.byteLength && (bytes[start]! & 0xc0) === 0x80) {
|
|
start += 1
|
|
}
|
|
return bytes.subarray(start).toString('utf8')
|
|
}
|