From feecc51db16acfa1a950e78fec91520daaee51df Mon Sep 17 00:00:00 2001 From: flujo-app <300233937+flujo-app@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:53:31 -0500 Subject: [PATCH] Make generic native teams the default and expose template CRUD --- .github/workflows/public-sdk.yml | 29 +---- PUBLIC_SDK.md | 41 ++++++ bin/factory-mcp.mjs | 28 ++++- bin/factory-public.mjs | 12 +- scripts/verify-public-sdk.mjs | 64 ++++++++++ src/adapters/flujo-workspace.mjs | 21 ++-- src/flujo-swarm/template/factory-team.mjs | 67 ++++++---- src/public-sdk.mjs | 11 ++ src/template-catalog.mjs | 146 ++++++++++++++++++++++ test/factory-team-template.test.mjs | 25 ++++ test/flujo-workspace.test.mjs | 41 ++++++ test/public-sdk.test.mjs | 34 +++++ test/template-catalog.test.mjs | 73 +++++++++++ 13 files changed, 532 insertions(+), 60 deletions(-) create mode 100644 scripts/verify-public-sdk.mjs create mode 100644 src/template-catalog.mjs create mode 100644 test/template-catalog.test.mjs diff --git a/.github/workflows/public-sdk.yml b/.github/workflows/public-sdk.yml index c51f46f..cef3237 100644 --- a/.github/workflows/public-sdk.yml +++ b/.github/workflows/public-sdk.yml @@ -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 diff --git a/PUBLIC_SDK.md b/PUBLIC_SDK.md index 28675fc..c23d40b 100644 --- a/PUBLIC_SDK.md +++ b/PUBLIC_SDK.md @@ -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 diff --git a/bin/factory-mcp.mjs b/bin/factory-mcp.mjs index 3184cfb..3217880 100644 --- a/bin/factory-mcp.mjs +++ b/bin/factory-mcp.mjs @@ -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.'], @@ -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'] }, @@ -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), @@ -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]; diff --git a/bin/factory-public.mjs b/bin/factory-public.mjs index 47f27ca..7aceaaa 100644 --- a/bin/factory-public.mjs +++ b/bin/factory-public.mjs @@ -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 [JSON | @file | -]\n'; +const usage = 'Usage: factory [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; @@ -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) { diff --git a/scripts/verify-public-sdk.mjs b/scripts/verify-public-sdk.mjs new file mode 100644 index 0000000..59d10de --- /dev/null +++ b/scripts/verify-public-sdk.mjs @@ -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); diff --git a/src/adapters/flujo-workspace.mjs b/src/adapters/flujo-workspace.mjs index 4cb0aa5..2c408f4 100644 --- a/src/adapters/flujo-workspace.mjs +++ b/src/adapters/flujo-workspace.mjs @@ -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) @@ -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); diff --git a/src/flujo-swarm/template/factory-team.mjs b/src/flujo-swarm/template/factory-team.mjs index 91d4f07..74b478d 100644 --- a/src/flujo-swarm/template/factory-team.mjs +++ b/src/flujo-swarm/template/factory-team.mjs @@ -3,13 +3,6 @@ import { CASE_SPECIALISTS_V1, validateSpecialists, specialistAgentServers } from export { CASE_SPECIALISTS_V1 }; export const FACTORY_FLOW_NAMES = Object.freeze({ agent: 'swarm_agent', team: 'swarm_team' }); -const TOOLS = Object.freeze({ - filesystem: ['read_file', 'write_file', 'edit_file', 'list_dir', 'search', 'create_directory'], - bash: ['run', 'start', 'status', 'wait', 'kill', 'write_stdin', 'list_sessions'], - browser: ['browser_open', 'browser_navigate', 'browser_snapshot', 'browser_click', 'browser_type', 'browser_scroll', 'browser_close'], - flujo: ['kv_get', 'kv_set', 'list_flows', 'read_flow', 'list_conversations', 'read_conversation'], -}); - function bounded(value, fallback, maximum, label) { if (value === undefined) return fallback; if (!Number.isSafeInteger(value) || value < 1 || value > maximum) throw new TypeError(`${label} must be from 1 to ${maximum}`); @@ -17,20 +10,41 @@ function bounded(value, fallback, maximum, label) { } function selectedServers(available, inventory) { - return available.filter(name => Object.hasOwn(TOOLS, name)).map(name => ({ name, - tools: Array.isArray(inventory[name]) ? TOOLS[name].filter(tool => inventory[name].includes(tool)) : [], + return available.filter(name => name !== 'fleet').map(name => ({ name, + tools: Array.isArray(inventory[name]) ? [...inventory[name]] : [], })).filter(server => server.tools.length); } +function context(value, label) { + if (value === undefined) return ''; + if (typeof value !== 'string' || value.length > 4000) throw new TypeError(`${label} must be text under 4000 characters`); + return value.trim(); +} + +function environmentText(value) { + if (value === undefined) return ''; + if (!value || typeof value !== 'object' || Array.isArray(value) || Object.getPrototypeOf(value) !== Object.prototype + || Object.keys(value).length > 32 || Object.entries(value).some(([key, entry]) => + !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key) + || /(?:SECRET|TOKEN|PASSWORD|CREDENTIAL|PRIVATE_KEY|API_KEY|AUTH)/i.test(key) || typeof entry !== 'string' || entry.length > 1000)) { + throw new TypeError('environment must be a plain object of bounded text values without credential fields'); + } + return Object.entries(value).map(([key, entry]) => `${key}: ${entry}`).join('\n'); +} + /** FLUJO subflow team template. One lead plus up to nine local agents per workspace. */ export function buildFactoryTeamSpecs({ model, availableServers = [], availableTools = {}, - limits = {}, specialists } = {}) { + limits = {}, specialists, goalContext, environment } = {}) { if (typeof model !== 'string' || !model.trim() || !Array.isArray(availableServers) || new Set(availableServers).size !== availableServers.length || availableServers.some(name => typeof name !== 'string')) throw new TypeError('Model and connected server inventory are required'); if (!availableTools || typeof availableTools !== 'object' || Array.isArray(availableTools)) { throw new TypeError('Tool inventory must be an object'); } + for (const [name, tools] of Object.entries(availableTools)) { + if (tools !== undefined && (!Array.isArray(tools) || tools.some(tool => typeof tool !== 'string' || !tool.trim()) + || new Set(tools).size !== tools.length)) throw new TypeError(`Invalid observed tool names for ${name}`); + } const agentTurns = bounded(limits.agentTurns, 200, 200, 'agentTurns'); const leadTurns = bounded(limits.leadTurns, 600, 600, 'leadTurns'); const requested = bounded(limits.concurrency, 9, 9, 'concurrency'); @@ -40,25 +54,34 @@ export function buildFactoryTeamSpecs({ model, availableServers = [], availableT const concurrency = specialists ? Math.min(requested, specialists.topologyTarget.specialistSubflowsPerWorker) : requested; const roleGuide = specialists ? `\nAssign specialist briefs with CASE_ID, AGENT_ID, ROLE_ID, ANGLE, TASK and DONE_WHEN.\n` + specialists.roles.map(role => `${role.id}: ${role.mission} ${role.instructions} Evidence: ${role.evidence}`).join('\n') : ''; - const agentPrompt = `You are one agent in a FLUJO team. Work in the team's shared workspace. Follow the assigned angle and role.\n` + - `Use only tools attached to this flow. Record exact findings and file paths; do not claim a tool ran unless you observed its result.\n` + - `Ask the parent through subflow_send_message when blocked. Report what is verified and what remains unknown.${roleGuide}`; - const teamPrompt = `You lead one FLUJO team for a FACTORY task. You may start at most ${concurrency} local agent subflows.\n` + - `These agents share this workspace; give each a distinct file path and self-contained task.\n` + - `Split the goal into independent angles, wait for their actual results, compare evidence, and request an independent check.\n` + - `A queued or timed-out subflow is still unfinished. Preserve its conversation ID and continue waiting or report uncertainty.\n` + - `Do not claim another Worker, machine, board, or fleet tool exists in this workspace.${roleGuide}`; - const agent = { name: FACTORY_FLOW_NAMES.agent, description: 'One evidence-focused agent in a shared FLUJO workspace.', + const goal = context(goalContext, 'goalContext'); + const surroundings = environmentText(environment); + const shared = `${goal ? `\nGoal context: ${goal}` : ''}${surroundings ? `\nEnvironment:\n${surroundings}` : ''}`; + const principles = `Use the tools actually attached to this flow; never claim an action or result without its receipt. ` + + `Reuse relevant installed upstream code and contracts before making a small adapter or product change. ` + + `Deliver a bounded, useful product change where the task calls for one. Coordinate concisely on an available shared board and linked issue or PR. ` + + `An independent reviewer must assess another person's implementation before merge or deployment. ` + + `Keep meaningful checks for authentication, consent, idempotency and action receipts; avoid repeated evidence capture once checks pass. ` + + `Categorize the recent hour as implementation, integration, design, review, tests, docs, operations or blocked; when tests exceed development, return to a usable slice. ` + + `Hold a shared Git integration lease only during a write. Reconcile an uncertain push against the exact remote ref before retrying. ` + + `Never expose credentials or customer data. Preserve uncertain external outcomes before retrying.`; + const agentPrompt = `You are a general-purpose agent in a FLUJO team. Work in the team's shared workspace on your assigned task.${shared}\n` + + `${principles} Ask the parent through subflow_send_message when blocked. Report what is verified and unknown.${roleGuide}`; + const teamPrompt = `You lead a general-purpose FLUJO team for a FACTORY task. One native subflow gate admits at most ${concurrency} local agent runs. ` + + `Give each admitted agent a concrete task and acceptance condition; you can also implement or independently review work yourself.${shared}\n` + + `${principles} Wait on original subflow conversation IDs for real terminal results. A queued or timed-out subflow is unfinished. ` + + `Use only the workspace and communication tools actually attached; do not infer another Worker or machine exists.${roleGuide}`; + const agent = { name: FACTORY_FLOW_NAMES.agent, description: 'General-purpose agent in a shared FLUJO workspace.', nodes: [ { key: 'start', type: 'start', label: 'Start', prompt: agentPrompt }, - { key: 'agent', type: 'process', label: 'Agent', model, prompt: 'Complete the assigned task and report evidence.', + { key: 'agent', type: 'process', label: 'Agent', model, prompt: 'Complete the assigned task and report the observed result.', servers: selectedServers(agentServers, availableTools), maxTurns: agentTurns }, { key: 'finish', type: 'finish' }, ], edges: [{ from: 'start', to: 'agent' }, { from: 'agent', to: 'finish' }] }; - const team = { name: FACTORY_FLOW_NAMES.team, description: 'Team lead with a bounded local FLUJO subflow gate.', + const team = { name: FACTORY_FLOW_NAMES.team, description: 'General-purpose lead with a bounded native FLUJO subflow gate.', nodes: [ { key: 'start', type: 'start', label: 'Start', prompt: teamPrompt }, - { key: 'lead', type: 'process', label: 'Lead', model, prompt: 'Run the evidence and review cycle.', + { key: 'lead', type: 'process', label: 'Lead', model, prompt: 'Deliver and coordinate the admitted task.', servers: selectedServers(connected, availableTools), maxTurns: leadTurns }, { key: 'agents', type: 'subflow', label: 'agent', flow: FACTORY_FLOW_NAMES.agent, concurrencyLimit: concurrency, inputMode: 'isolated', diff --git a/src/public-sdk.mjs b/src/public-sdk.mjs index 7d9012d..541fb0d 100644 --- a/src/public-sdk.mjs +++ b/src/public-sdk.mjs @@ -1,6 +1,7 @@ import { resolve } from 'node:path'; import { FactoryControl } from './control.mjs'; import { startLiteWorker } from './lite-worker.mjs'; +import { FactoryTemplateCatalog } from './template-catalog.mjs'; /** A local, durable coordinator. No provider calls or worker processes are started. */ export class Factory { @@ -67,6 +68,15 @@ export class Factory { return this.#withControl(control => control.claimTask(taskId, agentId, ttlMs)); } + listTemplates() { return this.#withControl(control => new FactoryTemplateCatalog(control).list()); } + getTemplate(name = 'generic') { return this.#withControl(control => new FactoryTemplateCatalog(control).get(name)); } + createTemplate(input) { return this.#withControl(control => new FactoryTemplateCatalog(control).create(input)); } + updateTemplate(name, input) { return this.#withControl(control => new FactoryTemplateCatalog(control).update(name, input)); } + deleteTemplate(name, options) { return this.#withControl(control => new FactoryTemplateCatalog(control).delete(name, options)); } + buildTemplate(name = 'generic', options = {}) { + return this.#withControl(control => new FactoryTemplateCatalog(control).build(name, options)); + } + status() { return this.#withControl(control => control.status()); } pause() { return this.#withControl(control => control.pause()); } resume() { return this.#withControl(control => control.resume()); } @@ -74,6 +84,7 @@ export class Factory { } export { FactoryControl } from './control.mjs'; +export { FactoryTemplateCatalog } from './template-catalog.mjs'; export { SpendingLedger } from './spending.mjs'; export { FactorySwarmEngine, conversationEffectKey, conversationMessageKey, conversationCancelKey } from './swarm-engine.mjs'; export { FactoryLocalFleet, FactoryManagedFleet, managedFleetReservationId } from './local-fleet.mjs'; diff --git a/src/template-catalog.mjs b/src/template-catalog.mjs new file mode 100644 index 0000000..1d5e553 --- /dev/null +++ b/src/template-catalog.mjs @@ -0,0 +1,146 @@ +import { FactoryControl, FactoryError } from './control.mjs'; +import { buildFactoryTeamSpecs } from './flujo-swarm/template/factory-team.mjs'; + +const NAME = /^[a-z][a-z0-9_-]{0,63}$/; +const FIELDS = ['description', 'goalContext', 'environment', 'limits']; +const DEFAULT = Object.freeze({ name: 'generic', description: 'Generic FLUJO team', + goalContext: '', environment: {}, limits: {} }); + +function fail(code, message) { throw new FactoryError(code, message); } +function plain(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) + && Object.getPrototypeOf(value) === Object.prototype; } +function nameOf(value) { + if (typeof value !== 'string' || !NAME.test(value)) fail('TEMPLATE_INVALID', 'Invalid template name.'); + return value; +} +function text(value, max, label) { + if (typeof value !== 'string' || value.length > max) fail('TEMPLATE_INVALID', `${label} must be a bounded string.`); + return value; +} +function settings(input, base = DEFAULT) { + if (!plain(input) || Object.keys(input).some(key => !FIELDS.includes(key))) { + fail('TEMPLATE_INVALID', 'Only template description, goalContext, environment and limits are supported.'); + } + const description = text(Object.hasOwn(input, 'description') ? input.description : base.description, 240, 'description'); + const goalContext = text(Object.hasOwn(input, 'goalContext') ? input.goalContext : base.goalContext, 4000, 'goalContext'); + const environment = Object.hasOwn(input, 'environment') ? input.environment : base.environment; + if (!plain(environment) || Object.keys(environment).length > 32 + || Object.entries(environment).some(([key, value]) => !/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(key) + || /(?:SECRET|TOKEN|PASSWORD|CREDENTIAL|PRIVATE_KEY|API_KEY|AUTH)/i.test(key) + || typeof value !== 'string' || value.length > 1000)) { + fail('TEMPLATE_INVALID', 'Environment must be a bounded plain string map without credential fields.'); + } + const limits = Object.hasOwn(input, 'limits') ? input.limits : base.limits; + if (!plain(limits) || Object.keys(limits).some(key => !['agentTurns', 'leadTurns', 'concurrency'].includes(key))) { + fail('TEMPLATE_INVALID', 'Only team turn and concurrency limits are supported.'); + } + // The native builder is the single authority for bound validation. + buildFactoryTeamSpecs({ model: 'catalog-validation', limits, goalContext, environment }); + return { description, goalContext, environment: { ...environment }, limits: { ...limits } }; +} + +/** Durable local template definitions. No Flow, Machine or provider action occurs here. */ +export class FactoryTemplateCatalog { + constructor(control) { + if (!(control instanceof FactoryControl)) throw new TypeError('FactoryControl is required'); + this.control = control; + } + transact(operation) { + return this.control.transaction(() => { + const version = this.control.db.prepare('PRAGMA user_version').get().user_version; + if (![1, 2].includes(version)) fail('TEMPLATE_SCHEMA', 'Template catalog requires a standard FactoryControl database.'); + this.control.db.exec(`CREATE TABLE IF NOT EXISTS factory_templates( + name TEXT PRIMARY KEY, revision INTEGER NOT NULL CHECK(revision > 0), + deleted INTEGER NOT NULL CHECK(deleted IN (0,1)), definition TEXT NOT NULL + )`); + return operation(); + }); + } + row(name) { return this.control.db.prepare('SELECT * FROM factory_templates WHERE name=?').get(name); } + value(row) { + if (!row || row.deleted) return null; + return { name: row.name, ...JSON.parse(row.definition), revision: row.revision, source: 'stored' }; + } + get(name = 'generic') { + nameOf(name); + return this.transact(() => this.value(this.row(name)) + ?? (name === 'generic' ? { ...DEFAULT, environment: {}, limits: {}, + revision: this.row(name)?.revision ?? 0, source: 'builtin' } : null)); + } + list() { + return this.transact(() => { + const rows = this.control.db.prepare('SELECT * FROM factory_templates WHERE deleted=0 ORDER BY name').all(); + const values = rows.map(row => this.value(row)); + if (!values.some(row => row.name === 'generic')) { + values.push({ ...DEFAULT, environment: {}, limits: {}, + revision: this.row('generic')?.revision ?? 0, source: 'builtin' }); + } + return values.sort((a, b) => a.name.localeCompare(b.name)); + }); + } + create(input) { + if (!plain(input)) fail('TEMPLATE_INVALID', 'Template input is required.'); + const { name, ...fields } = input; + nameOf(name); + if (name === 'generic') fail('TEMPLATE_CONFLICT', 'Update the built-in generic template to override it.'); + const definition = settings(fields); + return this.transact(() => { + const row = this.row(name); + if (row && !row.deleted) fail('TEMPLATE_CONFLICT', 'Template already exists.'); + const revision = (row?.revision ?? 0) + 1; + this.control.db.prepare(`INSERT INTO factory_templates(name,revision,deleted,definition) VALUES(?,?,0,?) + ON CONFLICT(name) DO UPDATE SET revision=excluded.revision,deleted=0,definition=excluded.definition`) + .run(name, revision, JSON.stringify(definition)); + return this.value(this.row(name)); + }); + } + update(name, input) { + nameOf(name); + if (!plain(input) || !Number.isSafeInteger(input.expectedRevision) || input.expectedRevision < 0) { + fail('TEMPLATE_INVALID', 'Expected revision is required.'); + } + const { expectedRevision, ...fields } = input; + return this.transact(() => { + const row = this.row(name); + const current = this.value(row) ?? (name === 'generic' ? { ...DEFAULT } : null); + if (!current || row?.deleted && name !== 'generic') fail('TEMPLATE_MISSING', 'Template does not exist.'); + if ((row?.revision ?? 0) !== expectedRevision) fail('TEMPLATE_STALE', 'Template revision changed.'); + const definition = settings(fields, current); + const revision = expectedRevision + 1; + this.control.db.prepare(`INSERT INTO factory_templates(name,revision,deleted,definition) VALUES(?,?,0,?) + ON CONFLICT(name) DO UPDATE SET revision=excluded.revision,deleted=0,definition=excluded.definition`) + .run(name, revision, JSON.stringify(definition)); + return this.value(this.row(name)); + }); + } + delete(name, { expectedRevision } = {}) { + nameOf(name); + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) { + fail('TEMPLATE_INVALID', 'Expected revision is required.'); + } + return this.transact(() => { + const row = this.row(name); + if (!row || row.deleted) fail('TEMPLATE_MISSING', 'Template does not exist.'); + if (row.revision !== expectedRevision) fail('TEMPLATE_STALE', 'Template revision changed.'); + this.control.db.prepare('UPDATE factory_templates SET revision=?,deleted=1 WHERE name=?') + .run(expectedRevision + 1, name); + return name === 'generic' + ? { ...DEFAULT, environment: {}, limits: {}, revision: expectedRevision + 1, source: 'builtin' } + : { name, deleted: true, revision: expectedRevision + 1 }; + }); + } + build(name = 'generic', options = {}) { + const record = this.get(name); + if (!record) fail('TEMPLATE_MISSING', 'Template does not exist.'); + if (!plain(options) || Object.keys(options).some(key => !['model', 'availableServers', 'availableTools', 'limits'].includes(key))) { + fail('TEMPLATE_INVALID', 'Model, server/tool inventory and optional limits are required.'); + } + if (options.limits !== undefined && (!plain(options.limits) + || Object.keys(options.limits).some(key => !['agentTurns', 'leadTurns', 'concurrency'].includes(key)))) { + fail('TEMPLATE_INVALID', 'Only team turn and concurrency limits are supported.'); + } + return buildFactoryTeamSpecs({ model: options.model, availableServers: options.availableServers, + availableTools: options.availableTools, limits: { ...record.limits, ...options.limits }, + goalContext: record.goalContext, environment: record.environment }); + } +} diff --git a/test/factory-team-template.test.mjs b/test/factory-team-template.test.mjs index fb45ce1..b74bebd 100644 --- a/test/factory-team-template.test.mjs +++ b/test/factory-team-template.test.mjs @@ -21,3 +21,28 @@ test('missing required specialist tools and invalid concurrency refuse before in assert.throws(() => buildFactoryTeamSpecs({ model: 'installed-model', availableServers: [], limits: { concurrency: 10 } }), /concurrency/); }); + +test('generic team keeps observed connected tools and one aggregate native gate', () => { + const inventory = { + filesystem: ['read_file', 'write_file', 'search'], + bash: ['run', 'start'], + browser: ['browser_open', 'browser_snapshot', 'browser_new_capability'], + flujo: ['read_flow', 'update_flow', 'create_flow', 'future_authoring_tool'], + github: ['get_issue', 'create_pull_request'], + }; + const [agent, team] = buildFactoryTeamSpecs({ model: 'installed-model', + availableServers: Object.keys(inventory), availableTools: inventory, + limits: { concurrency: 3 }, goalContext: 'Ship one usable product change.', + environment: { repo: '/workspace/repo', board: 'https://example.test/issues/1' } }); + for (const process of [agent.nodes.find(node => node.key === 'agent'), team.nodes.find(node => node.key === 'lead')]) { + assert.deepEqual(process.servers, Object.entries(inventory).map(([name, tools]) => ({ name, tools }))); + } + assert.equal(team.nodes.filter(node => node.type === 'subflow').length, 1); + assert.equal(team.nodes.find(node => node.type === 'subflow').concurrencyLimit, 3); + assert.match(team.nodes[0].prompt, /usable product change/); + assert.match(agent.nodes[0].prompt, /workspace\/repo/); + assert.throws(() => buildFactoryTeamSpecs({ model: 'installed-model', + environment: { token: 42 } }), /environment/); + assert.throws(() => buildFactoryTeamSpecs({ model: 'installed-model', + availableTools: { github: ['get_issue', 'get_issue'] } }), /Invalid observed tool names/); +}); diff --git a/test/flujo-workspace.test.mjs b/test/flujo-workspace.test.mjs index d6c4710..904556e 100644 --- a/test/flujo-workspace.test.mjs +++ b/test/flujo-workspace.test.mjs @@ -116,6 +116,37 @@ test('workspace adapter installs a paired agent and team flow before readiness', await assert.rejects(adapter.provision({ app: 'worker-three', flowSpecs: [{ name: 'same' }, { name: 'same' }] }), TypeError); }); +test('model-only provisioning installs a generic observed-tool team', async () => { + const installed = []; + const adapter = createFlujoWorkspaceAdapter({ origin: 'http://127.0.0.1:4200', clientFactory: settings => ({ + workspace: settings.workspace, async workspaces() { return []; }, async ensureWorkspace() {}, + async upsertModel(model) { installed.push(`model:${model.id}`); }, + async servers() { return ['bash', 'browser', 'filesystem', 'flujo', 'github', 'fleet'].map(name => ({ name, disabled: false })); }, + async serverTools(name) { return { tools: [{ name: `observed_${name}` }] }; }, + async saveFlowSpec(spec) { installed.push(spec); return { id: spec.name, name: spec.name }; }, + }) }); + assert.equal((await adapter.provision({ app: 'worker-generic', modelConfig: { id: 'installed-model' }, + goalContext: 'Deliver a working feature.', environment: { repo: '/work/repo' } })).state, 'ready'); + assert.equal(installed[0], 'model:installed-model'); + assert.deepEqual(installed.slice(1).map(spec => spec.name), ['swarm_agent', 'swarm_team']); + assert.deepEqual(installed[1].nodes.find(node => node.key === 'agent').servers, + ['bash', 'browser', 'filesystem', 'flujo', 'github'].map(name => ({ name, tools: [`observed_${name}`] }))); + assert.match(installed[2].nodes[0].prompt, /working feature/); +}); + +test('invalid generic template and explicit null specs do not create a workspace', async () => { + let created = 0; + const adapter = createFlujoWorkspaceAdapter({ origin: 'http://127.0.0.1:4200', clientFactory: settings => ({ + workspace: settings.workspace, async workspaces() { return []; }, + async ensureWorkspace() { created++; }, + }) }); + await assert.rejects(adapter.provision({ app: 'worker-generic', modelConfig: { id: 'installed-model' }, + environment: { secret: 42 } }), /environment/); + await assert.rejects(adapter.provision({ app: 'worker-generic', modelConfig: { id: 'installed-model' }, + flowSpecs: null }), /Distinct flow specs/); + assert.equal(created, 0); +}); + test('workspace template uses observed tools and rejects an unavailable required server', async () => { const installed = []; const adapter = createFlujoWorkspaceAdapter({ origin: 'http://127.0.0.1:4200', clientFactory: settings => ({ @@ -170,3 +201,13 @@ test('copied FLUJO client sends workspace and fixed conversation identity over H assert.equal(submission.header, 'swarm-worker-http'); assert.equal(submission.body.metadata.conversationId, 'conversation-http'); }); + +test('default template rejects credential fields before workspace mutation', async () => { + let created=0; + const adapter=createFlujoWorkspaceAdapter({origin:'http://127.0.0.1:4200',clientFactory:settings=>({ + workspace:settings.workspace,async workspaces(){return [];},async ensureWorkspace(){created++;} + })}); + await assert.rejects(adapter.provision({app:'secret-worker',modelConfig:{id:'installed-model'}, + environment:{API_KEY:'synthetic-secret'}}),/credential fields/); + assert.equal(created,0); +}); diff --git a/test/public-sdk.test.mjs b/test/public-sdk.test.mjs index b38f17b..6efa2cf 100644 --- a/test/public-sdk.test.mjs +++ b/test/public-sdk.test.mjs @@ -84,6 +84,22 @@ test('public MCP stdio server creates and reads a local swarm', async t => { assert.equal(created.isError, undefined); const status = await client.callTool({ name: 'factory_status', arguments: {} }); assert.equal(JSON.parse(status.content[0].text).cells.length, 2); + const templates = await client.callTool({name:'factory_template_list',arguments:{}}); + assert.equal(templates.isError, undefined); + assert.ok(JSON.parse(templates.content[0].text).templates.some(template => template.name === 'generic')); + const custom = await client.callTool({name:'factory_template_create',arguments:{name:'product',goalContext:'Ship a usable intake'}}); + assert.equal(custom.isError, undefined); + const record = JSON.parse(custom.content[0].text); + const changed = await client.callTool({name:'factory_template_update',arguments:{name:'product',expectedRevision:record.revision,description:'Reusable product team'}}); + assert.equal(changed.isError, undefined); + const stale = await client.callTool({name:'factory_template_delete',arguments:{name:'product',expectedRevision:record.revision}}); + assert.equal(stale.isError, true); + const built = await client.callTool({name:'factory_template_build',arguments:{name:'product',model:'installed-model'}}); + assert.equal(built.isError, undefined); + assert.equal(JSON.parse(built.content[0].text).flowSpecs.length, 2); + const removed = await client.callTool({name:'factory_template_delete',arguments:{name:'product',expectedRevision:JSON.parse(changed.content[0].text).revision}}); + assert.equal(removed.isError, undefined); + }); test('invalid swarm allocation fails before creating a database', async t => { @@ -159,3 +175,21 @@ test('CLI reads a JSON file without shell quoting', async t => { assert.equal(result.status, 0, result.stderr); assert.equal(JSON.parse(result.stdout).cells.length, 2); }); + +test('CLI manages templates using JSON files before swarm initialization', async t => { + const dir = await mkdtemp(join(tmpdir(), 'factory-template-cli-')); + t.after(() => rm(dir, {recursive:true,force:true})); + const database = join(dir,'templates.sqlite'); + function run(command,input) { + const result=spawnSync(process.execPath,[cli,command,database,...(input ? [JSON.stringify(input)] : [])],{encoding:'utf8'}); + assert.equal(result.status,0,result.stderr); + return JSON.parse(result.stdout); + } + assert.ok(run('template-list').some(record=>record.name==='generic')); + const created=run('template-create',{name:'cli-product',goalContext:'Build intake'}); + assert.equal(run('template-read',{name:'cli-product'}).revision,created.revision); + assert.equal(run('template-build',{name:'cli-product',model:'installed-model'}).length,2); + const changed=run('template-update',{name:'cli-product',expectedRevision:created.revision,description:'Updated'}); + run('template-delete',{name:'cli-product',expectedRevision:changed.revision}); + assert.equal(run('template-list').some(record=>record.name==='cli-product'),false); +}); diff --git a/test/template-catalog.test.mjs b/test/template-catalog.test.mjs new file mode 100644 index 0000000..c2ace36 --- /dev/null +++ b/test/template-catalog.test.mjs @@ -0,0 +1,73 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Factory } from '../src/public-sdk.mjs'; + +function fixture(t) { + const dir = mkdtempSync(join(tmpdir(), 'factory-templates-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const database = join(dir, 'factory.sqlite'); + return () => new Factory(database); +} + +test('generic template persists an override and deleting it restores the built-in definition', t => { + const factory = fixture(t); + assert.deepEqual(factory().listTemplates().map(row => row.name), ['generic']); + assert.equal(factory().getTemplate().source, 'builtin'); + assert.throws(() => factory().createTemplate({ name: 'generic' }), { code: 'TEMPLATE_CONFLICT' }); + const override = factory().updateTemplate('generic', { expectedRevision: 0, + goalContext: 'Ship an accessible account timeline', environment: { product: 'Calma' }, + limits: { concurrency: 3 } }); + assert.equal(override.revision, 1); + assert.equal(factory().getTemplate('generic').goalContext, 'Ship an accessible account timeline'); + assert.equal(factory().getTemplate('generic').source, 'stored'); + const reset = factory().deleteTemplate('generic', { expectedRevision: 1 }); + assert.equal(reset.source, 'builtin'); + assert.equal(factory().getTemplate('generic').revision, 2); + assert.equal(factory().getTemplate('generic').goalContext, ''); + assert.throws(() => factory().updateTemplate('generic', { expectedRevision: 0, + goalContext: 'Stale writer' }), { code: 'TEMPLATE_STALE' }); + assert.equal(factory().updateTemplate('generic', { expectedRevision: 2, + goalContext: 'New goal' }).revision, 3); +}); + +test('custom templates retain revision history across delete and recreate', t => { + const factory = fixture(t); + const first = factory().createTemplate({ name: 'delivery', description: 'Delivery team', + goalContext: 'Build one usable slice', environment: { channel: 'synthetic' }, + limits: { leadTurns: 100 } }); + assert.equal(first.revision, 1); + assert.equal(factory().getTemplate('delivery').environment.channel, 'synthetic'); + assert.throws(() => factory().createTemplate({ name: 'delivery' }), { code: 'TEMPLATE_CONFLICT' }); + assert.equal(factory().updateTemplate('delivery', { expectedRevision: 1, + limits: { concurrency: 2 } }).revision, 2); + assert.throws(() => factory().deleteTemplate('delivery', { expectedRevision: 1 }), { code: 'TEMPLATE_STALE' }); + assert.equal(factory().deleteTemplate('delivery', { expectedRevision: 2 }).revision, 3); + assert.equal(factory().getTemplate('delivery'), null); + assert.equal(factory().createTemplate({ name: 'delivery', goalContext: 'Next slice' }).revision, 4); + assert.throws(() => factory().updateTemplate('delivery', { expectedRevision: 2, + goalContext: 'Old process' }), { code: 'TEMPLATE_STALE' }); +}); + +test('build resolves stored context into native team specs without runtime action', t => { + const factory = fixture(t); + factory().createTemplate({ name: 'research', goalContext: 'Investigate only the synthetic ledger', + environment: { repository: 'fixture' }, limits: { concurrency: 2 } }); + const specs = factory().buildTemplate('research', { model: 'installed-model', availableServers: [], availableTools: {} }); + assert.deepEqual(specs.map(row => row.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 ledger/); + assert.throws(() => factory().buildTemplate('missing', { model: 'installed-model' }), { code: 'TEMPLATE_MISSING' }); + assert.throws(() => factory().createTemplate({ name: 'secrets', environment: { API_KEY: 'never-store' } }), + { code: 'TEMPLATE_INVALID' }); + assert.throws(() => factory().createTemplate({ name: 'oversized', environment: { note: 'x'.repeat(1001) } }), + { code: 'TEMPLATE_INVALID' }); + for (const field of ['description', 'goalContext', 'environment', 'limits']) { + assert.throws(() => factory().createTemplate({ name: 'null-field', [field]: null }), + { code: 'TEMPLATE_INVALID' }); + } + assert.throws(() => factory().buildTemplate('research', { model: 'installed-model', limits: null }), + { code: 'TEMPLATE_INVALID' }); +});