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
29 changes: 2 additions & 27 deletions .github/workflows/public-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,39 +23,14 @@ jobs:
cache: npm
- run: npm ci
- run: npm audit --omit=dev --audit-level=high
- run: node --test test/public-sdk.test.mjs test/savia-case-plan.test.mjs test/savia-topology.test.mjs test/managed-fleet.test.mjs test/managed-fleet-real-module.test.mjs test/managed-fleet-provider-closure.test.mjs test/provider-cell-retirement.test.mjs test/presentation.test.mjs test/presentation-modal.test.mjs test/presentation-closure.test.mjs test/observatory-client.test.mjs
- run: node --test test/public-sdk.test.mjs test/template-catalog.test.mjs test/factory-team-template.test.mjs test/flujo-workspace.test.mjs test/savia-case-plan.test.mjs test/savia-topology.test.mjs test/managed-fleet.test.mjs test/managed-fleet-real-module.test.mjs test/managed-fleet-provider-closure.test.mjs test/provider-cell-retirement.test.mjs test/presentation.test.mjs test/presentation-modal.test.mjs test/presentation-closure.test.mjs test/observatory-client.test.mjs
- name: Install and exercise the packed public package
shell: bash
run: |
tarball=$(npm pack --pack-destination "$RUNNER_TEMP" --json | node -e "let raw='';process.stdin.on('data',chunk=>raw+=chunk).on('end',()=>process.stdout.write(JSON.parse(raw)[0].filename))")
mkdir "$RUNNER_TEMP/consumer"
npm install --prefix "$RUNNER_TEMP/consumer" "$RUNNER_TEMP/$tarball" --ignore-scripts --no-audit --no-fund
FACTORY_CONSUMER="$RUNNER_TEMP/consumer" node --input-type=module <<'NODE'
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join } from 'node:path';
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', 'startLiteWorker']) {
assert.equal(typeof sdk[name], 'function', `${name} is missing`);
}
const require = createRequire(join(consumer, 'index.mjs'));
const observatory = await import(pathToFileURL(require.resolve('flujo-factory/observatory')));
assert.equal(typeof observatory.createObservatoryClient, 'function');
assert.equal(existsSync(join(consumer, 'node_modules', 'flujo-factory', 'src', 'observatory-client.d.mts')), true);
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
FACTORY_CONSUMER="$RUNNER_TEMP/consumer" node scripts/verify-public-sdk.mjs
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
FACTORY_TEST_MCP="$RUNNER_TEMP/consumer/node_modules/flujo-factory/bin/factory-mcp.mjs" node --test --test-name-pattern="public MCP stdio" test/public-sdk.test.mjs
41 changes: 41 additions & 0 deletions PUBLIC_SDK.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,47 @@ initialized ledger passed under Node 24 with zero provider calls; see the
same evidence record.
`flujo-factory` is not yet published to the npm registry.

## Default generic native team and template CRUD

FACTORY owns the reusable setup; applications supply their goal, environment descriptions,
upstream pins and runtime bindings. The built-in `generic` template produces the existing
native `swarm_agent` / `swarm_team` pair with one shared subflow concurrency gate. It uses
observed connected tools, concise shared-board coordination, independent review and useful
product delivery. Specialized SAVIA roles remain an explicit opt-in.

```js
const factory = new Factory('/private/control.sqlite');
const defaults = factory.getTemplate('generic');
factory.updateTemplate('generic', {
expectedRevision: defaults.revision,
environment: { workspace: '/workspace', board: 'https://github.com/org/project/issues/1' },
goalContext: 'Reuse the installed upstream systems and ship a usable product slice',
});
const flowSpecs = factory.buildTemplate('generic', {
model: 'installed-model-id',
availableServers: ['filesystem', 'bash'],
availableTools: { filesystem: ['read_file', 'write_file'], bash: ['run'] },
});
```

`listTemplates()`, `getTemplate(name)`, `createTemplate({name, ...settings})`,
`updateTemplate(name, {expectedRevision, ...settings})` and
`deleteTemplate(name, {expectedRevision})` persist in the same local Factory database.
Settings are `description`, `goalContext`, `environment` (string-valued descriptions) and
`limits` (`agentTurns`, `leadTurns`, `concurrency`). Revisions protect concurrent writers
and survive deletion/recreation. Deleting a persisted `generic` override resets it to the
built-in default. Put credentials in private runtime configuration, never template settings.

