From ed6ab123c93eae4de26597adcc6363093cfb0716 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Fri, 13 Nov 2026 05:36:10 +0300 Subject: [PATCH 1/3] feat(studio): X05 deploy builds the published version POST /agents/:id/deploy now reads the pinned version through readPublishedSpec, writes its snapshot to .lousho/agents//published.yaml and passes that file to `lousho build --agent=` - the working copy is never what gets built. Unpublished agents answer 409 LOUSHO_NOT_PUBLISHED before anything spawns; DeployResult carries the built `version`. publishAgent rewrites published.yaml whenever the pointer moves (best effort - a write failure logs, never fails the publish), so `lousho mcp .lousho/agents//published.yaml` sees the same snapshot deploy does. The file holds the spec's v1 projection (toLegacySpec): both `lousho build` and `lousho mcp` still load spec files through the v1 loadSpec, which rejects version: 2 documents. SettingsPanel's Deploy card disables the button with "Publish a version first" while unpublished, labels it `Deploy v` when published, headlines the result with the version, and toasts the server message on a 409 race. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../server/__tests__/deployPublished.test.ts | 154 ++++++++++++++++++ .../server/__tests__/deployRunner.test.ts | 141 +++++++++------- apps/agent-forge/server/app.ts | 18 +- apps/agent-forge/server/deployRunner.ts | 75 +++++---- .../lifecycle/__tests__/publish.test.ts | 25 ++- apps/agent-forge/server/lifecycle/publish.ts | 28 +++- apps/agent-forge/shared/wireTypes.ts | 2 + .../src/components/SettingsPanel.tsx | 39 +++-- .../__tests__/SettingsPanel.test.tsx | 65 +++++++- 9 files changed, 436 insertions(+), 111 deletions(-) create mode 100644 apps/agent-forge/server/__tests__/deployPublished.test.ts diff --git a/apps/agent-forge/server/__tests__/deployPublished.test.ts b/apps/agent-forge/server/__tests__/deployPublished.test.ts new file mode 100644 index 000000000..bf70ec4bd --- /dev/null +++ b/apps/agent-forge/server/__tests__/deployPublished.test.ts @@ -0,0 +1,154 @@ +/** + * X05: `POST /agents/:id/deploy` builds the PUBLISHED snapshot - the pinned + * version's spec written to `.lousho/agents//published.yaml` - never the + * working copy. `node:child_process`'s `spawn` is mocked so no real + * `lousho build` ever runs; the mock still asserts the `--agent=` file + * exists at spawn time, like a real build would require. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import type { ChildProcess } from 'node:child_process'; +import { parse as parseYaml } from 'yaml'; +import request from 'supertest'; +import type { AgentSpecV2 } from 'lousho'; +import { createApp } from '../app'; +import { RunManager } from '../runRegistry'; +import { newTestHost } from './testHost'; +import { FileCheckpointStore } from '../checkpointStore'; +import { FileApprovalStore } from '../approvalStore'; +import { createFsAgentStore } from '../../src/persistence/fsAgentStore'; +import { AgentRepo } from '../storage/agentRepo'; +import { SecretsStore } from '../secretsStore'; +import { studioPathsFor } from '../workspace/studioPaths'; +import { publishedSpecPath } from '../deployRunner'; +import type { DeployResult } from '../../shared/wireTypes'; + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, spawn: vi.fn() }; +}); +import { spawn } from 'node:child_process'; + +const PUBLISHED_SPEC: AgentSpecV2 = { + version: 2, + name: 'ops', + model: 'mock/mock-1', + instructions: 'Published instructions.', +}; +const WORKING_SPEC: AgentSpecV2 = { ...PUBLISHED_SPEC, instructions: 'Unpublished working-copy instructions.' }; + +/** The argv of the one child process `runDeploy` spawned. */ +function spawnedArgs(): string[] { + const calls = vi.mocked(spawn).mock.calls; + expect(calls).toHaveLength(1); + return calls[0][1] as string[]; +} + +describe('POST /agents/:id/deploy (published version)', () => { + let baseDir: string; + let studioHome: string; + let repo: AgentRepo; + let app: () => ReturnType; + + beforeEach(async () => { + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-x05-deploy-')); + studioHome = path.join(baseDir, '.lousho'); + repo = new AgentRepo(baseDir); + const agentStore = createFsAgentStore(baseDir); + const runManager = new RunManager({ + baseDir, + checkpointStore: new FileCheckpointStore(baseDir), + approvalStore: new FileApprovalStore(baseDir), + host: newTestHost(baseDir), + loadSpec: (id) => repo.readSpec(id), + saveSpec: (id, spec) => repo.writeSpec(id, spec), + }); + app = () => + createApp({ + agentStore, + runManager, + baseDir, + studioHome, + secretsStore: new SecretsStore(studioPathsFor(studioHome)), + }); + await repo.writeSpec('ops', PUBLISHED_SPEC); + vi.mocked(spawn).mockImplementation((_command, args) => { + const child = new EventEmitter() as ChildProcess; + child.stdout = new EventEmitter() as ChildProcess['stdout']; + child.stderr = new EventEmitter() as ChildProcess['stderr']; + process.nextTick(() => child.emit('close', 0)); + // The published.yaml the build reads must exist at spawn time. + const agentArg = (args as string[]).find((a) => a.startsWith('--agent=')); + expect(agentArg).toBeDefined(); + expect(fs.existsSync(agentArg!.slice('--agent='.length))).toBe(true); + return child; + }); + }); + + afterEach(() => { + fs.rmSync(baseDir, { recursive: true, force: true }); + vi.mocked(spawn).mockReset(); + }); + + const post = (id: string, body: unknown) => request(app()).post(`/agents/${id}/deploy`).send(body as object); + + it('answers 409 LOUSHO_NOT_PUBLISHED and spawns nothing for an unpublished agent', async () => { + const res = await post('ops', { adapter: 'node-server' }); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ error: 'Publish a version before deploying', code: 'LOUSHO_NOT_PUBLISHED' }); + expect(vi.mocked(spawn)).not.toHaveBeenCalled(); + }); + + it('answers 404 for an unknown agent and 400 for a bad adapter', async () => { + const ghost = await post('ghost', { adapter: 'node-server' }); + expect(ghost.status).toBe(404); + expect(ghost.body.error).toBe("No agent 'ghost'"); + + const bad = await post('ops', { adapter: 'carrier-pigeon' }); + expect(bad.status).toBe(400); + expect(vi.mocked(spawn)).not.toHaveBeenCalled(); + }); + + it('builds the published snapshot, not the drifted working copy, and reports the version', async () => { + // Publish v1 (PUBLISHED_SPEC), then drift the working copy. + const publish = await request(app()).post('/agents/ops/publish').send({}); + expect(publish.status).toBe(200); + await repo.writeSpec('ops', WORKING_SPEC); + + const res = await post('ops', { adapter: 'node-server' }); + expect(res.status).toBe(200); + const result = res.body as DeployResult; + expect(result.version).toBe(1); + expect(result.exitCode).toBe(0); + + const agentArg = spawnedArgs().find((a) => a.startsWith('--agent='))!.slice('--agent='.length); + expect(agentArg).toBe(publishedSpecPath(baseDir, 'ops')); + // The file deploy reads is the published snapshot's v1 projection - + // PUBLISHED_SPEC's instructions, not the working copy's. + const fed = parseYaml(fs.readFileSync(agentArg, 'utf8')) as Record; + expect(fed).toMatchObject({ + name: 'ops', + prompt: 'Published instructions.', + provider: { type: 'mock', model: 'mock-1' }, + }); + expect(fed['version']).toBeUndefined(); + }); + + it('publishing v1 after v2 rewrites published.yaml to the older snapshot', async () => { + // v1 = PUBLISHED_SPEC (the beforeEach working copy), v2 = new draft. + await request(app()).post('/agents/ops/publish').send({}); + await repo.writeSpec('ops', { ...PUBLISHED_SPEC, instructions: 'Version two instructions.' }); + await request(app()).post('/agents/ops/versions').send({ source: 'editor' }); // v2 + await request(app()).post('/agents/ops/publish').send({ version: 2 }); + + let fed = parseYaml(fs.readFileSync(publishedSpecPath(baseDir, 'ops'), 'utf8')) as Record; + expect(fed['prompt']).toBe('Version two instructions.'); + + await request(app()).post('/agents/ops/publish').send({ version: 1 }); + fed = parseYaml(fs.readFileSync(publishedSpecPath(baseDir, 'ops'), 'utf8')) as Record; + expect(fed['prompt']).toBe('Published instructions.'); + }); +}); diff --git a/apps/agent-forge/server/__tests__/deployRunner.test.ts b/apps/agent-forge/server/__tests__/deployRunner.test.ts index 792804ff6..a73d921ca 100644 --- a/apps/agent-forge/server/__tests__/deployRunner.test.ts +++ b/apps/agent-forge/server/__tests__/deployRunner.test.ts @@ -1,91 +1,122 @@ /** - * `runDeploy` feeds `lousho build` a spec-v1 file, not the v2 working copy: - * the deploy adapters still parse `--agent=` with the v1 `loadSpec`, which - * rejects a v2 file outright, so runDeploy writes the stored v2 spec through - * `toLegacySpec` into a derived `.lousho/build/.v1.yaml` first. + * X05 unit coverage for deployRunner: `publishedSpecPath`/`writePublishedSpec` + * own `.lousho/agents//published.yaml`, and `runDeploy` spawns + * `lousho build --agent=` with an injectable `spawn`. + * The snapshot file holds the spec's v1 projection (`toLegacySpec`) because + * `lousho build`/`lousho mcp` still load spec files through the v1 + * `loadSpec`, which rejects `version: 2` documents. */ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { EventEmitter } from 'node:events'; import * as fs from 'node:fs'; -import * as path from 'node:path'; import * as os from 'node:os'; +import * as path from 'node:path'; import type { ChildProcess } from 'node:child_process'; import { parse as parseYaml } from 'yaml'; -import type { AgentSpec } from 'lousho'; - -vi.mock('node:child_process', async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, spawn: vi.fn() }; -}); -import { spawn } from 'node:child_process'; -import { runDeploy } from '../deployRunner'; -import { AgentRepo } from '../storage/agentRepo'; +import { publishedSpecPath, runDeploy, writePublishedSpec, DEPLOY_ADAPTERS, isDeployAdapter } from '../deployRunner'; import { mergeLegacySave } from '../storage/legacySpec'; +import type { AgentSpec } from 'lousho'; -const SPEC: AgentSpec = { +const V1_SPEC: AgentSpec = { name: 'deploy-me', prompt: 'You are a deployable agent.', provider: { type: 'mock', model: 'mock-1' }, tools: ['web-fetch'], }; -/** The argv `runDeploy` passed to spawn on its one child process. */ -function spawnedArgs(): string[] { - const calls = vi.mocked(spawn).mock.calls; - expect(calls).toHaveLength(1); - return calls[0][1] as string[]; +/** A spawn double that closes successfully and records its argv. */ +function fakeSpawn(calls: string[][]) { + return ((_command: string, args: string[]) => { + calls.push(args); + const child = new EventEmitter() as ChildProcess; + child.stdout = new EventEmitter() as ChildProcess['stdout']; + child.stderr = new EventEmitter() as ChildProcess['stderr']; + process.nextTick(() => child.emit('close', 0)); + return child; + }) as unknown as typeof import('node:child_process').spawn; } -describe('runDeploy', () => { +describe('publishedSpecPath / writePublishedSpec', () => { let baseDir: string; beforeEach(() => { - baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-deploy-test-')); - vi.mocked(spawn).mockImplementation((_command, args) => { - const child = new EventEmitter() as ChildProcess; - child.stdout = new EventEmitter() as ChildProcess['stdout']; - child.stderr = new EventEmitter() as ChildProcess['stderr']; - process.nextTick(() => child.emit('close', 0)); - // The temp file the facade writes must still exist at spawn time. - const agentArg = (args as string[]).find((a) => a.startsWith('--agent=')); - expect(agentArg).toBeDefined(); - return child; - }); + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-x05-runner-')); }); afterEach(() => { fs.rmSync(baseDir, { recursive: true, force: true }); - vi.mocked(spawn).mockReset(); }); - it('hands `lousho build` a v1-shaped spec after a v1 facade save stored v2', async () => { - const repo = new AgentRepo(baseDir); - await repo.writeSpec('deploy-me', mergeLegacySave(undefined, SPEC)); - // Sanity: what is on disk really is v2 now. - expect(parseYaml(fs.readFileSync(path.join(baseDir, '.lousho', 'agents', 'deploy-me.yaml'), 'utf8'))).toMatchObject({ - version: 2, - }); - - const result = await runDeploy(baseDir, 'deploy-me', 'node-server'); - expect(result.exitCode).toBe(0); - - const agentArg = spawnedArgs().find((a) => a.startsWith('--agent='))!.slice('--agent='.length); - expect(agentArg).not.toBe(path.join(baseDir, '.lousho', 'agents', 'deploy-me.yaml')); - const fed = parseYaml(fs.readFileSync(agentArg, 'utf8')) as Record; - // v1 shape: prompt/provider keys, no version: 2, v1 tool aliases. + it('writes the published snapshot inside the agent sidecar folder as v1 YAML', async () => { + const spec = mergeLegacySave(undefined, V1_SPEC); + const filePath = await writePublishedSpec(baseDir, 'deploy-me', spec); + expect(filePath).toBe(publishedSpecPath(baseDir, 'deploy-me')); + expect(filePath).toBe(path.join(baseDir, '.lousho', 'agents', 'deploy-me', 'published.yaml')); + const fed = parseYaml(fs.readFileSync(filePath, 'utf8')) as Record; expect(fed).toMatchObject({ name: 'deploy-me', prompt: 'You are a deployable agent.', provider: { type: 'mock', model: 'mock-1' }, - tools: ['web-fetch'], }); expect(fed['version']).toBeUndefined(); + // The sidecar folder keeps published.yaml out of AgentRepo.list()'s scan. + expect(fs.existsSync(path.join(baseDir, '.lousho', 'agents', 'deploy-me.yaml'))).toBe(false); }); +}); + +describe('runDeploy', () => { + let baseDir: string; + let calls: string[][]; + + beforeEach(() => { + baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-x05-runner-')); + calls = []; + }); + + afterEach(() => { + fs.rmSync(baseDir, { recursive: true, force: true }); + }); + + it('spawns `lousho build` against the published snapshot and reports its version', async () => { + const specPath = path.join(baseDir, '.lousho', 'agents', 'ops', 'published.yaml'); + const result = await runDeploy(baseDir, 'ops', 'node-server', { version: 3, specPath }, { spawn: fakeSpawn(calls) }); + expect(result.exitCode).toBe(0); + expect(result.version).toBe(3); + expect(calls).toHaveLength(1); + const args = calls[0]; + // argv[0] is the lousho.js entry; the build flags follow it. + expect(args.slice(1)).toEqual(['build', '--target=node-server', `--agent=${specPath}`, `--out=${path.join(baseDir, '.lousho', 'build', 'node-server', 'ops')}`]); + expect(result.command).toContain(`--agent=${specPath}`); + }); + + it('resolves a non-zero exit as a failed DeployResult, still carrying the version', async () => { + const spawn = (() => { + const child = new EventEmitter() as ChildProcess; + const stderr = new EventEmitter(); + child.stdout = new EventEmitter() as ChildProcess['stdout']; + child.stderr = stderr as unknown as ChildProcess['stderr']; + process.nextTick(() => { + stderr.emit('data', Buffer.from('build exploded')); + child.emit('close', 2); + }); + return child; + }) as unknown as typeof import('node:child_process').spawn; + const result = await runDeploy( + baseDir, + 'ops', + 'docker', + { version: 7, specPath: path.join(baseDir, 'published.yaml') }, + { spawn } + ); + expect(result).toMatchObject({ exitCode: 2, version: 7 }); + expect(result.stderr).toContain('build exploded'); + }); +}); - it('reports a failing result without spawning when the agent was never saved', async () => { - const result = await runDeploy(baseDir, 'ghost', 'node-server'); - expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('ghost'); - expect(vi.mocked(spawn)).not.toHaveBeenCalled(); +describe('deploy adapter names', () => { + it('knows the three built-in adapters', () => { + expect(DEPLOY_ADAPTERS).toEqual(['node-server', 'cloudflare-worker', 'docker']); + expect(isDeployAdapter('node-server')).toBe(true); + expect(isDeployAdapter('carrier-pigeon')).toBe(false); }); }); diff --git a/apps/agent-forge/server/app.ts b/apps/agent-forge/server/app.ts index 94ce5d62b..c043bf868 100644 --- a/apps/agent-forge/server/app.ts +++ b/apps/agent-forge/server/app.ts @@ -119,7 +119,7 @@ import { readAgentTrace, traceDirExists, } from './traceStore'; -import { DEPLOY_ADAPTERS, isDeployAdapter, runDeploy } from './deployRunner'; +import { DEPLOY_ADAPTERS, isDeployAdapter, runDeploy, writePublishedSpec } from './deployRunner'; import { checkApiAccess, checkHostAndOrigin, type StudioAccessOptions } from './auth'; import { registerObserveProxy } from './observeProxy'; import { CollectorManager } from './collector'; @@ -157,7 +157,7 @@ import { registerSuggestionRoutes } from './routes/suggestionRoutes'; import { registerExportRoutes } from './lifecycle/export'; import { registerImportRoutes } from './lifecycle/import'; import { registerPortabilityRoutes } from './passes/portabilityRoutes'; -import { registerPublishRoutes } from './lifecycle/publish'; +import { readPublishedSpec, registerPublishRoutes } from './lifecycle/publish'; import { PassRunner } from './passes/passRunner'; import { SpendLog } from './passes/spendLog'; import { AlertStore } from './inbox/alertStore'; @@ -971,18 +971,26 @@ function registerDeployRoutes(app: Express): void { return; } const ws = workspaceOf(req); - const saved = await ws.agentStore.load(paramId(req)); + const id = paramId(req); + const saved = await ws.agentStore.load(id); if (!saved) { - res.status(404).json({ error: `No saved agent '${paramId(req)}' to deploy - save it first` }); + res.status(404).json({ error: `No agent '${id}'` }); return; } + // X05: deploy builds the PUBLISHED snapshot, never the working copy. + const published = await readPublishedSpec(id, { repo: ws.repo, versions: ws.versions }); + if (published === undefined) { + sendError(res, 409, 'Publish a version before deploying', 'LOUSHO_NOT_PUBLISHED'); + return; + } + const specPath = await writePublishedSpec(ws.ref.root, id, published.spec); // W11c: a bridge workspace builds with the project's own SDK bin; // in-process/read-only keep Studio's (`/bin/lousho.js` - the // launch workspace IS the SDK checkout there, and read-only never // reaches this line: the /agents write gate refused it). const selection = ws.hostSelector.current(); const cliEntry = selection.mode === 'bridge' && selection.sdk !== undefined ? selection.sdk.binPath : undefined; - const result = await runDeploy(ws.ref.root, paramId(req), adapter, cliEntry); + const result = await runDeploy(ws.ref.root, id, adapter, { version: published.version, specPath }, {}, cliEntry); res.status(result.exitCode === 0 ? 200 : 422).json(result); }) ); diff --git a/apps/agent-forge/server/deployRunner.ts b/apps/agent-forge/server/deployRunner.ts index 649769b1c..a1652039f 100644 --- a/apps/agent-forge/server/deployRunner.ts +++ b/apps/agent-forge/server/deployRunner.ts @@ -1,7 +1,7 @@ /** * "Deploy this agent" action - actually shells out to the existing * `lousho build` CLI command (src/cli/build.ts, invoked via `bin/lousho.js`) - * against the currently-selected agent's saved `AgentSpec` file, rather than + * against the agent's PUBLISHED spec snapshot (X05), rather than * reimplementing scaffold/build/describe against the `DeploymentAdapter` * registry a second time in this server. * @@ -13,8 +13,8 @@ import { spawn } from 'node:child_process'; import * as path from 'node:path'; import { stringify as stringifyYaml } from 'yaml'; +import type { AgentSpecV2 } from 'lousho'; import type { DeployResult } from '../shared/wireTypes'; -import { AgentRepo } from './storage/agentRepo'; import { writeFileAtomic } from './storage/atomic'; import { toLegacySpec } from './storage/legacySpec'; @@ -39,53 +39,64 @@ export function isDeployAdapter(value: string): value is DeployAdapter { } /** - * Runs `node /bin/lousho.js build --target= --agent= --out=/.lousho/build//` - * and resolves once it exits (never rejects - a failing build is a normal, - * displayable outcome, not an exceptional one, matching `runBuild()`'s own - * "never throws" contract in src/cli/build.ts). - * - * Requires the SDK to have been built (`npm run build` at the repo root, so - * `dist/cli/build.js` exists) - same prerequisite the `lousho` CLI itself - * has outside this dev environment. + * Path of the published snapshot file deploy and `lousho mcp` use: inside + * the agent's sidecar folder `.lousho/agents//`, so `AgentRepo.list()` + * never mistakes it for an agent spec. */ +export function publishedSpecPath(baseDir: string, agentId: string): string { + return path.join(baseDir, '.lousho', 'agents', agentId, 'published.yaml'); +} + /** - * The v1 spec file `lousho build` gets: the working copy on disk is spec v2, - * which the deploy adapters' v1 `loadSpec` rejects outright, so the deploy - * projects it through the same `toLegacySpec` facade the GET routes use and - * writes a derived `.lousho/build/.v1.yaml` it owns - `lousho - * build`/`lousho dev` only speak v1 until they get their own v2 loader. + * Writes the published snapshot atomically and returns its path. + * + * The file holds the spec's v1 projection (`toLegacySpec`), not the raw v2 + * document: both consumers - `lousho build --agent=` and `lousho mcp` - + * still load spec files through the v1 `loadSpec`, which rejects a + * `version: 2` document outright. This is the same projection the deploy + * path always used; only its source changed (the published snapshot rather + * than the working copy). Secret refs stay refs - nothing is resolved here. */ -async function writeLegacyDeploySpec(baseDir: string, agentId: string): Promise { - const spec = await new AgentRepo(baseDir).readSpec(agentId); - if (spec === undefined) return undefined; - const filePath = path.join(baseDir, '.lousho', 'build', `${agentId}.v1.yaml`); +export async function writePublishedSpec(baseDir: string, agentId: string, spec: AgentSpecV2): Promise { + const filePath = publishedSpecPath(baseDir, agentId); await writeFileAtomic(filePath, stringifyYaml(toLegacySpec(spec))); return filePath; } +/** Test seam: `spawn` is injectable so tests never launch a real build. */ +export interface DeployDeps { + spawn?: typeof spawn; +} + +/** + * Runs `node /bin/lousho.js build --target= --agent= --out=/.lousho/build//` + * and resolves once it exits (never rejects - a failing build is a normal, + * displayable outcome, not an exceptional one, matching `runBuild()`'s own + * "never throws" contract in src/cli/build.ts). The result carries the + * published `version` that was built. + * + * Requires the SDK to have been built (`npm run build` at the repo root, so + * `dist/cli/build.js` exists) - same prerequisite the `lousho` CLI itself + * has outside this dev environment. + */ export async function runDeploy( baseDir: string, agentId: string, adapter: string, + published: { version: number; specPath: string }, + deps: DeployDeps = {}, // W11c: a bridge workspace deploys through ITS SDK's bin (the project's // installed `lousho`), not `/bin/lousho.js` - the route passes // `hostSelector.current().sdk.binPath` on bridge mode. cliEntry: string = path.join(baseDir, 'bin', 'lousho.js') ): Promise { + const spawnFn = deps.spawn ?? spawn; const outDir = path.join(baseDir, '.lousho', 'build', adapter, agentId); - const agentPath = await writeLegacyDeploySpec(baseDir, agentId); - const args = ['build', `--target=${adapter}`, `--agent=${agentPath}`, `--out=${outDir}`]; - if (agentPath === undefined) { - return { - exitCode: 1, - stdout: '', - stderr: `No saved agent '${agentId}' to deploy - save it first`, - command: `node ${cliEntry} ${args.join(' ')}`, - }; - } + const args = ['build', `--target=${adapter}`, `--agent=${published.specPath}`, `--out=${outDir}`]; + const command = `node ${cliEntry} ${args.join(' ')}`; return new Promise((resolve) => { - const child = spawn(process.execPath, [cliEntry, ...args], { cwd: baseDir }); + const child = spawnFn(process.execPath, [cliEntry, ...args], { cwd: baseDir }); let stdout = ''; let stderr = ''; child.stdout.on('data', (chunk) => { @@ -95,10 +106,10 @@ export async function runDeploy( stderr += chunk.toString(); }); child.on('error', (error) => { - resolve({ exitCode: 1, stdout, stderr: `${stderr}\n${error.message}`, command: `node ${cliEntry} ${args.join(' ')}` }); + resolve({ exitCode: 1, stdout, stderr: `${stderr}\n${error.message}`, command, version: published.version }); }); child.on('close', (code) => { - resolve({ exitCode: code ?? 1, stdout, stderr, command: `node ${cliEntry} ${args.join(' ')}` }); + resolve({ exitCode: code ?? 1, stdout, stderr, command, version: published.version }); }); }); } diff --git a/apps/agent-forge/server/lifecycle/__tests__/publish.test.ts b/apps/agent-forge/server/lifecycle/__tests__/publish.test.ts index d35f2faaf..2dd8ea380 100644 --- a/apps/agent-forge/server/lifecycle/__tests__/publish.test.ts +++ b/apps/agent-forge/server/lifecycle/__tests__/publish.test.ts @@ -8,7 +8,9 @@ import { StudioEventBus } from '../../events/eventBus'; import { AgentRepo } from '../../storage/agentRepo'; import { specHash } from '../../storage/specHash'; import { VersionStore } from '../../storage/versionStore'; +import { parse as parseYaml } from 'yaml'; import { PublishError, publishAgent, publishStateOf, readPublishedSpec } from '../publish'; +import { publishedSpecPath } from '../../deployRunner'; const AGENT = 'ops'; const SPEC_A: AgentSpecV2 = { version: 2, name: 'ops', model: 'mock/mock-1', instructions: 'Check the queues.' }; @@ -29,7 +31,7 @@ describe('publishAgent', () => { let versions: VersionStore; let events: StudioEventBus; let published: StudioEvent[]; - let deps: { repo: AgentRepo; versions: VersionStore; events: StudioEventBus }; + let deps: { repo: AgentRepo; versions: VersionStore; events: StudioEventBus; baseDir: string }; beforeEach(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-l14a-publish-')); @@ -38,7 +40,7 @@ describe('publishAgent', () => { events = new StudioEventBus(); published = []; events.subscribe((event) => published.push(event)); - deps = { repo, versions, events }; + deps = { repo, versions, events, baseDir: dir }; }); afterEach(() => { @@ -85,6 +87,23 @@ describe('publishAgent', () => { expect(metaEvents()).toHaveLength(2); }); + it('writes published.yaml for the pinned snapshot and rewrites it when the pointer moves back', async () => { + // The v1 projection drops v2-only fields, so the drafts differ in + // `instructions` (which projects to the v1 `prompt`). + const first: AgentSpecV2 = { version: 2, name: 'ops', model: 'mock/mock-1', instructions: 'First published.' }; + const second: AgentSpecV2 = { ...first, instructions: 'Second published.' }; + const snapshotPath = publishedSpecPath(dir, AGENT); + await seedVersions(repo, versions, [first, second]); + + await publishAgent(AGENT, { version: 2 }, deps); + let fed = parseYaml(fs.readFileSync(snapshotPath, 'utf8')) as Record; + expect(fed).toMatchObject({ name: 'ops', prompt: 'Second published.' }); + + await publishAgent(AGENT, { version: 1 }, deps); + fed = parseYaml(fs.readFileSync(snapshotPath, 'utf8')) as Record; + expect(fed).toMatchObject({ name: 'ops', prompt: 'First published.' }); + }); + it('keeps concurrent publishes consistent', async () => { await seedVersions(repo, versions, [SPEC_A, SPEC_B]); await repo.writeSpec(AGENT, SPEC_C); @@ -128,7 +147,7 @@ describe('readPublishedSpec', () => { it('returns the pinned snapshot, not the drifted working copy', async () => { await seedVersions(repo, versions, [SPEC_A, SPEC_B]); - await publishAgent(AGENT, { version: 1 }, { repo, versions, events: new StudioEventBus() }); + await publishAgent(AGENT, { version: 1 }, { repo, versions, events: new StudioEventBus(), baseDir: dir }); await repo.writeSpec(AGENT, SPEC_C); const published = await readPublishedSpec(AGENT, { repo, versions }); const v1 = await versions.get(AGENT, 1); diff --git a/apps/agent-forge/server/lifecycle/publish.ts b/apps/agent-forge/server/lifecycle/publish.ts index bb25c3e6f..dc12160e4 100644 --- a/apps/agent-forge/server/lifecycle/publish.ts +++ b/apps/agent-forge/server/lifecycle/publish.ts @@ -20,15 +20,18 @@ import type { StudioEventBus } from '../events/eventBus'; import { asyncRoute, paramId, sendError } from '../http/routeUtils'; import type { AgentRepo } from '../storage/agentRepo'; import { withAgentLock } from '../storage/atomic'; +import { writePublishedSpec } from '../deployRunner'; import { specHash } from '../storage/specHash'; import type { VersionStore } from '../storage/versionStore'; import { workspaceOf } from '../workspace/middleware'; -/** What `publishAgent`/`readPublishedSpec` need: the workspace's repo, versions and event bus. */ +/** What `publishAgent`/`readPublishedSpec` need: the workspace's repo, versions, event bus and root. */ export interface PublishDeps { repo: AgentRepo; versions: VersionStore; events: StudioEventBus; + /** Workspace root - `published.yaml` lands in `/.lousho/agents//` (X05). */ + baseDir: string; } /** A publish failure with its HTTP status and wire `code`. */ @@ -81,13 +84,29 @@ export async function publishAgent( target = written; } } - if (target === meta.publishedVersion) return { publishedVersion: target }; - await deps.repo.writeMeta(id, { ...meta, publishedVersion: target, updatedAt: new Date().toISOString() }); - deps.events.publish({ type: 'agent.changed', agentId: id, reason: 'meta' }); + if (target !== meta.publishedVersion) { + await deps.repo.writeMeta(id, { ...meta, publishedVersion: target, updatedAt: new Date().toISOString() }); + deps.events.publish({ type: 'agent.changed', agentId: id, reason: 'meta' }); + } + await writePublishedSnapshot(id, deps); return { publishedVersion: target }; }); } +/** + * X05: keep `.lousho/agents//published.yaml` - the file `lousho build` + * and `lousho mcp` read - in step with the pointer. Best effort: a write + * failure logs and never fails the publish. + */ +async function writePublishedSnapshot(id: string, deps: PublishDeps): Promise { + try { + const published = await readPublishedSpec(id, deps); + if (published !== undefined) await writePublishedSpec(deps.baseDir, id, published.spec); + } catch (error) { + console.warn(`[agent-forge] published.yaml write for '${id}' failed:`, error); + } +} + /** * The published version's snapshot spec, or `undefined` when nothing is * published (no pointer, or the pinned record is gone). Serve/MCP/deploy and @@ -148,6 +167,7 @@ export function registerPublishRoutes(app: Express): void { repo: ws.repo, versions: ws.versions, events: ws.events, + baseDir: ws.ref.root, }); res.json(result); } catch (error) { diff --git a/apps/agent-forge/shared/wireTypes.ts b/apps/agent-forge/shared/wireTypes.ts index 023f27848..861c1b33a 100644 --- a/apps/agent-forge/shared/wireTypes.ts +++ b/apps/agent-forge/shared/wireTypes.ts @@ -533,6 +533,8 @@ export interface DeployResult { stdout: string; stderr: string; command: string; + /** The published version `lousho build` ran against (X05). */ + version: number; } /** diff --git a/apps/agent-forge/src/components/SettingsPanel.tsx b/apps/agent-forge/src/components/SettingsPanel.tsx index e7232f22b..3b3c15066 100644 --- a/apps/agent-forge/src/components/SettingsPanel.tsx +++ b/apps/agent-forge/src/components/SettingsPanel.tsx @@ -4,6 +4,7 @@ import { useAppState } from '../state/AppState'; import { runtimeClient } from '../runtime/runtimeClient'; import type { DeployResult, ProviderKeyStatus, SettingsFile, SettingsProfile } from '../../shared/wireTypes'; import { errorMessage } from './errorMessage'; +import { useToast } from '../ui/Toast'; import { AgentAuthorSection, AppearanceSection, ShortcutsSection } from './SettingsAppSections'; import { CollectorCard } from './settings/CollectorCard'; import { PassesSection } from './PassesSection'; @@ -106,10 +107,13 @@ function useSettingsProfiles(setDeployAdapter: (adapter: string) => void, deploy } function useDeploy(deployAdapter: string) { - const { agentId } = useAppState(); + const { agentId, agents } = useAppState(); + const toast = useToast(); const [deployAdapters, setDeployAdapters] = useState([]); const [deploying, setDeploying] = useState(false); const [deployResult, setDeployResult] = useState(undefined); + // X05: deploy builds the published version - `null` until one is pinned. + const publishedVersion = agents?.find((a) => a.id === agentId)?.publishedVersion ?? null; useEffect(() => { runtimeClient @@ -124,12 +128,15 @@ function useDeploy(deployAdapter: string) { try { const result = await runtimeClient.deployAgent(agentId, deployAdapter); setDeployResult(result); + } catch (error) { + // A 409 (published pointer moved between render and click) lands here. + toast.show({ message: errorMessage(error), tone: 'error' }); } finally { setDeploying(false); } } - return { deployAdapters, deploying, deployResult, deploy }; + return { deployAdapters, deploying, deployResult, deploy, publishedVersion }; } interface ProviderKeyFieldProps { @@ -346,12 +353,15 @@ function ProfileSection({ profiles }: ProfileSectionProps) { ); } -function DeployResultView({ result }: { result: DeployResult }) { +function DeployResultView({ result, adapter }: { result: DeployResult; adapter: string }) { + const headline = + result.exitCode === 0 + ? `Deployed v${result.version} to ${adapter}` + : `Build of v${result.version} failed (exit ${result.exitCode})`; return (
-
- {result.exitCode === 0 ? 'Build succeeded' : `Build failed (exit ${result.exitCode})`} — {result.command} -
+
{headline}
+
{result.command}
         {result.stdout}
         {result.stderr}
