Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/server/src/agent/claude/roles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ const ORCHESTRATOR_PROMPT =
'and after writing code you MUST call run_app to verify the app actually runs. ' +
'If run_app returns ok:false, read the error/traceback, fix the file(s), and call run_app again — ' +
'repeat until it returns ok:true. Do the coding yourself; do NOT delegate file edits. ' +
'DECLARE how the app runs so any stack works: unless it starts via a standard `npm run dev`/`npm start` ' +
'or a self-running entrypoint (Flask `app.run()`, `python app.py`), write a `Procfile` with a `web:` line ' +
'giving the exact start command — e.g. `web: .venv/bin/uvicorn main:app --host 127.0.0.1` (FastAPI), ' +
'`web: go run .` (Go), `web: bundle exec rails s` (Rails). OpenREPL runs that command verbatim, so it must ' +
'bind a port and (for Python venvs) use the `.venv/bin/` path. ' +
'Then delegate a review to the reviewer subagent (read-only). ' +
'CRITICAL: when the reviewer returns findings, you MUST APPLY each fix yourself with write_file ' +
'(do not just list or describe the fixes), then call run_app once more to confirm it still runs. ' +
Expand Down
2 changes: 2 additions & 0 deletions packages/server/src/agent/subagents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export const SUBAGENTS: SubAgentDef[] = [
'CRITICAL: after writing code you MUST call run_app to verify the app actually runs. ' +
'If run_app returns ok:false, read the error/traceback carefully, fix the offending file(s), then call run_app again. ' +
'Repeat this run → fix → run loop until run_app returns ok:true. ' +
'Unless the app starts via `npm run dev`/`npm start` or a self-running entrypoint, DECLARE the run command in a ' +
'`Procfile` (`web: <command>`, e.g. `web: .venv/bin/uvicorn main:app --host 127.0.0.1`) so any framework runs. ' +
'Do NOT declare the work done while run_app is still failing. When it finally runs, briefly state what you built and confirm it runs.',
toolNames: ['read_file', 'write_file', 'list_dir', 'search_repo', 'run_command', 'run_app'],
},
Expand Down
57 changes: 53 additions & 4 deletions packages/server/src/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,10 @@ const FRONTEND_RE = /^(dev:)?(web|client|frontend|fe|ui)$/i;

/**
* Figure out how to run the user's app — auto-detected run configurations.
* Priority: user-defined .openrepl/workflows.json → auto-detected BE+FE pair →
* single dev/start script → static index.html.
* OpenREPL stays framework-agnostic: whoever builds the app (the agent, or the
* user) declares how to run it, and OpenREPL just runs that. Priority:
* declared run command (.openrepl/workflows.json → Procfile) → auto-detected
* Node/Python/static as a convenience fallback.
*/
export async function detectWorkflows(workspaceDir: string): Promise<DetectResult> {
let pkg: any = null;
Expand All @@ -31,10 +33,16 @@ export async function detectWorkflows(workspaceDir: string): Promise<DetectResul

if (pkg && isOpenReplItself(pkg)) return { self: true, install: null, workflows: [] };

// 1) user-defined override
// 1) declared run command — .openrepl/workflows.json (richest) then Procfile.
// These let any stack (FastAPI, Go, Rails, …) run without OpenREPL knowing the
// framework; the framework knowledge lives in the declared command.
const userDefined = await loadUserWorkflows(workspaceDir);
if (userDefined.length) {
return { self: false, install: await nodeInstall(workspaceDir, pkg), workflows: userDefined };
return { self: false, install: await detectInstall(workspaceDir, pkg), workflows: userDefined };
}
const procSteps = await loadProcfile(workspaceDir);
if (procSteps.length) {
return { self: false, install: await detectInstall(workspaceDir, pkg), workflows: [{ name: 'Dev', steps: procSteps }] };
}

// 2) Node — auto-detect from package.json scripts
Expand Down Expand Up @@ -188,6 +196,47 @@ async function nodeInstall(workspaceDir: string, pkg: any): Promise<string | nul
return (await exists(path.join(workspaceDir, 'node_modules'))) ? null : 'npm install';
}

