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/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const E2E_STUDIO_TOKEN = 'e2e-studio-token';
const inheritedEnv = { ...process.env };
delete inheritedEnv.LOUSHO_OBSERVE;
const E2E_OBSERVE_URL = process.env.LOUSHO_STUDIO_E2E_OBSERVE_URL ?? 'http://127.0.0.1:1';
/** W03a: the Studio home is ~/.lousho/studio by default - the e2e server gets its own temp home so CI never touches a real one. */
const studioHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-forge-e2e-home-'));

export default defineConfig({
testDir: './e2e',
Expand Down Expand Up @@ -58,6 +60,7 @@ export default defineConfig({
LOUSHO_STUDIO_COLLECTOR: '0',
// No daemon here: keep the LLM-judge loop (server/judge.ts) from polling it.
LOUSHO_STUDIO_JUDGE: '0',
LOUSHO_STUDIO_HOME: studioHome,
// X08: the Agent Author runs a scripted mockModel (server/author/
// authorScript.ts) so forgeBuilder.spec.ts needs no provider key.
// The e2e flag keeps the hook live under the prod server's
Expand Down
2 changes: 1 addition & 1 deletion apps/agent-forge/server/__tests__/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,7 @@ describe('settings/secrets HTTP routes', () => {
loadSpec: (id) => repo.readSpec(id),
saveSpec: (id, spec) => repo.writeSpec(id, spec),
});
app = createApp({ agentStore, runManager, baseDir });
app = createApp({ agentStore, runManager, baseDir, studioHome: path.join(baseDir, '.lousho') });
});

afterEach(() => {
Expand Down
4 changes: 2 additions & 2 deletions apps/agent-forge/server/__tests__/authorSessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ describe('author session routes', () => {
loadSpec: (id) => repo.readSpec(id),
saveSpec: (id, spec) => repo.writeSpec(id, spec),
});
app = createApp({ agentStore, runManager, baseDir, staticDir, access: { token: TOKEN } });
app = createApp({ agentStore, runManager, baseDir, studioHome: path.join(baseDir, '.lousho'), staticDir, access: { token: TOKEN } });
});

afterEach(() => {
Expand Down Expand Up @@ -178,7 +178,7 @@ describe('author session routes', () => {
loadSpec: (id) => repo.readSpec(id),
saveSpec: (id, spec) => repo.writeSpec(id, spec),
});
const busyApp = createApp({ agentStore, runManager, baseDir, staticDir, access: { token: TOKEN }, sessions, authorRunner: runner });
const busyApp = createApp({ agentStore, runManager, baseDir, studioHome: path.join(baseDir, '.lousho'), staticDir, access: { token: TOKEN }, sessions, authorRunner: runner });

const created = await request(busyApp).post('/assistant/sessions').set(STUDIO_TOKEN_HEADER, TOKEN).send({});
expect(created.status).toBe(201);
Expand Down
2 changes: 1 addition & 1 deletion apps/agent-forge/server/__tests__/authorSettings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ describe('author settings routes', () => {
loadSpec: (id) => repo.readSpec(id),
saveSpec: (id, spec) => repo.writeSpec(id, spec),
});
app = createApp({ agentStore, runManager, baseDir, access: { token: TOKEN } });
app = createApp({ agentStore, runManager, baseDir, studioHome: path.join(baseDir, '.lousho'), access: { token: TOKEN } });
for (const key of ENV_KEYS) {
savedEnv[key] = process.env[key];
delete process.env[key];
Expand Down
1 change: 1 addition & 0 deletions apps/agent-forge/server/__tests__/authorStream.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ describe('WS /assistant/stream (startStudioServer)', () => {
judge: false,
staticDir: false,
token: TOKEN,
studioHome: home,
authorRunner: runner,
});
origin = `http://127.0.0.1:${handle.port}`;
Expand Down
141 changes: 141 additions & 0 deletions apps/agent-forge/server/__tests__/legacyImportRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import type { Express } from 'express';
import request from 'supertest';
import { createApp } from '../app';
import { STUDIO_TOKEN_HEADER } from '../auth';
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 { WorkspaceRegistry } from '../workspace/workspaceRegistry';
import { SecretsStore } from '../secretsStore';
import { SettingsStore } from '../settingsStore';
import { studioPathsFor } from '../workspace/studioPaths';
import type { LegacyImportResult, LegacyImportStatus } from '../../shared/wireTypes';

const TOKEN = 'w03a-routes-token-0123456789';

/**
* `GET/POST /workspaces/:ws/legacy-import` and `.../skip` (W03a): the
* one-time offer to copy a workspace's old project-level settings/secrets
* into the Studio home. `:ws` must be a registered workspace (404 like
* the rest of `/workspaces`), an empty offer 409s, and every route sits
* behind the studio token like the registry routes themselves.
*/
describe('/workspaces/:ws/legacy-import routes', () => {
let baseDir: string;
let studioHome: string;
let app: Express;
let registry: WorkspaceRegistry;
let wsId: string;
let wsRoot: string;

const authed = (req: request.Test) => req.set(STUDIO_TOKEN_HEADER, TOKEN);

function seedLegacyFiles(): void {
const settings = new SettingsStore(wsRoot);
settings.upsertProfile({
id: 'legacy-acme',
name: 'legacy-acme',
providerType: 'openai',
providerKeyRef: 'openai',
deployAdapter: 'node-server',
otelEnabled: false,
hookTimeoutMs: 5000,
sandboxBackend: 'noop',
});
const secrets = new SecretsStore(wsRoot);
secrets.setKey('openai', 'sk-old-key');
secrets.setSecret('GITHUB_TOKEN', 'ghp_old');
}

beforeEach(async () => {
baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-w03a-base-'));
studioHome = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-w03a-home-'));
wsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-w03a-ws-'));
const repo = new AgentRepo(baseDir);
const agentStore = createFsAgentStore(baseDir, repo);
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),
});
registry = new WorkspaceRegistry(path.join(studioHome, 'workspaces.json'));
await registry.ensure(baseDir);
wsId = (await registry.add(wsRoot)).id;
app = createApp({
agentStore,
runManager,
baseDir,
studioHome,
workspaceRegistry: registry,
access: { token: TOKEN },
});
});

afterEach(() => {
fs.rmSync(baseDir, { recursive: true, force: true });
fs.rmSync(studioHome, { recursive: true, force: true });
fs.rmSync(wsRoot, { recursive: true, force: true });
});

it('every route needs the studio token', async () => {
expect((await request(app).get(`/workspaces/${wsId}/legacy-import`)).status).toBe(401);
expect((await request(app).post(`/workspaces/${wsId}/legacy-import`)).status).toBe(401);
expect((await request(app).post(`/workspaces/${wsId}/legacy-import/skip`)).status).toBe(401);
});

it('404s an unregistered workspace on all three routes', async () => {
expect((await authed(request(app).get('/workspaces/ghost-abc123/legacy-import'))).status).toBe(404);
expect((await authed(request(app).post('/workspaces/ghost-abc123/legacy-import'))).status).toBe(404);
expect((await authed(request(app).post('/workspaces/ghost-abc123/legacy-import/skip'))).status).toBe(404);
});

it('reports found items, imports them once, then reports handled', async () => {
seedLegacyFiles();

const status = await authed(request(app).get(`/workspaces/${wsId}/legacy-import`));
expect(status.status).toBe(200);
expect(status.body as LegacyImportStatus).toMatchObject({
workspaceId: wsId,
found: ['settings', 'secrets'],
handled: null,
});

const imported = await authed(request(app).post(`/workspaces/${wsId}/legacy-import`));
expect(imported.status).toBe(200);
expect(imported.body as LegacyImportResult).toEqual({ profiles: 1, providerKeys: 1, namedSecrets: 1 });

// The values landed in the Studio home stores.
const paths = studioPathsFor(studioHome);
expect(new SettingsStore(paths).list().profiles.map((p) => p.id)).toContain('legacy-acme');
expect(new SecretsStore(paths).getKey('openai')).toBe('sk-old-key');

const after = await authed(request(app).get(`/workspaces/${wsId}/legacy-import`));
expect((after.body as LegacyImportStatus).handled).toBe('imported');
});

it('409s LOUSHO_LEGACY_IMPORT_EMPTY when the folder has nothing to offer', async () => {
const res = await authed(request(app).post(`/workspaces/${wsId}/legacy-import`));
expect(res.status).toBe(409);
expect(res.body).toMatchObject({ code: 'LOUSHO_LEGACY_IMPORT_EMPTY' });
});

it('skip marks the offer handled without copying', async () => {
seedLegacyFiles();
const res = await authed(request(app).post(`/workspaces/${wsId}/legacy-import/skip`));
expect(res.status).toBe(204);
const status = await authed(request(app).get(`/workspaces/${wsId}/legacy-import`));
expect((status.body as LegacyImportStatus).handled).toBe('skipped');
const paths = studioPathsFor(studioHome);
expect(new SecretsStore(paths).getKey('openai')).toBeUndefined();
});
});
11 changes: 11 additions & 0 deletions apps/agent-forge/server/__tests__/setupStudioHome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';

/**
* W03a moved the Studio-home default to `~/.lousho/studio`
* (`LOUSHO_STUDIO_HOME` overrides it). Server tests must never write a
* real user home, so every test file gets its own temp home; tests that
* need a specific one pass `studioHome`/set the env var themselves.
*/
process.env.LOUSHO_STUDIO_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'lousho-studio-home-'));
5 changes: 3 additions & 2 deletions apps/agent-forge/server/__tests__/steerGate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,9 @@ class GatedSteerProvider implements LLMProvider {
this.calls.push(options);
const block = this.blockCalls > 0;
if (block) this.blockCalls -= 1;
const mode = this.mode;
const { mode } = this;
const waitGate = this.waitGate.bind(this);
const text = this.text.bind(this);
const deltas = mode === 'after-delta' ? ['Working on it. ', 'All done.'] : ['All done.'];
const fullStream = (async function* (): AsyncGenerator<StreamChunk> {
let emitted = false;
Expand All @@ -98,7 +99,7 @@ class GatedSteerProvider implements LLMProvider {
textStream: (async function* () {
yield* deltas;
})(),
text: Promise.resolve(this.text()),
text: Promise.resolve(text()),
usage: Promise.resolve(USAGE),
finishReason: Promise.resolve('stop'),
toolCalls: Promise.resolve([]),
Expand Down
17 changes: 13 additions & 4 deletions apps/agent-forge/server/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,8 @@ import type { AbortRunRequest, ApproveRequest, ForkRunRequest, ForkRunResponse,
import { pinsProblem } from './pins';
import { chatTriggersFor, singleWorkspace, type WorkspaceContext, type WorkspaceResolver } from './workspace/context';
import { workspaceMiddleware, workspaceOf } from './workspace/middleware';
import { configureStudioHome, type StudioPaths } from './workspace/studioPaths';
import { configureStudioHome, defaultStudioHome, type StudioPaths } from './workspace/studioPaths';
import { LegacyImportService } from './workspace/legacyImport';
import { workspaceRefFor } from './workspace/workspaceId';
import { WorkspaceRegistry } from './workspace/workspaceRegistry';
import { CodeAgentCache } from './workspace/codeAgentScan';
Expand Down Expand Up @@ -187,8 +188,9 @@ interface CreateAppBaseOptions {
/**
* The Studio home directory every Studio-global file lives under
* (settings, secrets, Author sessions, … - see workspace/studioPaths.ts).
* Defaults to `<baseDir>/.lousho` today, which keeps every file exactly
* where it was; a later change moves the default to `~/.lousho/studio`.
* Defaults to `defaultStudioHome()` (`LOUSHO_STUDIO_HOME` or
* `~/.lousho/studio`); tests pass a temp dir so they never touch a
* real home.
*/
studioHome?: string;
/** Defaults to `new SecretsStore(studioPaths())` when omitted. */
Expand Down Expand Up @@ -1220,6 +1222,10 @@ function createStudioStores(options: CreateAppOptions, paths: StudioPaths) {
// The app's one code-agent scan cache (W08a): shared by the scan
// routes, `/workspace/agent-dirs` and the aggregate agent list.
const codeAgentCache = options.codeAgentCache ?? new CodeAgentCache();
// The one-time old-settings/secrets import (W03a): reads each
// workspace's project-level `.lousho/` files, copies what the home
// lacks, records the outcome in `paths.legacyImports`.
const legacyImport = new LegacyImportService({ file: paths.legacyImports, settings, secrets, home: paths });
// The app's one spend log and pass runner (passes/): Studio-global, so
// they live next to the other studioPaths() stores rather than per
// workspace. `passRunner` defaults to one over this `spendLog`.
Expand Down Expand Up @@ -1269,6 +1275,7 @@ function createStudioStores(options: CreateAppOptions, paths: StudioPaths) {
probeDeps,
collector,
codeAgentCache,
legacyImport,
};
}

Expand Down Expand Up @@ -1300,7 +1307,7 @@ export function createApp(options: CreateAppOptions): Express {
// The Studio home is process-wide and configured once, here, before any
// request is served; request handlers never call studioPaths() - the
// stores below close over the paths resolved now.
const paths = configureStudioHome(options.studioHome ?? path.join(baseDir, '.lousho'));
const paths = configureStudioHome(options.studioHome ?? defaultStudioHome());
const {
secrets,
settings,
Expand All @@ -1314,6 +1321,7 @@ export function createApp(options: CreateAppOptions): Express {
probeDeps,
collector,
codeAgentCache,
legacyImport,
} = createStudioStores(options, paths);
const workspace = defaultWorkspaceContext(options, passRunner, alerts, { settings, spendLog }, secrets);
// One resolver for the middleware below and the Author tools' per-call
Expand Down Expand Up @@ -1495,6 +1503,7 @@ export function createApp(options: CreateAppOptions): Express {
// plain resolver needs no `forget`.
forget: forgetWorkspace(options.workspaces),
scanCache: codeAgentCache,
legacyImport,
});
// W11c: `GET /workspaces/:ws/host` + `POST /workspaces/:ws/host/restart`
// - the `:ws` path param resolves through the same resolver the
Expand Down
Loading
Loading