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
9 changes: 8 additions & 1 deletion .github/workflows/public-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand All @@ -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
Expand Down
9 changes: 8 additions & 1 deletion PUBLIC_SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 2 additions & 6 deletions bin/factory-lite.mjs
Original file line number Diff line number Diff line change
@@ -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 <codex|claude> <database-path> [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) {
Expand Down
11 changes: 10 additions & 1 deletion src/lite-worker.mjs
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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 });
}
4 changes: 3 additions & 1 deletion src/public-sdk.mjs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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';
Expand All @@ -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';
19 changes: 18 additions & 1 deletion test/public-sdk.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand All @@ -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 }));
Expand Down
Loading