Skip to content
Merged
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
58 changes: 45 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ oma-forge/
│ ├── server/ # Fastify API + SSE — spawns workflow subprocesses
│ └── web/ # Vite + React + TypeScript UI
├── packages/
│ ├── cli/ # `oma-forge` — npx entry (spawns server + UI)
│ ├── shared/ # Shared Forge API types + event protocol
│ └── reporter/ # Hooks for workflow files to stream events to Forge
├── workflows/ # Example workflow files (e.g. demo.ts)
Expand All @@ -49,6 +50,30 @@ oma-forge/

Requires Node.js ≥ 18.

### Run Forge against your workflow (`npx oma-forge`)

From your project (or this repo after `npm install` && `npm run build`):

```bash
npx oma-forge ./path/to/workflow.ts
```

This starts the API and UI, opens [http://localhost:5173](http://localhost:5173), and pre-fills the workflow path. Set a goal and click **Run** to execute via subprocess and watch live progress.

Options: `--no-open`, `--port <n>`, `--web-port <n>`, `--help`.

Forge loads `.env` from the nearest `package.json` directory to your workflow file (API keys, provider settings). Your workflow file must depend on `@oma-forge/reporter` and `@open-multi-agent/core`; install `tsx` to run TypeScript locally.

In this monorepo after building:

```bash
npm run build
npx oma-forge workflows/demo.ts
# or: npm run oma-forge -- workflows/demo.ts
```

### Monorepo development

```bash
git clone https://github.com/open-multi-agent/oma-forge.git
cd oma-forge
Expand All @@ -65,35 +90,42 @@ This starts both workspaces:
- `GET /api/events` — SSE stream of run progress and trace events
- `POST /api/runs` — start a workflow (`{ goal?, workflowPath? }`)

Set `FORGE_WORKFLOW_PATH` to change the default workflow file.
Set `FORGE_WORKFLOW_PATH` to change the default workflow file when not using the CLI.

Set `FORGE_RUN_STALL_MS` to control how long a run can go without workflow events before Forge marks it failed (default: 5 minutes). Runs that exit without a `result` event, or report success with no tasks/trace/tokens, are also marked failed with an explicit health reason in the UI.

### Writing a workflow

Use **Open Multi Agent as usual**. Forge only needs:

1. A **default export** (async function) so the runner can invoke your file.
2. **`...forgeHooks()`** spread on your existing `OpenMultiAgent` config (streams progress to the UI; no-op outside Forge).
3. **`process.env.FORGE_GOAL`** for the run goal and **`forgeAbortSignal()`** on `runTeam` so Cancel works (or your own `AbortController` + `SIGTERM` handler).

```ts
// workflows/my-workflow.ts
import { OpenMultiAgent } from '@open-multi-agent/core'
import { bootstrapForgeWorkflow, type ForgeRunContext } from '@oma-forge/reporter'
import { forgeAbortSignal, forgeHooks } from '@oma-forge/reporter'

export default async function run(ctx: ForgeRunContext) {
const { goal, abortSignal, reporter } = ctx
export default async function main() {
const oma = new OpenMultiAgent({
defaultProvider: 'gemini',
defaultApiKey: process.env.GOOGLE_API_KEY ?? process.env.GEMINI_API_KEY,
onProgress: reporter.onProgress,
onTrace: reporter.onTrace,
onPlanReady: reporter.onPlanReady,
onAgentStream: reporter.onAgentStream,
...forgeHooks(),
})
const team = oma.createTeam('my-team', { /* agents, tools, MCPs */ })
return oma.runTeam(team, process.env.FORGE_GOAL ?? '', {
abortSignal: forgeAbortSignal(),
})
// Wire tools, MCPs, teams here — Forge never sees them.
const team = oma.createTeam('my-team', { /* ... */ })
reporter.finish(await oma.runTeam(team, goal, { abortSignal }))
}

void bootstrapForgeWorkflow(run)
```

Return the `runTeam` result and Forge records it automatically. No bootstrap wrapper.

#### Optional: integrate in `@open-multi-agent/core`

To avoid the `forgeHooks()` spread entirely, core could merge a Forge adapter when `FORGE_RUN_ID` is set, e.g. `new OpenMultiAgent(withForge(config))` or built-in env detection. That keeps workflow files identical between CLI and Forge.

For Gemini models, install `@google/genai` and set `GOOGLE_API_KEY` or `GEMINI_API_KEY`.

Other scripts:
Expand Down
4 changes: 4 additions & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"private": true,
"license": "Apache-2.0",
"type": "module",
"exports": {
".": "./dist/index.js",
"./package.json": "./package.json"
},
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "npm run build -w @oma-forge/shared && npm run build -w @oma-forge/reporter && tsc",
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/load-env.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { resolveRepoRoot } from './workflows/paths.js'
import { resolveProjectRoot } from './workflows/paths.js'

/** Loads repo-root `.env` into `process.env` (does not override existing vars). */
/** Loads project-root `.env` into `process.env` (does not override existing vars). */
export function loadEnvFile(): void {
const path = join(resolveRepoRoot(), '.env')
const path = join(resolveProjectRoot(), '.env')
if (!existsSync(path)) return

for (const rawLine of readFileSync(path, 'utf8').split('\n')) {
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/workflows/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,14 @@ function repoRootCandidates(): readonly string[] {
return [process.cwd(), join(process.cwd(), '..'), join(process.cwd(), '../..')]
}

/** User project root when launched via `oma-forge` CLI (workflow cwd, `.env`, subprocess). */
export function resolveProjectRoot(): string {
if (process.env.FORGE_PROJECT_ROOT) {
return resolve(process.env.FORGE_PROJECT_ROOT)
}
return resolveRepoRoot()
}

/** Best-effort monorepo root for resolving default workflow paths. */
export function resolveRepoRoot(): string {
for (const base of repoRootCandidates()) {
Expand Down Expand Up @@ -34,7 +42,7 @@ export function resolveWorkflowPath(input?: string): string {
const trimmed = input?.trim()
if (!trimmed) return resolveDefaultWorkflowPath()
if (isAbsolute(trimmed)) return trimmed
return resolve(resolveRepoRoot(), trimmed)
return resolve(resolveProjectRoot(), trimmed)
}

export function workflowPathExists(path: string): boolean {
Expand Down
11 changes: 8 additions & 3 deletions apps/server/src/workflows/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
parseForgeWorkflowLine,
type ForgeWorkflowEvent,
} from '@oma-forge/shared'
import { resolveRepoRoot } from './paths.js'
import { resolveProjectRoot } from './paths.js'

const require = createRequire(import.meta.url)

Expand All @@ -16,6 +16,10 @@ function resolveTsxCli(): string {
return join(tsxRoot, 'dist/cli.mjs')
}

function resolveWorkflowRunnerEntry(): string {
return require.resolve('@oma-forge/reporter/run-workflow')
}

export type RunWorkflowSubprocessOptions = {
readonly runId: string
readonly goal: string
Expand All @@ -34,11 +38,12 @@ export async function runWorkflowSubprocess(
options: RunWorkflowSubprocessOptions,
): Promise<RunWorkflowSubprocessResult> {
const tsxCli = resolveTsxCli()
const cwd = resolveRepoRoot()
const runnerEntry = resolveWorkflowRunnerEntry()
const cwd = resolveProjectRoot()

const child: ChildProcess = spawn(
process.execPath,
[tsxCli, options.workflowPath],
[tsxCli, runnerEntry, options.workflowPath],
{
cwd,
env: {
Expand Down
58 changes: 58 additions & 0 deletions apps/server/tests/workflow-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { existsSync, mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import {
resolveProjectRoot,
resolveRepoRoot,
resolveWorkflowPath,
} from '../src/workflows/paths.js'

describe('workflow paths', () => {
const previousProjectRoot = process.env.FORGE_PROJECT_ROOT
const previousWorkflowPath = process.env.FORGE_WORKFLOW_PATH

beforeEach(() => {
delete process.env.FORGE_PROJECT_ROOT
delete process.env.FORGE_WORKFLOW_PATH
})

afterEach(() => {
if (previousProjectRoot === undefined) delete process.env.FORGE_PROJECT_ROOT
else process.env.FORGE_PROJECT_ROOT = previousProjectRoot
if (previousWorkflowPath === undefined) delete process.env.FORGE_WORKFLOW_PATH
else process.env.FORGE_WORKFLOW_PATH = previousWorkflowPath
})

it('resolveProjectRoot uses FORGE_PROJECT_ROOT when set', () => {
const dir = mkdtempSync(join(tmpdir(), 'oma-forge-root-'))
process.env.FORGE_PROJECT_ROOT = dir
expect(resolveProjectRoot()).toBe(dir)
})

it('resolveWorkflowPath resolves relative paths against FORGE_PROJECT_ROOT', () => {
const dir = mkdtempSync(join(tmpdir(), 'oma-forge-proj-'))
const workflows = join(dir, 'workflows')
mkdirSync(workflows, { recursive: true })
const file = join(workflows, 'custom.ts')
writeFileSync(file, 'export default async function run() {}')

process.env.FORGE_PROJECT_ROOT = dir
expect(resolveWorkflowPath('workflows/custom.ts')).toBe(file)
})

it('resolveWorkflowPath keeps absolute paths unchanged', () => {
const file = join(tmpdir(), 'abs-workflow.ts')
expect(resolveWorkflowPath(file)).toBe(file)
})

it('resolveDefaultWorkflowPath uses FORGE_WORKFLOW_PATH when set', () => {
const file = join(tmpdir(), 'env-workflow.ts')
process.env.FORGE_WORKFLOW_PATH = file
expect(resolveWorkflowPath()).toBe(file)
})

it('resolveRepoRoot still finds oma-forge monorepo layout', () => {
expect(existsSync(join(resolveRepoRoot(), 'apps', 'server'))).toBe(true)
})
})
3 changes: 3 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@
"private": true,
"license": "Apache-2.0",
"type": "module",
"exports": {
"./package.json": "./package.json"
},
"scripts": {
"dev": "npm run build -w @oma-forge/shared && vite",
"build": "npm run build -w @oma-forge/shared && tsc --noEmit && vite build",
Expand Down
2 changes: 1 addition & 1 deletion apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export default defineConfig({
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
target: `http://localhost:${process.env.FORGE_API_PORT ?? process.env.PORT ?? 3001}`,
changeOrigin: true,
},
},
Expand Down
29 changes: 26 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@
],
"scripts": {
"dev": "npm run build -w @oma-forge/shared && npm run build -w @oma-forge/reporter && concurrently -n web,server -c blue,green \"npm run dev -w @oma-forge/web\" \"npm run dev -w @oma-forge/server\"",
"build": "npm run build -w @oma-forge/shared && npm run build -w @oma-forge/reporter && npm run build -w @oma-forge/server && npm run build -w @oma-forge/web",
"build": "npm run build -w @oma-forge/shared && npm run build -w @oma-forge/reporter && npm run build -w @oma-forge/server && npm run build -w @oma-forge/web && npm run build -w oma-forge",
"preview": "npm run preview -w @oma-forge/web",
"lint": "npm run lint -w @oma-forge/shared && npm run lint -w @oma-forge/reporter && npm run lint -w @oma-forge/server && npm run lint -w @oma-forge/web",
"test": "npm run test -w @oma-forge/server"
"lint": "npm run lint -w @oma-forge/shared && npm run lint -w @oma-forge/reporter && npm run lint -w @oma-forge/server && npm run lint -w @oma-forge/web && npm run lint -w oma-forge",
"oma-forge": "oma-forge",
"test": "npm run test -w @oma-forge/server && npm run test -w oma-forge"
},
"devDependencies": {
"concurrently": "^9.1.2"
Expand Down
37 changes: 37 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"name": "oma-forge",
"version": "0.1.0",
"description": "Local dev UI for Open Multi Agent workflows — run `npx oma-forge <workflow.ts>`",
"license": "Apache-2.0",
"type": "module",
"bin": {
"oma-forge": "./dist/cli.js"
},
"files": [
"dist"
],
"repository": {
"type": "git",
"url": "git+https://github.com/open-multi-agent/oma-forge.git",
"directory": "packages/cli"
},
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"build": "tsc",
"lint": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@oma-forge/reporter": "*",
"@oma-forge/server": "*",
"@oma-forge/shared": "*",
"@oma-forge/web": "*"
},
"devDependencies": {
"@types/node": "^22.15.29",
"typescript": "^5.8.3",
"vitest": "^3.2.1"
}
}
Loading
Loading