diff --git a/README.md b/README.md index d7165c6..5ae32bc 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 `, `--web-port `, `--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 @@ -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: diff --git a/apps/server/package.json b/apps/server/package.json index 64a27d0..3145bc3 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/apps/server/src/load-env.ts b/apps/server/src/load-env.ts index b4b2164..bfc0385 100644 --- a/apps/server/src/load-env.ts +++ b/apps/server/src/load-env.ts @@ -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')) { diff --git a/apps/server/src/workflows/paths.ts b/apps/server/src/workflows/paths.ts index e8e0e40..141eba2 100644 --- a/apps/server/src/workflows/paths.ts +++ b/apps/server/src/workflows/paths.ts @@ -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()) { @@ -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 { diff --git a/apps/server/src/workflows/runner.ts b/apps/server/src/workflows/runner.ts index c621f9d..ec5c9c0 100644 --- a/apps/server/src/workflows/runner.ts +++ b/apps/server/src/workflows/runner.ts @@ -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) @@ -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 @@ -34,11 +38,12 @@ export async function runWorkflowSubprocess( options: RunWorkflowSubprocessOptions, ): Promise { 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: { diff --git a/apps/server/tests/workflow-paths.test.ts b/apps/server/tests/workflow-paths.test.ts new file mode 100644 index 0000000..1e54f2e --- /dev/null +++ b/apps/server/tests/workflow-paths.test.ts @@ -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) + }) +}) diff --git a/apps/web/package.json b/apps/web/package.json index 0ff2fe7..76b4696 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index e4ebd8c..ad5e732 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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, }, }, diff --git a/package-lock.json b/package-lock.json index cfa46d0..a8f1807 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,6 @@ "packages/*", "workflows" ], - "dependencies": { - "@google/genai": "^1.52.0" - }, "devDependencies": { "concurrently": "^9.1.2" }, @@ -3744,6 +3741,10 @@ "dev": true, "license": "MIT" }, + "node_modules/oma-forge": { + "resolved": "packages/cli", + "link": true + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -5316,6 +5317,28 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "packages/cli": { + "name": "oma-forge", + "version": "0.1.0", + "license": "Apache-2.0", + "dependencies": { + "@oma-forge/reporter": "*", + "@oma-forge/server": "*", + "@oma-forge/shared": "*", + "@oma-forge/web": "*" + }, + "bin": { + "oma-forge": "dist/cli.js" + }, + "devDependencies": { + "@types/node": "^22.15.29", + "typescript": "^5.8.3", + "vitest": "^3.2.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, "packages/reporter": { "name": "@oma-forge/reporter", "version": "0.1.0", diff --git a/package.json b/package.json index 10e8e05..5410b70 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..7807362 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,37 @@ +{ + "name": "oma-forge", + "version": "0.1.0", + "description": "Local dev UI for Open Multi Agent workflows — run `npx oma-forge `", + "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" + } +} diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts new file mode 100644 index 0000000..f169a9f --- /dev/null +++ b/packages/cli/src/cli.ts @@ -0,0 +1,148 @@ +#!/usr/bin/env node +import { existsSync } from 'node:fs' +import { spawn, type ChildProcess } from 'node:child_process' +import { cwd } from 'node:process' +import { resolveProjectRoot } from './resolve-project-root.js' +import { HELP_TEXT, parseArgs } from './parse-args.js' +import { openBrowser } from './open-browser.js' +import { + resolveForgeMonorepoRoot, + resolveReporterDist, + resolveServerEntry, + resolveSharedDist, + resolveViteCli, + resolveWebRoot, + resolveWorkflowArg, +} from './paths.js' + +function die(message: string): never { + console.error(`oma-forge: ${message}`) + process.exit(1) +} + +function missingBuildHint(): string { + const root = resolveForgeMonorepoRoot() + if (root) { + return `Run "npm run build" from ${root} (or clone oma-forge and build once).` + } + return 'Reinstall oma-forge or run from a built oma-forge checkout.' +} + +function ensureBuiltArtifacts(): void { + const missing: string[] = [] + if (!existsSync(resolveServerEntry())) missing.push('@oma-forge/server') + if (!existsSync(resolveSharedDist())) missing.push('@oma-forge/shared') + if (!existsSync(resolveReporterDist())) missing.push('@oma-forge/reporter') + if (missing.length > 0) { + die(`Missing build output for ${missing.join(', ')}. ${missingBuildHint()}`) + } +} + +function spawnChild( + command: string, + args: readonly string[], + env: NodeJS.ProcessEnv, + options: { readonly cwd?: string }, +): ChildProcess { + const child = spawn(command, args, { + cwd: options.cwd, + env, + stdio: 'inherit', + shell: false, + }) + child.on('error', (err) => { + console.error(`oma-forge: failed to start ${command}: ${err.message}`) + process.exit(1) + }) + return child +} + +async function main(): Promise { + let options: ReturnType + try { + options = parseArgs(process.argv.slice(2)) + } catch (err) { + die(err instanceof Error ? err.message : String(err)) + } + + if (options.help) { + console.log(HELP_TEXT) + return + } + + if (!options.workflowPath) { + console.error(HELP_TEXT) + die('Workflow path is required.') + } + + const workflowPath = resolveWorkflowArg(options.workflowPath, cwd()) + if (!existsSync(workflowPath)) { + die(`Workflow file not found: ${workflowPath}`) + } + + ensureBuiltArtifacts() + + const projectRoot = resolveProjectRoot(workflowPath) + const serverEntry = resolveServerEntry() + const webRoot = resolveWebRoot() + const viteCli = resolveViteCli(webRoot) + const webUrl = `http://localhost:${options.webPort}` + + const baseEnv: NodeJS.ProcessEnv = { + ...process.env, + FORGE_WORKFLOW_PATH: workflowPath, + FORGE_PROJECT_ROOT: projectRoot, + PORT: String(options.apiPort), + FORGE_API_PORT: String(options.apiPort), + } + + console.log(`oma-forge: workflow ${workflowPath}`) + console.log(`oma-forge: project root ${projectRoot}`) + console.log(`oma-forge: API http://localhost:${options.apiPort}`) + console.log(`oma-forge: UI ${webUrl}`) + + const children: ChildProcess[] = [] + let shuttingDown = false + + const shutdown = (code = 0) => { + if (shuttingDown) return + shuttingDown = true + for (const child of children) { + if (!child.killed) child.kill('SIGTERM') + } + process.exit(code) + } + + process.on('SIGINT', () => shutdown(130)) + process.on('SIGTERM', () => shutdown(143)) + + children.push( + spawnChild(process.execPath, [serverEntry], baseEnv, {}), + spawnChild( + process.execPath, + [viteCli, '--config', 'vite.config.ts', '--port', String(options.webPort), '--strictPort'], + baseEnv, + { cwd: webRoot }, + ), + ) + + if (options.openBrowser) { + setTimeout(() => openBrowser(webUrl), 1200) + } + + await Promise.race( + children.map( + (child) => + new Promise((resolve) => { + child.on('close', (code) => resolve(code)) + }), + ), + ).then((code) => { + if (code !== 0 && code !== null) { + console.error(`oma-forge: process exited with code ${code}`) + } + shutdown(code ?? 1) + }) +} + +void main() diff --git a/packages/cli/src/open-browser.ts b/packages/cli/src/open-browser.ts new file mode 100644 index 0000000..e4a0bf6 --- /dev/null +++ b/packages/cli/src/open-browser.ts @@ -0,0 +1,14 @@ +import { execFile } from 'node:child_process' + +export function openBrowser(url: string): void { + const platform = process.platform + if (platform === 'darwin') { + execFile('open', [url], () => {}) + return + } + if (platform === 'win32') { + execFile('cmd', ['/c', 'start', '', url], () => {}) + return + } + execFile('xdg-open', [url], () => {}) +} diff --git a/packages/cli/src/parse-args.test.ts b/packages/cli/src/parse-args.test.ts new file mode 100644 index 0000000..65f920f --- /dev/null +++ b/packages/cli/src/parse-args.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' +import { parseArgs } from './parse-args.js' + +describe('parseArgs', () => { + it('parses workflow path and flags', () => { + expect(parseArgs(['./wf.ts', '--no-open', '--port', '4000', '--web-port', '6000'])).toEqual({ + workflowPath: './wf.ts', + openBrowser: false, + apiPort: 4000, + webPort: 6000, + help: false, + }) + }) + + it('sets help flag', () => { + expect(parseArgs(['--help']).help).toBe(true) + }) + + it('rejects unknown options', () => { + expect(() => parseArgs(['--nope'])).toThrow(/Unknown option/) + }) +}) diff --git a/packages/cli/src/parse-args.ts b/packages/cli/src/parse-args.ts new file mode 100644 index 0000000..f6902fa --- /dev/null +++ b/packages/cli/src/parse-args.ts @@ -0,0 +1,71 @@ +export type CliOptions = { + readonly workflowPath: string | undefined + readonly openBrowser: boolean + readonly apiPort: number + readonly webPort: number + readonly help: boolean +} + +export function parseArgs(argv: readonly string[]): CliOptions { + let workflowPath: string | undefined + let openBrowser = true + let apiPort = 3001 + let webPort = 5173 + let help = false + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i] + if (arg === '--help' || arg === '-h') { + help = true + continue + } + if (arg === '--no-open') { + openBrowser = false + continue + } + if (arg === '--port') { + apiPort = parsePort(argv[++i], 'port') + continue + } + if (arg === '--web-port') { + webPort = parsePort(argv[++i], 'web-port') + continue + } + if (arg.startsWith('-')) { + throw new Error(`Unknown option: ${arg}`) + } + if (workflowPath) { + throw new Error(`Unexpected argument: ${arg}`) + } + workflowPath = arg + } + + return { workflowPath, openBrowser, apiPort, webPort, help } +} + +function parsePort(value: string | undefined, name: string): number { + if (!value) throw new Error(`Missing value for --${name}`) + const port = Number(value) + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error(`Invalid --${name}: ${value}`) + } + return port +} + +export const HELP_TEXT = `Usage: oma-forge [options] + + Start the Forge API and UI pointed at your workflow file. + +Options: + --port API port (default: 3001) + --web-port UI port (default: 5173) + --no-open Do not open a browser tab + -h, --help Show this help + +Environment: + Loads .env from your project root (nearest package.json to the workflow). + API keys and provider settings belong in that file or the shell environment. + +Example: + npx oma-forge ./workflows/my-workflow.ts +` diff --git a/packages/cli/src/paths.ts b/packages/cli/src/paths.ts new file mode 100644 index 0000000..ff340d7 --- /dev/null +++ b/packages/cli/src/paths.ts @@ -0,0 +1,53 @@ +import { createRequire } from 'node:module' +import { existsSync } from 'node:fs' +import { dirname, isAbsolute, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) + +export function resolveWorkflowArg(arg: string, cwd: string): string { + return resolve(isAbsolute(arg) ? arg : join(cwd, arg)) +} + +export function resolveServerEntry(): string { + const serverRoot = dirname(require.resolve('@oma-forge/server/package.json')) + return join(serverRoot, 'dist/index.js') +} + +export function resolveWebRoot(): string { + return dirname(require.resolve('@oma-forge/web/package.json')) +} + +export function resolveViteCli(webRoot: string): string { + const webRequire = createRequire(join(webRoot, 'package.json')) + const viteRoot = dirname(webRequire.resolve('vite/package.json')) + return join(viteRoot, 'bin', 'vite.js') +} + +export function resolveSharedDist(): string { + const sharedRoot = dirname(require.resolve('@oma-forge/shared/package.json')) + return join(sharedRoot, 'dist/index.js') +} + +export function resolveReporterDist(): string { + const reporterRoot = dirname(require.resolve('@oma-forge/reporter/package.json')) + return join(reporterRoot, 'dist/index.js') +} + +/** Monorepo root (for build hint), when CLI is linked from the oma-forge workspace. */ +export function resolveForgeMonorepoRoot(): string | undefined { + try { + const serverRoot = dirname(require.resolve('@oma-forge/server/package.json')) + const candidate = join(serverRoot, '..', '..') + if (existsSync(join(candidate, 'package.json')) && existsSync(join(candidate, 'apps', 'server'))) { + return resolve(candidate) + } + } catch { + // not in monorepo layout + } + return undefined +} + +export function cliDir(): string { + return dirname(fileURLToPath(import.meta.url)) +} diff --git a/packages/cli/src/resolve-project-root.ts b/packages/cli/src/resolve-project-root.ts new file mode 100644 index 0000000..4f0d40f --- /dev/null +++ b/packages/cli/src/resolve-project-root.ts @@ -0,0 +1,16 @@ +import { existsSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' + +/** Nearest directory with a package.json, else the workflow file's directory. */ +export function resolveProjectRoot(workflowPath: string): string { + let dir = dirname(resolve(workflowPath)) + + while (true) { + if (existsSync(join(dir, 'package.json'))) return dir + const parent = dirname(dir) + if (parent === dir) break + dir = parent + } + + return dirname(resolve(workflowPath)) +} diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000..a5ea8c3 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "outDir": "dist", + "rootDir": "src", + "strict": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src"] +} diff --git a/packages/reporter/package.json b/packages/reporter/package.json index 0fc7198..569bec8 100644 --- a/packages/reporter/package.json +++ b/packages/reporter/package.json @@ -9,7 +9,13 @@ "types": "./src/index.ts", "import": "./dist/index.js", "default": "./dist/index.js" - } + }, + "./run-workflow": { + "types": "./src/run-workflow.ts", + "import": "./dist/run-workflow.js", + "default": "./dist/run-workflow.js" + }, + "./package.json": "./package.json" }, "scripts": { "build": "tsc", diff --git a/packages/reporter/src/bootstrap.ts b/packages/reporter/src/bootstrap.ts index e5058c8..75bb910 100644 --- a/packages/reporter/src/bootstrap.ts +++ b/packages/reporter/src/bootstrap.ts @@ -1,27 +1,21 @@ -import { createForgeReporter } from './reporter.js' +import { forgeAbortSignal, forgeRunId, getForgeReporter } from './forge-env.js' +import { runForgeWorkflow } from './run-forge.js' import type { ForgeRunContext } from './types.js' /** - * Runs a workflow when Forge spawns it as a subprocess. - * No-ops when the file is imported without `FORGE_RUN_ID` (e.g. tests). + * @deprecated Export a default async function instead; Forge runs it via the workflow runner shim. */ export async function bootstrapForgeWorkflow( run: (ctx: ForgeRunContext) => Promise, ): Promise { - const runId = process.env.FORGE_RUN_ID - if (!runId) return - - const goal = process.env.FORGE_GOAL ?? '' - const abortController = new AbortController() - process.on('SIGTERM', () => abortController.abort()) - - const reporter = createForgeReporter(runId) - - try { - await run({ runId, goal, abortSignal: abortController.signal, reporter }) - } catch (error) { - const message = error instanceof Error ? error.message : 'Workflow failed' - reporter.fail(message) - process.exit(1) - } + await runForgeWorkflow(async () => { + const runId = forgeRunId() + if (!runId) return + await run({ + runId, + goal: process.env.FORGE_GOAL ?? '', + abortSignal: forgeAbortSignal(), + reporter: getForgeReporter(), + }) + }) } diff --git a/packages/reporter/src/forge-env.ts b/packages/reporter/src/forge-env.ts new file mode 100644 index 0000000..f3b62ac --- /dev/null +++ b/packages/reporter/src/forge-env.ts @@ -0,0 +1,31 @@ +import { createForgeReporter, type ForgeReporter } from './reporter.js' +import { noopForgeReporter } from './noop-reporter.js' + +let reporter: ForgeReporter | undefined +let abortController: AbortController | undefined + +function initForgeRun(): ForgeReporter { + const runId = process.env.FORGE_RUN_ID + if (!runId) return noopForgeReporter + + if (!reporter) { + reporter = createForgeReporter(runId) + abortController = new AbortController() + process.on('SIGTERM', () => abortController?.abort()) + } + return reporter +} + +/** Reporter that streams to Forge when `FORGE_RUN_ID` is set; no-op otherwise. */ +export function getForgeReporter(): ForgeReporter { + return process.env.FORGE_RUN_ID ? initForgeRun() : noopForgeReporter +} + +export function forgeRunId(): string | undefined { + return process.env.FORGE_RUN_ID +} + +export function forgeAbortSignal(): AbortSignal { + initForgeRun() + return abortController?.signal ?? new AbortController().signal +} diff --git a/packages/reporter/src/hooks.ts b/packages/reporter/src/hooks.ts new file mode 100644 index 0000000..344aa93 --- /dev/null +++ b/packages/reporter/src/hooks.ts @@ -0,0 +1,29 @@ +import type { TeamRunResult } from '@open-multi-agent/core' +import { getForgeReporter } from './forge-env.js' + +/** + * Spread into `new OpenMultiAgent({ ...yourConfig, ...forgeHooks() })` so Forge can stream + * progress and trace. No-op when the file is not run as a Forge subprocess. + */ +export function forgeHooks() { + const reporter = getForgeReporter() + return { + onProgress: reporter.onProgress, + onTrace: reporter.onTrace, + onPlanReady: reporter.onPlanReady, + onAgentStream: reporter.onAgentStream, + } +} + +export function forgeFinish(result: TeamRunResult): void { + getForgeReporter().finish(result) +} + +export function forgeFail(message: string): void { + getForgeReporter().fail(message) +} + +export function isTeamRunResult(value: unknown): value is TeamRunResult { + if (typeof value !== 'object' || value === null) return false + return 'success' in value && 'goal' in value && 'tasks' in value +} diff --git a/packages/reporter/src/index.ts b/packages/reporter/src/index.ts index 8ddded0..27000af 100644 --- a/packages/reporter/src/index.ts +++ b/packages/reporter/src/index.ts @@ -1,3 +1,6 @@ export { bootstrapForgeWorkflow } from './bootstrap.js' export { createForgeReporter, type ForgeReporter } from './reporter.js' +export { forgeFail, forgeFinish, forgeHooks, isTeamRunResult } from './hooks.js' +export { forgeAbortSignal, forgeRunId, getForgeReporter } from './forge-env.js' +/** @deprecated Use `forgeHooks`, `process.env.FORGE_GOAL`, and `forgeAbortSignal` instead. */ export type { ForgeRunContext } from './types.js' diff --git a/packages/reporter/src/noop-reporter.ts b/packages/reporter/src/noop-reporter.ts new file mode 100644 index 0000000..b0f9983 --- /dev/null +++ b/packages/reporter/src/noop-reporter.ts @@ -0,0 +1,13 @@ +import type { ForgeReporter } from './reporter.js' + +const noop = () => {} + +/** Used when a workflow file is loaded outside a Forge subprocess. */ +export const noopForgeReporter: ForgeReporter = { + onProgress: noop, + onTrace: noop, + onPlanReady: async () => true, + onAgentStream: noop, + finish: noop, + fail: noop, +} diff --git a/packages/reporter/src/run-forge.ts b/packages/reporter/src/run-forge.ts new file mode 100644 index 0000000..5ce3b14 --- /dev/null +++ b/packages/reporter/src/run-forge.ts @@ -0,0 +1,23 @@ +import { getForgeReporter } from './forge-env.js' +import { isTeamRunResult } from './hooks.js' + +/** + * Runs `main` inside a Forge subprocess (when `FORGE_RUN_ID` is set). + * Finishes the run from the return value when it looks like a {@link TeamRunResult}. + */ +export async function runForgeWorkflow(main: () => Promise): Promise { + if (!process.env.FORGE_RUN_ID) return + + getForgeReporter() + + try { + const result = await main() + if (isTeamRunResult(result)) { + getForgeReporter().finish(result) + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Workflow failed' + getForgeReporter().fail(message) + process.exit(1) + } +} diff --git a/packages/reporter/src/run-workflow.ts b/packages/reporter/src/run-workflow.ts new file mode 100644 index 0000000..9233a1d --- /dev/null +++ b/packages/reporter/src/run-workflow.ts @@ -0,0 +1,20 @@ +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { runForgeWorkflow } from './run-forge.js' + +const workflowPath = process.argv[2] +if (!workflowPath) { + console.error('oma-forge: missing workflow path') + process.exit(1) +} + +await runForgeWorkflow(async () => { + const mod = await import(pathToFileURL(resolve(workflowPath)).href) + const fn = mod.default + if (typeof fn !== 'function') { + throw new Error( + 'Workflow must export a default async function — see https://github.com/open-multi-agent/oma-forge', + ) + } + return fn() +}) diff --git a/packages/shared/package.json b/packages/shared/package.json index f402a24..94fd34c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -9,7 +9,8 @@ "types": "./src/index.ts", "import": "./dist/index.js", "default": "./dist/index.js" - } + }, + "./package.json": "./package.json" }, "scripts": { "build": "tsc", diff --git a/workflows/demo.ts b/workflows/demo.ts index 1ce24d6..9d004f8 100644 --- a/workflows/demo.ts +++ b/workflows/demo.ts @@ -1,5 +1,5 @@ import { OpenMultiAgent } from '@open-multi-agent/core' -import { bootstrapForgeWorkflow, type ForgeRunContext } from '@oma-forge/reporter' +import { forgeAbortSignal, forgeHooks } from '@oma-forge/reporter' const teamConfig = { name: 'forge-demo', @@ -31,36 +31,30 @@ function teamGoal(userGoal: string): string { } /** Built-in demo workflow — tools and MCPs are wired here, not in Forge. */ -export default async function run(ctx: ForgeRunContext): Promise { - const { goal, abortSignal, reporter } = ctx - +export default async function main() { const apiKey = process.env.GOOGLE_API_KEY ?? process.env.GEMINI_API_KEY if (!apiKey) { - reporter.fail( - 'Missing GOOGLE_API_KEY or GEMINI_API_KEY — add one to .env at the repo root or export it in your shell.', + throw new Error( + 'Missing GOOGLE_API_KEY or GEMINI_API_KEY — add one to .env at the project root or export it in your shell.', ) - process.exit(1) } + const goal = process.env.FORGE_GOAL ?? '' + const oma = new OpenMultiAgent({ defaultProvider: 'gemini', defaultApiKey: apiKey, defaultModel: 'gemini-2.5-flash', - onProgress: reporter.onProgress, - onTrace: reporter.onTrace, - onPlanReady: reporter.onPlanReady, - onAgentStream: reporter.onAgentStream, + // TODO: using existing OMA hooks to stream progress and trace to Forge may block the developer from using it in their workflow. + ...forgeHooks(), }) const team = oma.createTeam(teamConfig.name, teamConfig) - const result = await oma.runTeam(team, teamGoal(goal), { - abortSignal, + return oma.runTeam(team, teamGoal(goal), { + abortSignal: forgeAbortSignal(), coordinator: { instructions: 'Always decompose into at least two sequential tasks: one for researcher, one for summary-writer.', }, }) - reporter.finish(result) } - -void bootstrapForgeWorkflow(run) diff --git a/workflows/test-stub.ts b/workflows/test-stub.ts index 031b2e4..df689e9 100644 --- a/workflows/test-stub.ts +++ b/workflows/test-stub.ts @@ -1,13 +1,14 @@ -import { bootstrapForgeWorkflow, type ForgeRunContext } from '@oma-forge/reporter' +import { getForgeReporter } from '@oma-forge/reporter' /** Minimal workflow for server integration tests (no LLM calls). */ -export default async function run(ctx: ForgeRunContext): Promise { - const { goal, reporter } = ctx +export default async function main() { + const goal = process.env.FORGE_GOAL ?? '' + const reporter = getForgeReporter() reporter.onProgress({ type: 'task_start', task: 't1', agent: 'worker' }) reporter.onTrace({ type: 'tool_call', - runId: ctx.runId, + runId: process.env.FORGE_RUN_ID!, startMs: 0, endMs: 1, durationMs: 1, @@ -20,7 +21,7 @@ export default async function run(ctx: ForgeRunContext): Promise { }) reporter.onProgress({ type: 'task_complete', task: 't1', agent: 'worker' }) - reporter.finish({ + return { success: true, goal, tasks: [ @@ -34,7 +35,5 @@ export default async function run(ctx: ForgeRunContext): Promise { ], agentResults: new Map(), totalTokenUsage: { input_tokens: 0, output_tokens: 0 }, - }) + } } - -void bootstrapForgeWorkflow(run)