From ee3579b6b56700c9b969844246f8a508c2968b4b Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:43:57 -0500 Subject: [PATCH] Expose lite worker launch through Factory SDK --- .github/workflows/public-sdk.yml | 9 ++++++++- PUBLIC_SDK.md | 9 ++++++++- bin/factory-lite.mjs | 8 ++------ src/lite-worker.mjs | 11 ++++++++++- src/public-sdk.mjs | 4 +++- test/public-sdk.test.mjs | 19 ++++++++++++++++++- 6 files changed, 49 insertions(+), 11 deletions(-) diff --git a/.github/workflows/public-sdk.yml b/.github/workflows/public-sdk.yml index d3cc0a4..c51f46f 100644 --- a/.github/workflows/public-sdk.yml +++ b/.github/workflows/public-sdk.yml @@ -38,7 +38,7 @@ jobs: import { pathToFileURL } from 'node:url'; const consumer = process.env.FACTORY_CONSUMER; const sdk = await import(pathToFileURL(join(consumer, 'node_modules', 'flujo-factory', 'src', 'public-sdk.mjs'))); - for (const name of ['Factory', 'FactoryManagedFleet', 'buildSaviaCasePlan', 'observeSaviaCaseTopology', 'createObservatoryClient', 'liteWorkerCommand']) { + for (const name of ['Factory', 'FactoryManagedFleet', 'buildSaviaCasePlan', 'observeSaviaCaseTopology', 'createObservatoryClient', 'liteWorkerCommand', 'startLiteWorker']) { assert.equal(typeof sdk[name], 'function', `${name} is missing`); } const require = createRequire(join(consumer, 'index.mjs')); @@ -48,6 +48,13 @@ jobs: const factory = new sdk.Factory(join(consumer, 'control.sqlite')); factory.createSwarm({ mission: 'package smoke', budgetCents: 0, agents: [{ id: 'agent-a' }] }); assert.equal(factory.status().cells.length, 2); + const child = { pid: 7 }; + let launched; + assert.equal(factory.startLiteWorker({ provider: 'claude', spawnImpl(command, args, options) { + launched = { command, args, options }; return child; + } }), child); + assert.equal(launched.command, 'claude'); + assert.equal(JSON.parse(launched.args[launched.args.indexOf('--mcp-config') + 1]).mcpServers.factory.args[1], factory.database); NODE node "$RUNNER_TEMP/consumer/node_modules/flujo-factory/bin/factory-public.mjs" --help node "$RUNNER_TEMP/consumer/node_modules/flujo-factory/bin/factory-lite.mjs" --help diff --git a/PUBLIC_SDK.md b/PUBLIC_SDK.md index 74f3bfd..28675fc 100644 --- a/PUBLIC_SDK.md +++ b/PUBLIC_SDK.md @@ -115,10 +115,17 @@ const factory = new Factory('/tmp/my-factory.sqlite'); factory.createSwarm({ mission: 'Build an app', budgetCents: 0, agents: [{ id: 'builder', role: 'developer' }, { id: 'reviewer', role: 'verifier' }] }); console.log(factory.status()); +const worker = factory.startLiteWorker({ provider: 'codex', prompt: 'Work on the Factory board' }); +worker.on('exit', code => console.log('lite worker exited:', code)); ``` The lower-level `FactoryControl` is also exported for the full local controller -API. Each SDK call closes its database connection before returning. +API. Each ledger SDK call closes its database connection before returning. +`startLiteWorker` returns a Node `ChildProcess`; it runs in the caller's current +directory by default and inherits the terminal. Pass `cwd` or `stdio` to change +those process options. The standalone `startLiteWorker({provider,database,...})` +and `liteWorkerCommand({provider,database,...})` are also exported; the latter +only builds the process specification. ## FLUJO worker dispatch diff --git a/bin/factory-lite.mjs b/bin/factory-lite.mjs index 447f6ba..cc40c60 100644 --- a/bin/factory-lite.mjs +++ b/bin/factory-lite.mjs @@ -1,16 +1,12 @@ #!/usr/bin/env node -import { spawn } from 'node:child_process'; -import { mkdirSync } from 'node:fs'; -import { liteWorkerCommand } from '../src/lite-worker.mjs'; +import { Factory } from '../src/public-sdk.mjs'; const [provider, database, ...words] = process.argv.slice(2); if (provider === '--help' || provider === 'help' || !provider) { process.stdout.write('Usage: factory-lite [prompt]\n'); } else { try { - const spec = liteWorkerCommand({ provider, database, prompt: words.length ? words.join(' ') : undefined }); - if (spec.env?.CODEX_HOME) mkdirSync(spec.env.CODEX_HOME, { recursive: true, mode: 0o700 }); - const child = spawn(spec.command, spec.args, { cwd: spec.cwd, env: spec.env, stdio: 'inherit' }); + const child = new Factory(database).startLiteWorker({ provider, prompt: words.length ? words.join(' ') : undefined }); child.on('error', error => { process.stderr.write(`${error.message}\n`); process.exitCode = 1; }); child.on('exit', (code, signal) => { process.exitCode = code ?? (signal ? 1 : 0); }); } catch (error) { diff --git a/src/lite-worker.mjs b/src/lite-worker.mjs index 2b11ecf..3727b23 100644 --- a/src/lite-worker.mjs +++ b/src/lite-worker.mjs @@ -1,10 +1,12 @@ import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { homedir } from 'node:os'; +import { spawn } from 'node:child_process'; +import { mkdirSync } from 'node:fs'; const mcp = fileURLToPath(new URL('../bin/factory-mcp.mjs', import.meta.url)); -/** Start a normal provider CLI with its native tools and only FACTORY's MCP server. */ +/** Build a normal provider CLI command with its native tools and only FACTORY's MCP server. */ export function liteWorkerCommand({ provider, database, prompt, cwd = process.cwd() } = {}) { if (!['codex', 'claude'].includes(provider)) throw new TypeError('provider must be codex or claude'); if (typeof database !== 'string' || !database.trim()) throw new TypeError('database path is required'); @@ -21,3 +23,10 @@ export function liteWorkerCommand({ provider, database, prompt, cwd = process.cw args: ['--sandbox', 'danger-full-access', '--ask-for-approval', 'never', '--config', config, ...(prompt === undefined ? [] : [prompt])] }; } + +/** Launch an interactive native CLI session and return its ChildProcess. */ +export function startLiteWorker({ stdio = 'inherit', spawnImpl = spawn, ...options } = {}) { + const spec = liteWorkerCommand(options); + if (spec.env?.CODEX_HOME) mkdirSync(spec.env.CODEX_HOME, { recursive: true, mode: 0o700 }); + return spawnImpl(spec.command, spec.args, { cwd: spec.cwd, env: spec.env, stdio }); +} diff --git a/src/public-sdk.mjs b/src/public-sdk.mjs index 2029f1f..7d9012d 100644 --- a/src/public-sdk.mjs +++ b/src/public-sdk.mjs @@ -1,5 +1,6 @@ import { resolve } from 'node:path'; import { FactoryControl } from './control.mjs'; +import { startLiteWorker } from './lite-worker.mjs'; /** A local, durable coordinator. No provider calls or worker processes are started. */ export class Factory { @@ -69,6 +70,7 @@ export class Factory { status() { return this.#withControl(control => control.status()); } pause() { return this.#withControl(control => control.pause()); } resume() { return this.#withControl(control => control.resume()); } + startLiteWorker(options = {}) { return startLiteWorker({ ...options, database: this.database }); } } export { FactoryControl } from './control.mjs'; @@ -82,4 +84,4 @@ export { buildSaviaCasePlan } from './flujo-swarm/template/savia-case-plan.mjs'; export { observeSaviaCaseTopology } from './flujo-swarm/template/savia-topology.mjs'; export { createPresentationServer, startPresentationServer } from './presentation.mjs'; export { createObservatoryClient } from './observatory-client.mjs'; -export { liteWorkerCommand } from './lite-worker.mjs'; +export { liteWorkerCommand, startLiteWorker } from './lite-worker.mjs'; diff --git a/test/public-sdk.test.mjs b/test/public-sdk.test.mjs index 5b42df3..b38f17b 100644 --- a/test/public-sdk.test.mjs +++ b/test/public-sdk.test.mjs @@ -7,7 +7,7 @@ import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; -import { Factory, FactoryControl, liteWorkerCommand } from '../src/public-sdk.mjs'; +import { Factory, FactoryControl, liteWorkerCommand, startLiteWorker } from '../src/public-sdk.mjs'; const cli = fileURLToPath(new URL('../bin/factory-public.mjs', import.meta.url)); const mcp = process.env.FACTORY_TEST_MCP ?? fileURLToPath(new URL('../bin/factory-mcp.mjs', import.meta.url)); @@ -32,6 +32,23 @@ test('lite workers launch native CLIs with only the Factory MCP configuration', assert.throws(() => liteWorkerCommand({ provider: 'flujo', database: 'board.sqlite' }), /provider/); }); +test('public SDK starts a lite worker through the same launcher as the CLI', async t => { + const dir = await mkdtemp(join(tmpdir(), 'factory-lite-sdk-')); + t.after(() => rm(dir, { recursive: true, force: true })); + const database = join(dir, 'board.sqlite'); + const factory = new Factory(database); + const child = { pid: 1234 }; + let launched; + const result = factory.startLiteWorker({ provider: 'claude', prompt: 'Work on the board', cwd: dir, + stdio: 'pipe', spawnImpl(command, args, options) { launched = { command, args, options }; return child; } }); + assert.equal(result, child); + assert.equal(launched.command, 'claude'); + assert.equal(launched.options.cwd, dir); + assert.equal(launched.options.stdio, 'pipe'); + assert.equal(JSON.parse(launched.args[launched.args.indexOf('--mcp-config') + 1]).mcpServers.factory.args[1], database); + assert.equal(typeof startLiteWorker, 'function'); +}); + test('public SDK and CLI coordinate a local swarm without a provider', async t => { const dir = await mkdtemp(join(tmpdir(), 'factory-public-')); t.after(() => rm(dir, { recursive: true, force: true }));