The CLI exposes `template-list` and JSON-input `template-read`, `template-create`,
`template-update`, `template-delete`, `template-build`; the MCP server exposes corresponding
`factory_template_*` tools. CRUD and building specifications start no workers.

Provisioning through `createFlujoWorkspaceAdapter` with `modelConfig` and no explicit
flows selects the generic native team automatically. Explicit `flowSpec`, `flowSpecs` or
`teamTemplate` continues to take precedence. To install a catalog template, pass the
built `flowSpecs` through the adapter's existing provisioning interface; the catalog's
local overrides are not implicitly shared with a remote adapter.

## CLI

### Lite workers
Expand Down
28 changes: 27 additions & 1 deletion bin/factory-mcp.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ if (!database || process.argv.length !== 3) {
}
const factory = new Factory(database);
const definitions = [
['factory_template_list', 'List durable templates, including the default generic native FLUJO team.'],
['factory_template_read', 'Read a template and its revision.'],
['factory_template_create', 'Create a reusable template without provisioning or launching workers.'],
['factory_template_update', 'Update template settings against an expected revision.'],
['factory_template_delete', 'Delete a custom template or reset the generic override.'],
['factory_template_build', 'Build native FLUJO flow specifications using an installed model and observed tool inventory.'],
['factory_create', 'Create a durable local swarm with mission, budgetCents, and an agents array.'],
['factory_add_agent', 'Reserve and enroll a local agent cell.'],
['factory_add_task', 'Add an immutable task for agents to claim.'],
Expand All @@ -20,7 +26,17 @@ const definitions = [
['factory_pause', 'Pause new local admission.'],
['factory_resume', 'Resume local admission.'],
];
const settings = { description: {type:'string'}, goalContext: {type:'string'},
environment: {type:'object',additionalProperties:{type:'string'}},
limits: {type:'object',additionalProperties:false,properties:{agentTurns:{type:'integer'},leadTurns:{type:'integer'},concurrency:{type:'integer'}}} };
const schemas = {
factory_template_list: {},
factory_template_read: {required:['name'],properties:{name:{type:'string'}}},
factory_template_create: {required:['name'],properties:{name:{type:'string'},...settings}},
factory_template_update: {required:['name','expectedRevision'],properties:{name:{type:'string'},expectedRevision:{type:'integer',minimum:0},...settings}},
factory_template_delete: {required:['name','expectedRevision'],properties:{name:{type:'string'},expectedRevision:{type:'integer',minimum:0}}},
factory_template_build: {required:['model'],properties:{name:{type:'string'},model:{type:'string'},
availableServers:{type:'array',items:{type:'string'}}, availableTools:{type:'object',additionalProperties:{type:'array',items:{type:'string'}}}, limits:settings.limits}},
factory_create: { required: ['mission'], properties: { mission: { type: 'string' }, budgetCents: { type: 'integer', minimum: 0 },
agents: { type: 'array', items: { type: 'object', required: ['id'], properties: { id: { type: 'string' },
role: { type: 'string', enum: ['developer', 'verifier', 'watcher', 'coordinator'] },
Expand All @@ -37,6 +53,16 @@ const schemas = {
agentId: { type: 'string' }, ttlMs: { type: 'integer', minimum: 1 } } },
};
const methods = {
factory_template_list: () => ({templates:factory.listTemplates()}),
factory_template_read: args => {
const record = factory.getTemplate(args.name);
if (!record) throw Object.assign(new Error('Template does not exist.'), {code:'TEMPLATE_MISSING'});
return record;
},
factory_template_create: args => factory.createTemplate(args),
factory_template_update: ({name,...args}) => factory.updateTemplate(name,args),
factory_template_delete: ({name,...args}) => factory.deleteTemplate(name,args),
factory_template_build: ({name='generic',...args}) => ({flowSpecs:factory.buildTemplate(name,args)}),
factory_create: args => factory.createSwarm(args),
factory_add_agent: args => factory.addAgent(args),
factory_add_task: args => factory.addTask(args),
Expand All @@ -49,7 +75,7 @@ const packageVersion = JSON.parse(readFileSync(new URL('../package.json', import
const server = new Server({ name: 'factory-local', version: packageVersion }, { capabilities: { tools: {} } });
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: definitions.map(([name, description]) => ({
name, description, inputSchema: { type: 'object', additionalProperties: false, ...(schemas[name] ?? {}) },
annotations: { readOnlyHint: name === 'factory_status', openWorldHint: false },
annotations: { readOnlyHint: ['factory_status','factory_template_list','factory_template_read','factory_template_build'].includes(name), openWorldHint: false },
})) }));
server.setRequestHandler(CallToolRequestSchema, async request => {
const method = methods[request.params.name];
Expand Down
12 changes: 9 additions & 3 deletions bin/factory-public.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { Factory } from '../src/public-sdk.mjs';
import { readFileSync } from 'node:fs';

const [command, database, ...rest] = process.argv.slice(2);
const usage = 'Usage: factory <create|agent|task|claim|status|pause|resume> <database-path> [JSON | @file | -]\n';
const usage = 'Usage: factory <create|agent|task|claim|status|pause|resume|template-list|template-read|template-create|template-update|template-delete|template-build> <database-path> [JSON | @file | -]\n';
if (!command || command === '--help' || command === 'help') {
process.stdout.write(usage);
process.exit(0);
}
try {
if (!database || !['create','agent','task','claim','status','pause','resume'].includes(command)) throw new Error(usage.trim());
const mutating = ['create','agent','task','claim'].includes(command);
if (!database || !['create','agent','task','claim','status','pause','resume','template-list','template-read','template-create','template-update','template-delete','template-build'].includes(command)) throw new Error(usage.trim());
const mutating = ['create','agent','task','claim','template-read','template-create','template-update','template-delete','template-build'].includes(command);
if (mutating !== (rest.length === 1)) throw new Error(mutating ? 'Exactly one JSON argument is required.' : 'Unexpected arguments.');
const source = mutating ? rest[0] === '-' ? readFileSync(0, 'utf8')
: rest[0].startsWith('@') ? readFileSync(rest[0].slice(1), 'utf8') : rest[0] : undefined;
Expand All @@ -19,6 +19,12 @@ try {
const factory = new Factory(database);
const result = ({ create: () => factory.createSwarm(input), agent: () => factory.addAgent(input),
task: () => factory.addTask(input), claim: () => factory.claimTask(input),
'template-list': () => factory.listTemplates(),
'template-read': () => factory.getTemplate(input.name),
'template-create': () => factory.createTemplate(input),
'template-update': () => { const {name,...options}=input; return factory.updateTemplate(name,options); },
'template-delete': () => { const {name,...options}=input; return factory.deleteTemplate(name,options); },
'template-build': () => { const {name='generic',...options}=input; return factory.buildTemplate(name,options); },
status: () => factory.status(), pause: () => factory.pause(), resume: () => factory.resume() })[command]();
process.stdout.write(JSON.stringify(result) + '\n');
} catch (error) {
Expand Down
64 changes: 64 additions & 0 deletions scripts/verify-public-sdk.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { isAbsolute, join } from 'node:path';
import { pathToFileURL } from 'node:url';

// Run against a fresh install of an npm pack tarball, outside this checkout.
const consumer = process.env.FACTORY_CONSUMER;
assert.ok(consumer && isAbsolute(consumer), 'FACTORY_CONSUMER must be an absolute consumer directory');
const require = createRequire(join(consumer, 'index.mjs'));
const sdk = await import(pathToFileURL(require.resolve('flujo-factory')));
for (const name of [
'Factory',
'FactoryManagedFleet',
'buildSaviaCasePlan',
'observeSaviaCaseTopology',
'createObservatoryClient',
'liteWorkerCommand',
'startLiteWorker',
]) {
assert.equal(typeof sdk[name], 'function', `${name} is missing`);
}
const observatory = await import(pathToFileURL(require.resolve('flujo-factory/observatory')));
assert.equal(typeof observatory.createObservatoryClient, 'function');
assert.equal(existsSync(join(consumer, 'node_modules', 'flujo-factory', 'src', 'observatory-client.d.mts')), true);

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);

assert.deepEqual(factory.listTemplates().map(template => template.name), ['generic']);
const created = factory.createTemplate({
name: 'package-smoke',
goalContext: 'Deliver a synthetic customer case',
environment: { channel: 'synthetic' },
limits: { concurrency: 2 },
});
assert.equal(created.revision, 1);
assert.equal(factory.getTemplate('package-smoke').goalContext, 'Deliver a synthetic customer case');
const specs = factory.buildTemplate('package-smoke', {
model: 'installed-model', availableServers: [], availableTools: {},
});
assert.deepEqual(specs.map(spec => spec.name), ['swarm_agent', 'swarm_team']);
assert.equal(specs[1].nodes.find(node => node.key === 'agents').concurrencyLimit, 2);
assert.match(specs[1].nodes[0].prompt, /synthetic customer case/);
const updated = factory.updateTemplate('package-smoke', {
expectedRevision: created.revision,
goalContext: 'Deliver a synthetic case status',
});
assert.equal(updated.revision, 2);
assert.equal(factory.getTemplate('package-smoke').goalContext, 'Deliver a synthetic case status');
assert.equal(factory.deleteTemplate('package-smoke', { expectedRevision: updated.revision }).deleted, true);
assert.equal(factory.getTemplate('package-smoke'), null);
21 changes: 14 additions & 7 deletions src/adapters/flujo-workspace.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,13 @@ export function createFlujoWorkspaceAdapter({ origin, token, clientFactory = def
const adapter = Object.freeze({
capabilities: Object.freeze({ adapter: 'flujo-workspace', machineIsolation: false,
recursiveProvisioning: false, providerSpendObserved: false }),
async provision({ app, flowSpec, flowSpecs, teamTemplate, modelConfig } = {}) {
async provision({ app, flowSpec, flowSpecs, teamTemplate, modelConfig, goalContext, environment } = {}) {
const client = clientFor(app);
const requestedSpecs = flowSpecs ?? (flowSpec === undefined ? null : [flowSpec]);
const explicitSpecs = flowSpec !== undefined || flowSpecs !== undefined;
const defaultTemplate = !explicitSpecs && teamTemplate === undefined && modelConfig !== undefined;
if (teamTemplate !== undefined && (flowSpec !== undefined || flowSpecs !== undefined)
|| teamTemplate === undefined && (!Array.isArray(requestedSpecs)
|| !defaultTemplate && teamTemplate === undefined && (!Array.isArray(requestedSpecs)
|| requestedSpecs.length < 1 || requestedSpecs.length > 32
|| new Set(requestedSpecs.map(spec => spec?.name)).size !== requestedSpecs.length
|| requestedSpecs.some(spec => !spec || typeof spec !== 'object' || Array.isArray(spec)
Expand All @@ -46,22 +48,27 @@ export function createFlujoWorkspaceAdapter({ origin, token, clientFactory = def
if (teamTemplate && modelConfig && teamTemplate.model !== modelConfig.id) {
throw new TypeError('Team template and model configuration must name the same model');
}
const template = teamTemplate ?? (defaultTemplate ? { model: modelConfig.id, goalContext, environment } : null);
if (template) buildFactoryTeamSpecs({ ...template,
availableServers: template.specialists?.binding?.requiredAgentServers ?? [], availableTools: {} });
// A fresh FACTORY intent must never adopt or overwrite a preexisting workspace.
if ((await client.workspaces()).includes(client.workspace)) throw new Error('Workspace identity is already occupied.');
await client.ensureWorkspace();
if (modelConfig) await client.upsertModel(modelConfig);
let specs = requestedSpecs;
if (teamTemplate) {
const supported = new Set(['filesystem', 'bash', 'browser', 'flujo']);
const servers = (await client.servers()).filter(server => !server.disabled && supported.has(server.name));
if (template) {
const servers = (await client.servers()).filter(server => !server.disabled && server.name !== 'fleet');
const availableServers = [...new Set(servers.map(server => server.name))];
const availableTools = {};
for (const name of availableServers) {
const observed = await client.serverTools(name);
if (observed?.error || !Array.isArray(observed?.tools)) throw new Error('Connected FLUJO tool inventory is unavailable.');
availableTools[name] = observed.tools.map(tool => tool?.name).filter(name => typeof name === 'string');
const names = observed.tools.map(tool => tool?.name);
if (names.some(tool => typeof tool !== 'string' || !tool.trim()) || new Set(names).size !== names.length)
throw new Error('Connected FLUJO tool inventory has invalid names.');
availableTools[name] = names;
}
specs = buildFactoryTeamSpecs({ ...teamTemplate, availableServers, availableTools });
specs = buildFactoryTeamSpecs({ ...template, availableServers, availableTools });
}
for (const spec of specs) {
const flow = await client.saveFlowSpec(spec);
Expand Down
Loading
Loading