mirror of
https://github.com/stablyai/orca.git
synced 2026-11-15 18:37:24 +00:00
feat(cloud): run orca.yaml setup once for API/CLI/phone/routine workspaces (#168)
Desktop-created cloud workspaces already run setup through orcad's worktree create after the setup prompt. Workspaces made through createWorkspace use the VM's primary checkout, which no worktree create touches, so their setup never ran. Start it detached on create (once per workspace, orcad's env, skipped on environment builds) and have routine runners wait for it.
This commit is contained in:
@@ -230,7 +230,7 @@ export const platformChecks: Record<string, Check> = {
|
||||
ctx.run.destroyed('other', s.envId)
|
||||
}
|
||||
}
|
||||
return { status: ok ? 'partial' : 'fail', detail: 'install script ran during the build (marker in env.from-build); per-repo orca.yaml setup on each new workspace runs through the desktop hook runtime (Electron run)' }
|
||||
return { status: ok ? 'partial' : 'fail', detail: 'install script ran during the build (marker in env.from-build); per-repo orca.yaml setup: desktop workspaces run it in orcad's worktree create (setup prompt), API/CLI/phone/routine workspaces once on create (sandbox-control src/project-setup.ts), skipped on environment builds' }
|
||||
},
|
||||
|
||||
// ---------------- W4 ----------------
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getSandbox, markUserSlept, type SandboxRow } from '../store'
|
||||
import { archiveSandbox, unarchiveSandbox } from '../workspace-lifecycle'
|
||||
import { ApiError } from './caller'
|
||||
import { randomId } from './keys'
|
||||
import { startProjectSetup } from '../project-setup'
|
||||
|
||||
export type WorkspaceOrigin = 'desktop' | 'api' | 'mcp' | 'routine' | 'vm'
|
||||
|
||||
@@ -169,6 +170,14 @@ export async function createWorkspace(
|
||||
originRef: input.originRef ?? null,
|
||||
hidden: input.hidden ?? false
|
||||
})
|
||||
// Agents here work in the primary checkout, which no worktree create sets up: run orca.yaml setup
|
||||
// once. Best effort: a workspace whose setup could not start still opens (the log says why).
|
||||
if (ready.row.session_id) {
|
||||
const setup = await startProjectSetup(life.provider, ready.row.session_id).catch(() => 'failed' as const)
|
||||
if (setup !== 'not_configured') {
|
||||
console.log(JSON.stringify({ event: 'project_setup', sandboxId: ready.row.id, origin: input.origin, state: setup }))
|
||||
}
|
||||
}
|
||||
return (await getSandbox(life.db, ready.row.id)) as WorkspaceRow
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// The repo's orca.yaml `scripts.setup` for workspaces nobody opens on a desktop first (API, CLI, phone,
|
||||
// routines: `createWorkspace`). Those agents work in the VM's primary checkout, so no worktree create
|
||||
// ever runs the setup hook there. A desktop-created cloud workspace is a linked worktree that orcad's
|
||||
// worktree create sets up after the desktop's setup prompt, so the plain create route never calls this.
|
||||
//
|
||||
// Runs once per workspace (a marker under ~/.orca-sandbox, which the disk snapshot keeps across
|
||||
// sleep), detached so a Worker never waits out a long install, with orcad's process env (managed
|
||||
// ORCA_* names and the session's secrets) like the desktop's setup terminal. Skipped when the VM was
|
||||
// booted from an org environment build: its install script already installed the dependencies.
|
||||
// Log and exit code: ~/.orca-sandbox/project-setup/{setup.log,exit}.
|
||||
//
|
||||
// No setup prompt: nobody is there to answer it, and the caller chose this repo for this sandboxed VM,
|
||||
// as unattended worktree creates run setup unless the project turned it off.
|
||||
import { parseOrcaYaml } from '../../../../src/shared/orca-yaml'
|
||||
import { PROJECT_ROOT } from './api/vm-agent'
|
||||
import type { SandboxProviderApi } from './sandbox-provider'
|
||||
|
||||
/** `bash -c LAUNCHER orca-project-setup <setup script> <runner script>`: both are data, written to files. */
|
||||
const LAUNCHER = String.raw`set -u
|
||||
umask 077
|
||||
P=${PROJECT_ROOT}
|
||||
D="$HOME/.orca-sandbox/project-setup"
|
||||
mkdir -p "$D"
|
||||
# noclobber makes the marker an atomic claim: a retried create never starts a second run.
|
||||
if ! (set -C; : >"$D/started") 2>/dev/null; then echo '{"state":"already"}'; exit 0; fi
|
||||
common="$(git -C "$P" rev-parse --path-format=absolute --git-common-dir 2>/dev/null || true)"
|
||||
if [ -n "$common" ] && [ -e "$common/orca-env-deps" ]; then
|
||||
echo 'orca: setup skipped: the environment build already installed dependencies' >"$D/setup.log"
|
||||
echo skipped >"$D/exit"
|
||||
echo '{"state":"skipped_environment_build"}'
|
||||
exit 0
|
||||
fi
|
||||
opid="$(cat "$HOME/.orca-sandbox/orcad.pid" 2>/dev/null || true)"
|
||||
if [ -z "$opid" ] || [ ! -r "/proc/$opid/environ" ]; then
|
||||
rm -f "$D/started"
|
||||
echo '{"state":"no_orcad"}'
|
||||
exit 0
|
||||
fi
|
||||
printf '%s' "$1" >"$D/setup.sh"
|
||||
printf '%s' "$2" >"$D/runner.sh"
|
||||
setsid nohup bash "$D/runner.sh" "$D" "$opid" >"$D/setup.log" 2>&1 </dev/null &
|
||||
echo $! >"$D/pid"
|
||||
echo '{"state":"started"}'`
|
||||
|
||||
const RUNNER = String.raw`set -u
|
||||
D="$1" opid="$2"
|
||||
while IFS= read -r -d '' kv; do
|
||||
case "$kv" in [A-Za-z_]*=*) export "$kv" 2>/dev/null || true ;; esac
|
||||
done <"/proc/$opid/environ"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
cd ${PROJECT_ROOT} || { echo 'orca: no project checkout'; echo 2 >"$D/exit"; exit 0; }
|
||||
echo "orca: running orca.yaml setup in $PWD at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
code=0
|
||||
timeout --kill-after=30 3600 bash "$D/setup.sh" </dev/null || code=$?
|
||||
echo "orca: setup exited $code at $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "$code" >"$D/exit.tmp" && mv "$D/exit.tmp" "$D/exit"`
|
||||
|
||||
const STATES = ['started', 'already', 'skipped_environment_build', 'no_orcad'] as const
|
||||
export type ProjectSetupState = 'not_configured' | 'failed' | (typeof STATES)[number]
|
||||
|
||||
/** As the desktop's setup terminal runs it: bash, stopping at the first failing line. */
|
||||
function runnerScript(script: string): string {
|
||||
return `#!/usr/bin/env bash\nset -e\n${script.replace(/\r\n/g, '\n')}\n`
|
||||
}
|
||||
|
||||
/** Reads the checkout's orca.yaml with the desktop's parser and starts its setup script in the VM. */
|
||||
export async function startProjectSetup(provider: SandboxProviderApi, sessionId: string): Promise<ProjectSetupState> {
|
||||
const yaml = await provider.readFile(sessionId, `${PROJECT_ROOT}/orca.yaml`)
|
||||
const script = yaml ? parseOrcaYaml(yaml)?.scripts.setup?.trim() : undefined
|
||||
if (!script) {
|
||||
return 'not_configured'
|
||||
}
|
||||
const result = await provider.runCommand(sessionId, {
|
||||
command: 'bash',
|
||||
args: ['-c', LAUNCHER, 'orca-project-setup', runnerScript(script), RUNNER],
|
||||
timeoutMs: 30_000
|
||||
})
|
||||
const state = /"state":"([a-z_]+)"/.exec(result.stdout)?.[1]
|
||||
return STATES.find((s) => s === state) ?? 'failed'
|
||||
}
|
||||
|
||||
export const PROJECT_SETUP_SCRIPTS_FOR_TESTS = { LAUNCHER, RUNNER }
|
||||
@@ -75,6 +75,13 @@ cd "$P" 2>/dev/null || { code=2; note=no_project; finish; }
|
||||
base="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||
start_sha="$(git rev-parse HEAD 2>/dev/null || true)"
|
||||
echo "routine: agent=$agent post=$post limit=$limit base=$base"
|
||||
# The workspace's orca.yaml setup (src/project-setup.ts) installs what the agent needs: let it finish.
|
||||
S="$HOME/.orca-sandbox/project-setup"
|
||||
if [ -e "$S/started" ] && [ ! -e "$S/exit" ]; then
|
||||
echo "routine: waiting for orca.yaml setup"
|
||||
while [ ! -e "$S/exit" ] && kill -0 "$(cat "$S/pid" 2>/dev/null || echo 0)" 2>/dev/null; do sleep 2; done
|
||||
fi
|
||||
[ -e "$S/exit" ] && echo "routine: orca.yaml setup $(cat "$S/exit")"
|
||||
case "$agent" in
|
||||
claude)
|
||||
# The prompt goes in on stdin: as an argv entry, text starting with "-" would parse as a flag.
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { RunCommandParams, SandboxProviderApi } from '../src/sandbox-provider'
|
||||
import { PROJECT_SETUP_SCRIPTS_FOR_TESTS, startProjectSetup } from '../src/project-setup'
|
||||
|
||||
function provider(files: Record<string, string>, stdout = '{"state":"started"}\n') {
|
||||
const calls: RunCommandParams[] = []
|
||||
const api = {
|
||||
readFile: async (_session: string, path: string) => files[path] ?? null,
|
||||
runCommand: async (_session: string, params: RunCommandParams) => {
|
||||
calls.push(params)
|
||||
return { exitCode: 0, stdout, stderr: '' }
|
||||
}
|
||||
} as unknown as SandboxProviderApi
|
||||
return { api, calls }
|
||||
}
|
||||
|
||||
describe('startProjectSetup', () => {
|
||||
it('does nothing without an orca.yaml setup script', async () => {
|
||||
const none = provider({})
|
||||
expect(await startProjectSetup(none.api, 's1')).toBe('not_configured')
|
||||
const tabsOnly = provider({ '/vercel/sandbox/project/orca.yaml': 'defaultTabs:\n - title: x\n' })
|
||||
expect(await startProjectSetup(tabsOnly.api, 's1')).toBe('not_configured')
|
||||
expect([...none.calls, ...tabsOnly.calls]).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('starts the parsed setup script as data, never in the shell line', async () => {
|
||||
const p = provider({ '/vercel/sandbox/project/orca.yaml': 'scripts:\n setup: |\n echo "$(whoami)" > .marker\n pnpm install\n' })
|
||||
expect(await startProjectSetup(p.api, 's1')).toBe('started')
|
||||
const [call] = p.calls
|
||||
expect(call!.command).toBe('bash')
|
||||
const [flag, launcher, name, script, runner] = call!.args!
|
||||
expect([flag, name]).toEqual(['-c', 'orca-project-setup'])
|
||||
expect(launcher).toBe(PROJECT_SETUP_SCRIPTS_FOR_TESTS.LAUNCHER)
|
||||
expect(runner).toBe(PROJECT_SETUP_SCRIPTS_FOR_TESTS.RUNNER)
|
||||
expect(launcher).not.toContain('pnpm install')
|
||||
// Same runner text as the desktop's setup terminal: bash with `set -e`.
|
||||
expect(script).toBe('#!/usr/bin/env bash\nset -e\necho "$(whoami)" > .marker\npnpm install\n')
|
||||
})
|
||||
|
||||
it('reports the launcher state, and failed on output it does not recognize', async () => {
|
||||
const yaml = { '/vercel/sandbox/project/orca.yaml': 'scripts:\n setup: make deps\n' }
|
||||
expect(await startProjectSetup(provider(yaml, '{"state":"already"}\n').api, 's1')).toBe('already')
|
||||
expect(await startProjectSetup(provider(yaml, '{"state":"skipped_environment_build"}\n').api, 's1')).toBe('skipped_environment_build')
|
||||
expect(await startProjectSetup(provider(yaml, 'bash: oops\n').api, 's1')).toBe('failed')
|
||||
})
|
||||
|
||||
it('claims the run atomically and skips environment-build checkouts', () => {
|
||||
const { LAUNCHER } = PROJECT_SETUP_SCRIPTS_FOR_TESTS
|
||||
expect(LAUNCHER).toContain('(set -C; : >"$D/started")')
|
||||
expect(LAUNCHER).toContain('orca-env-deps')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user