@@ -366,9 +376,10 @@ interface DeploySectionProps {
 }
 
 function DeploySection({ deployAdapter, onAdapterChange }: DeploySectionProps) {
-  const { deployAdapters, deploying, deployResult, deploy } = useDeploy(deployAdapter);
+  const { deployAdapters, deploying, deployResult, deploy, publishedVersion } = useDeploy(deployAdapter);
+  const unpublished = publishedVersion === null;
   return (
-    
+    
       
- - {deployResult && } + {unpublished &&
Deploy builds the published version. Publish one from the Versions tab.
} + {deployResult && }
); } diff --git a/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx b/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx index bf114286d..3d592649c 100644 --- a/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx +++ b/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx @@ -8,15 +8,30 @@ import { toggleTheme } from '../../shell/theme'; import type { ProviderKeyStatus } from '../../../shared/wireTypes'; const keys = vi.hoisted(() => ({ list: [] as ProviderKeyStatus[] })); +const deploy = vi.hoisted(() => ({ + agents: [] as Array<{ id: string; publishedVersion: number | null }>, + result: undefined as import('../../../shared/wireTypes').DeployResult | undefined, + error: undefined as Error | undefined, + calls: [] as Array<{ agentId: string; adapter: string }>, +})); +const toast = vi.hoisted(() => ({ show: vi.fn() })); vi.mock('../../state/AppState', () => ({ - useAppState: () => ({ agentId: 'a1', refreshActiveProfile: async () => undefined }), + useAppState: () => ({ agentId: 'a1', agents: deploy.agents, refreshActiveProfile: async () => undefined }), +})); +vi.mock('../../ui/Toast', () => ({ + useToast: () => ({ show: toast.show }), })); vi.mock('../../runtime/runtimeClient', () => ({ runtimeClient: { listProviderKeys: async () => keys.list, listSettingsProfiles: async () => ({ activeProfileId: 'local', profiles: [] }), listDeployAdapters: async () => ['node-server'], + deployAgent: async (agentId: string, adapter: string) => { + deploy.calls.push({ agentId, adapter }); + if (deploy.error) throw deploy.error; + return deploy.result; + }, getAuthorSettings: async () => ({ model: 'anthropic/claude-sonnet-5-5', reviewModel: 'anthropic/claude-haiku-5-5', @@ -75,6 +90,11 @@ const button = (scope: Element, text: string) => beforeEach(() => { keys.list = []; + deploy.agents = [{ id: 'a1', publishedVersion: null }]; + deploy.result = undefined; + deploy.error = undefined; + deploy.calls = []; + toast.show.mockClear(); installMemoryStorage(); }); afterEach(() => { @@ -157,6 +177,49 @@ describe('SettingsPanel', () => { expect(host.textContent).toContain("Tables use each screen's default until you pick one."); }); + it('disables Deploy with guidance while the agent has no published version', async () => { + const host = await mount(); + const deployButton = button(host, 'Deploy this agent'); + expect(deployButton.disabled).toBe(true); + expect(deployButton.title).toBe('Publish a version first'); + expect(host.textContent).toContain('Deploy builds the published version. Publish one from the Versions tab.'); + }); + + it('labels the button Deploy v, runs the build and shows the published version in the result', async () => { + deploy.agents = [{ id: 'a1', publishedVersion: 2 }]; + deploy.result = { exitCode: 0, stdout: 'ok\n', stderr: '', command: 'node bin/lousho.js build', version: 2 }; + const host = await mount(); + const deployButton = button(host, 'Deploy v2'); + expect(deployButton.disabled).toBe(false); + await act(async () => { + deployButton.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(deploy.calls).toEqual([{ agentId: 'a1', adapter: 'node-server' }]); + expect(host.textContent).toContain('Deployed v2 to node-server'); + }); + + it('shows the failed-build headline and toasts the server message on a deploy error', async () => { + deploy.agents = [{ id: 'a1', publishedVersion: 3 }]; + deploy.result = { exitCode: 1, stdout: '', stderr: 'boom', command: 'node bin/lousho.js build', version: 3 }; + const host = await mount(); + await act(async () => { + button(host, 'Deploy v3').click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(host.textContent).toContain('Build of v3 failed (exit 1)'); + + deploy.error = new Error('Publish a version before deploying'); + await act(async () => { + button(host, 'Deploy v3').click(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(toast.show).toHaveBeenCalledWith({ message: 'Publish a version before deploying', tone: 'error' }); + }); + it('the collector card reports an unreachable daemon and lists every shortcut', async () => { const host = await mount('screen'); const collector = host.querySelector('section[aria-label="Collector"]')!; From 27962ce96cdfceb867add04162277ea119b86e16 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Fri, 13 Nov 2026 07:15:49 +0300 Subject: [PATCH 2/3] test(studio): publish before deploy in the app.test.ts deploy case X05 made deploy build the published snapshot, so the route now answers 409 LOUSHO_NOT_PUBLISHED for an unpublished agent; the test PUT the spec but never published. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/agent-forge/server/__tests__/app.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/agent-forge/server/__tests__/app.test.ts b/apps/agent-forge/server/__tests__/app.test.ts index 12189da38..bad7d3349 100644 --- a/apps/agent-forge/server/__tests__/app.test.ts +++ b/apps/agent-forge/server/__tests__/app.test.ts @@ -441,6 +441,9 @@ describe('settings/secrets HTTP routes', () => { it('POST /agents/:id/deploy shells out to `lousho build` and reports a non-zero exit when the SDK has not been built', async () => { await request(app).put('/agents/deploy-me-2').send(SPEC); + // X05: deploy builds the published snapshot, so publish first. + const publish = await request(app).post('/agents/deploy-me-2/publish').send({}); + expect(publish.status).toBe(200); const res = await request(app).post('/agents/deploy-me-2/deploy').send({ adapter: 'node-server' }); // This test environment may or may not have `dist/` built - either way // the route must resolve (never hang/throw) and report SOME exit code From fa14eb92217e91370c2264c3b029c3c42874ed66 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Sat, 14 Nov 2026 15:49:46 +0300 Subject: [PATCH 3/3] test(studio): add ToastProvider passthrough to SettingsPanel Toast mock Main's Toast module gained a ToastProvider export the X05 mock did not provide, breaking the forge suite's vi.mock contract. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx b/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx index 3d592649c..073163468 100644 --- a/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx +++ b/apps/agent-forge/src/components/__tests__/SettingsPanel.test.tsx @@ -21,6 +21,7 @@ vi.mock('../../state/AppState', () => ({ })); vi.mock('../../ui/Toast', () => ({ useToast: () => ({ show: toast.show }), + ToastProvider: ({ children }: { children: React.ReactNode }) => children, })); vi.mock('../../runtime/runtimeClient', () => ({ runtimeClient: {