/**
* Parse a Procfile (`process_name: command` per line — the Heroku/Foreman
* convention) into workflow steps. Language-agnostic: the command is whatever
* the author wrote. Only the `web` process serves an HTTP preview.
*/
async function loadProcfile(workspaceDir: string): Promise<WorkflowStep[]> {
let raw: string;
try {
raw = await fs.readFile(path.join(workspaceDir, 'Procfile'), 'utf8');
} catch {
return [];
}
const steps: WorkflowStep[] = [];
for (const line of raw.split('\n')) {
if (line.trim().startsWith('#')) continue;
const m = line.match(/^\s*([A-Za-z0-9_-]+)\s*:\s*(.+?)\s*$/);
// Skip Heroku's `release` phase — a one-off pre-deploy hook, not a process to run.
if (m && m[1] !== 'release') steps.push({ name: m[1], command: m[2] });
}
if (!steps.length) return [];
// Only `web` serves an HTTP preview; a worker-only app has none (don't point
// the preview at a non-server process and leave the UI waiting for a port).
const web = steps.find((s) => s.name === 'web');
if (web) web.preview = true;
return steps;
}

/**
* Convenience dependency install for a *declared* run (workflows.json/Procfile):
* npm for a Node project, a managed .venv for a Python one. Node and Python are
* mutually exclusive here — a Node app that merely has a stray requirements.txt
* must NOT get a surprise venv/pip step that can fail and block startup.
*/
async function detectInstall(workspaceDir: string, pkg: any): Promise<string | null> {
if (pkg) return nodeInstall(workspaceDir, pkg);
const reqs = await exists(path.join(workspaceDir, 'requirements.txt'));
const venv = await exists(path.join(workspaceDir, '.venv'));
if (reqs && !venv) return 'python3 -m venv .venv && .venv/bin/pip install -q -r requirements.txt';
return null;
}

async function exists(p: string): Promise<boolean> {
try {
await fs.access(p);
Expand Down
42 changes: 42 additions & 0 deletions packages/server/test/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,48 @@ describe('detectWorkflows', () => {
);
expect(d.workflows[0].name).toBe('Custom');
});

it('Procfile: runs the declared command verbatim (any framework), web previews', async () => {
const d = await detectWorkflows(await ws({ 'Procfile': 'web: uvicorn main:app --host 127.0.0.1\n' }));
expect(d.workflows[0].steps[0]).toMatchObject({ name: 'web', command: 'uvicorn main:app --host 127.0.0.1', preview: true });
});

it('Procfile: the web process is the preview even when it is not first', async () => {
const d = await detectWorkflows(await ws({ 'Procfile': 'worker: celery -A t worker\nweb: go run .\n# a comment\n' }));
const steps = d.workflows[0].steps;
expect(steps.map((s) => s.name)).toEqual(['worker', 'web']);
expect(steps.find((s) => s.name === 'web')?.preview).toBe(true);
expect(steps.find((s) => s.name === 'worker')?.preview).toBeUndefined();
});

it('Procfile takes priority over framework auto-detection', async () => {
// app.py would otherwise be run as `python app.py`; the declared command wins.
const d = await detectWorkflows(await ws({ 'app.py': 'x=1', 'Procfile': 'web: gunicorn app:app\n' }));
expect(d.workflows[0].steps[0].command).toBe('gunicorn app:app');
});

it('Procfile + requirements.txt → managed venv install', async () => {
const d = await detectWorkflows(await ws({ 'Procfile': 'web: .venv/bin/uvicorn main:app\n', 'requirements.txt': 'fastapi\nuvicorn' }));
expect(d.install).toContain('python3 -m venv .venv');
});

it('Procfile: skips the release phase and previews nothing when there is no web', async () => {
const d = await detectWorkflows(await ws({ 'Procfile': 'release: python migrate.py\nworker: python worker.py\n' }));
const steps = d.workflows[0].steps;
expect(steps.map((s) => s.name)).toEqual(['worker']); // release dropped
expect(steps.some((s) => s.preview)).toBe(false); // no web → no preview
});

it('a Node project is not given a Python venv just because a stray requirements.txt exists', async () => {
const d = await detectWorkflows(
await ws({
'.openrepl/workflows.json': pkg({ workflows: [{ name: 'Dev', steps: [{ name: 'app', command: 'npm run dev', preview: true }] }] }),
'package.json': pkg({ name: 'app', scripts: { dev: 'vite' } }),
'requirements.txt': 'some-unrelated-tool',
}),
);
expect(d.install).toBe('npm install'); // not a venv/pip command
});
});

describe('WorkflowManager (static site)', () => {
Expand Down
Loading