Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/agent-forge/server/__tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
154 changes: 154 additions & 0 deletions apps/agent-forge/server/__tests__/deployPublished.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* X05: `POST /agents/:id/deploy` builds the PUBLISHED snapshot - the pinned
* version's spec written to `.lousho/agents/<id>/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<typeof import('node:child_process')>();
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<typeof createApp>;

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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
expect(fed['prompt']).toBe('Published instructions.');
});
});
141 changes: 86 additions & 55 deletions apps/agent-forge/server/__tests__/deployRunner.test.ts
Original file line number Diff line number Diff line change
@@ -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/<agentId>.v1.yaml` first.
* X05 unit coverage for deployRunner: `publishedSpecPath`/`writePublishedSpec`
* own `.lousho/agents/<id>/published.yaml`, and `runDeploy` spawns
* `lousho build --agent=<published snapshot>` 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<typeof import('node:child_process')>();
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<string, unknown>;
// 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<string, unknown>;
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);
});
});
18 changes: 13 additions & 5 deletions apps/agent-forge/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ import {
readAgentTrace,
traceDirExists,
} from './traceStore';
import { DEPLOY_ADAPTERS, isDeployAdapter, runDeploy } from './deployRunner';
import { DEPLOY_ADAPTERS, isDeployAdapter, runDeploy, writePublishedSpec } from './deployRunner';
import { checkApiAccess, checkHostAndOrigin, checkHostOnly, type StudioAccessOptions } from './auth';
import { registerObserveProxy } from './observeProxy';
import { CollectorManager } from './collector';
Expand Down Expand Up @@ -163,7 +163,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';
Expand Down Expand Up @@ -1003,18 +1003,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 (`<ws>/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);
})
);
Expand Down
Loading
Loading