diff --git a/apps/agent-forge/playwright.config.ts b/apps/agent-forge/playwright.config.ts index e3e0e1bd1..29fccc644 100644 --- a/apps/agent-forge/playwright.config.ts +++ b/apps/agent-forge/playwright.config.ts @@ -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', @@ -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 diff --git a/apps/agent-forge/server/__tests__/app.test.ts b/apps/agent-forge/server/__tests__/app.test.ts index 12189da38..375dba95f 100644 --- a/apps/agent-forge/server/__tests__/app.test.ts +++ b/apps/agent-forge/server/__tests__/app.test.ts @@ -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(() => { diff --git a/apps/agent-forge/server/__tests__/authorSessions.test.ts b/apps/agent-forge/server/__tests__/authorSessions.test.ts index 12bc1fa5e..b4dfab310 100644 --- a/apps/agent-forge/server/__tests__/authorSessions.test.ts +++ b/apps/agent-forge/server/__tests__/authorSessions.test.ts @@ -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(() => { @@ -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); diff --git a/apps/agent-forge/server/__tests__/authorSettings.test.ts b/apps/agent-forge/server/__tests__/authorSettings.test.ts index 78b4b2f65..867841132 100644 --- a/apps/agent-forge/server/__tests__/authorSettings.test.ts +++ b/apps/agent-forge/server/__tests__/authorSettings.test.ts @@ -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]; diff --git a/apps/agent-forge/server/__tests__/authorStream.test.ts b/apps/agent-forge/server/__tests__/authorStream.test.ts index 2e9b4cf36..ed741e9a4 100644 --- a/apps/agent-forge/server/__tests__/authorStream.test.ts +++ b/apps/agent-forge/server/__tests__/authorStream.test.ts @@ -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}`; diff --git a/apps/agent-forge/server/__tests__/legacyImportRoutes.test.ts b/apps/agent-forge/server/__tests__/legacyImportRoutes.test.ts new file mode 100644 index 000000000..dd4bc56cf --- /dev/null +++ b/apps/agent-forge/server/__tests__/legacyImportRoutes.test.ts @@ -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(); + }); +}); diff --git a/apps/agent-forge/server/__tests__/setupStudioHome.ts b/apps/agent-forge/server/__tests__/setupStudioHome.ts new file mode 100644 index 000000000..2c4470f29 --- /dev/null +++ b/apps/agent-forge/server/__tests__/setupStudioHome.ts @@ -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-')); diff --git a/apps/agent-forge/server/__tests__/steerGate.ts b/apps/agent-forge/server/__tests__/steerGate.ts index 0ecefe7ad..c2a34b6f9 100644 --- a/apps/agent-forge/server/__tests__/steerGate.ts +++ b/apps/agent-forge/server/__tests__/steerGate.ts @@ -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 { let emitted = false; @@ -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([]), diff --git a/apps/agent-forge/server/app.ts b/apps/agent-forge/server/app.ts index 29a74e653..9f45c3b54 100644 --- a/apps/agent-forge/server/app.ts +++ b/apps/agent-forge/server/app.ts @@ -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'; @@ -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 `/.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. */ @@ -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`. @@ -1269,6 +1275,7 @@ function createStudioStores(options: CreateAppOptions, paths: StudioPaths) { probeDeps, collector, codeAgentCache, + legacyImport, }; } @@ -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, @@ -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 @@ -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 diff --git a/apps/agent-forge/server/index.ts b/apps/agent-forge/server/index.ts index 1ece870cf..adb5b045a 100644 --- a/apps/agent-forge/server/index.ts +++ b/apps/agent-forge/server/index.ts @@ -20,9 +20,9 @@ import { startJudgeLoop, type JudgeLoopOptions } from './judge'; import { CollectorManager } from './collector'; import type { IdleVersioner } from './storage/idleVersioner'; import { STUDIO_ALLOWED_ORIGINS_ENV, STUDIO_TOKEN_ENV, allowedHostsFor, mintStudioToken } from './auth'; -import { createWorkspaceContext, type WorkspaceServices } from './workspace/context'; +import { createWorkspaceContext, type WorkspaceContext, type WorkspaceServices } from './workspace/context'; import { WorkspaceManager } from './workspace/workspaceManager'; -import { configureStudioHome, studioPaths } from './workspace/studioPaths'; +import { configureStudioHome, defaultStudioHome, studioPaths } from './workspace/studioPaths'; import { WorkspaceRegistry } from './workspace/workspaceRegistry'; import { AUTHOR_STREAM_PATH } from '../shared/authorWire'; import { AuthorSessionStore } from './author/sessionStore'; @@ -109,10 +109,11 @@ export interface StartStudioServerOptions { */ idleVersioner?: IdleVersioner; /** - * The Studio home directory (`configureStudioHome`'s argument - every - * Studio-global store derives its files from it). Defaults to - * `/.lousho`, where the launch folder's files already live; - * tests pass a temp dir so they never touch a real home (W02b). + * The Studio home directory every Studio-global file lives under + * (settings, secrets, Author sessions, the workspace registry, …). + * Defaults to `defaultStudioHome()` - `LOUSHO_STUDIO_HOME` when set, + * else `~/.lousho/studio`; tests pass a temp dir so they never touch a + * real home (W03a). */ studioHome?: string; /** @@ -154,6 +155,58 @@ function resolveDocsDir(moduleDir: string): string { return path.join(moduleDir, '..', '..', '..', 'docs'); } +/** + * The launch workspace's registry entry plus the warm launch context and + * the `WorkspaceManager` every other registered workspace resolves + * through - extracted from `startStudioServer` so the boot function stays + * a flat wiring list. + */ +async function buildWorkspaceStack( + options: StartStudioServerOptions, + paths: ReturnType, + deps: { + baseDir: string; + secretsStore: SecretsStore; + settingsStore: SettingsStore; + spendLog: SpendLog; + passRunner: PassRunner; + alerts: AlertStore; + collector: CollectorManager; + } +): Promise<{ registry: WorkspaceRegistry; workspace: WorkspaceContext; workspaces: WorkspaceManager }> { + const { baseDir, secretsStore, settingsStore, spendLog, passRunner, alerts, collector } = deps; + // The launch folder is added to the workspace registry on every start + // (`ensure` keeps one entry and bumps lastOpenedAt on a restart) and + // its stored ref is the launch context's identity. + const registry = new WorkspaceRegistry(paths.workspaces); + const launchRef = await registry.ensure(baseDir); + // The Studio-global stores every workspace context shares (W02a): the + // manager's `build` reuses this exact object, so a lazily-built context + // gets the same passRunner/spendLog/alerts/secrets/settings as launch. + const services: WorkspaceServices = { + secretsStore, + settingsStore, + observe: options.observe !== false, + passRunner, + spendLog, + alerts, + // W12: the one CollectorManager feeds run reporting here and bridge + // children through the host selector's env (context.ts adapts it). + collector, + studioVersion: VERSION, + }; + const workspace = createWorkspaceContext(launchRef, services); + // Every registered workspace resolves over HTTP through this manager: + // contexts build lazily on first use, stay warm up to the cap, and idle + // ones dispose - the launch context is always warm and never a + // candidate (workspace/workspaceManager.ts). + const workspaces = new WorkspaceManager({ registry, launch: workspace, build: (ref) => createWorkspaceContext(ref, services) }); + // A test-injected versioner replaces the context's default one before the + // app and `close()` below both read `workspace.idleVersioner`. + if (options.idleVersioner !== undefined) workspace.idleVersioner = options.idleVersioner; + return { registry, workspace, workspaces }; +} + // fallow-ignore-next-line complexity export async function startStudioServer( options: StartStudioServerOptions = {} @@ -164,10 +217,11 @@ export async function startStudioServer( const moduleDir = path.dirname(fileURLToPath(import.meta.url)); const staticDir = resolveStaticDir(options.staticDir, moduleDir); - // The Studio home keeps every file where it was (the launch folder's - // `.lousho/`); moving it to `~/.lousho/studio` is a later change of this - // one default - every store below already reads its paths from it. - configureStudioHome(options.studioHome ?? path.join(baseDir, '.lousho')); + // Global state lives in the Studio home (`LOUSHO_STUDIO_HOME` or + // `~/.lousho/studio`), shared across every workspace Studio opens; a + // workspace's old project-level `.lousho/` settings/secrets can be + // imported once through `POST /workspaces/:ws/legacy-import`. + configureStudioHome(options.studioHome ?? defaultStudioHome()); const paths = studioPaths(); const secretsStore = new SecretsStore(paths); const settingsStore = new SettingsStore(paths); @@ -184,11 +238,6 @@ export async function startStudioServer( ...(options.collector === false ? { autoStart: false } : {}), }); - // The launch folder is added to the workspace registry on every start - // (`ensure` keeps one entry and bumps lastOpenedAt on a restart) and - // its stored ref is the launch context's identity. - const registry = new WorkspaceRegistry(paths.workspaces); - const launchRef = await registry.ensure(baseDir); // The app's one pass runner + spend log (passes/): Studio-global, shared // by every workspace context through WorkspaceServices, and the same // instances the spend route and `close()` use. @@ -202,30 +251,15 @@ export async function startStudioServer( // writes `auto_disabled` alerts here per workspace; R02a's inbox routes // read the same instance off the app options. const alerts = new AlertStore(paths.alerts); - // The Studio-global stores every workspace context shares (W02a): the - // manager's `build` reuses this exact object, so a lazily-built context - // gets the same passRunner/spendLog/alerts/secrets/settings as launch. - const services: WorkspaceServices = { + const { registry, workspace, workspaces } = await buildWorkspaceStack(options, paths, { + baseDir, secretsStore, settingsStore, - observe: options.observe !== false, - passRunner, spendLog, + passRunner, alerts, - // W12: the one CollectorManager feeds run reporting here and bridge - // children through the host selector's env (context.ts adapts it). collector, - studioVersion: VERSION, - }; - const workspace = createWorkspaceContext(launchRef, services); - // Every registered workspace resolves over HTTP through this manager: - // contexts build lazily on first use, stay warm up to the cap, and idle - // ones dispose - the launch context is always warm and never a - // candidate (workspace/workspaceManager.ts). - const workspaces = new WorkspaceManager({ registry, launch: workspace, build: (ref) => createWorkspaceContext(ref, services) }); - // A test-injected versioner replaces the context's default one before the - // app and `close()` below both read `workspace.idleVersioner`. - if (options.idleVersioner !== undefined) workspace.idleVersioner = options.idleVersioner; + }); const token = options.token ?? (process.env[STUDIO_TOKEN_ENV] || mintStudioToken()); const allowedOrigins = (process.env[STUDIO_ALLOWED_ORIGINS_ENV] ?? '').split(',').filter(Boolean); diff --git a/apps/agent-forge/server/vitest.config.ts b/apps/agent-forge/server/vitest.config.ts index 52a7fc86d..497c614d5 100644 --- a/apps/agent-forge/server/vitest.config.ts +++ b/apps/agent-forge/server/vitest.config.ts @@ -13,5 +13,8 @@ export default defineConfig({ // collector daemon - `startStudioServer` probes only (collector.ts). LOUSHO_STUDIO_COLLECTOR: '0', }, + // W03a: the Studio home defaults to ~/.lousho/studio - point every + // server test file at its own temp home so no test writes a real one. + setupFiles: ['server/__tests__/setupStudioHome.ts'], }, }); diff --git a/apps/agent-forge/server/workspace/__tests__/legacyImport.test.ts b/apps/agent-forge/server/workspace/__tests__/legacyImport.test.ts new file mode 100644 index 000000000..f24c2e293 --- /dev/null +++ b/apps/agent-forge/server/workspace/__tests__/legacyImport.test.ts @@ -0,0 +1,177 @@ +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 { WorkspaceRef } from '../../../shared/workspace'; +import { SecretsStore } from '../../secretsStore'; +import { SettingsStore } from '../../settingsStore'; +import { studioPathsFor } from '../studioPaths'; +import { LegacyImportService } from '../legacyImport'; + +/** + * W03a's one-time import (server/workspace/legacyImport.ts): detection of + * a workspace's old project-level `.lousho/` settings/secrets, copy-only- + * what-is-missing rules, the `legacy-imports.json` outcome records, and + * the guarantee the old files are never touched. + */ + +let dir: string; +let homeDir: string; +let workspaceDir: string; +let paths: ReturnType; +let settings: SettingsStore; +let secrets: SecretsStore; +let service: LegacyImportService; +let ref: WorkspaceRef; + +const PROD_PROFILE = { + id: 'legacy-acme', + name: 'legacy-acme', + providerType: 'openai', + providerKeyRef: 'openai', + deployAdapter: 'node-server', + otelEnabled: false, + hookTimeoutMs: 5000, + sandboxBackend: 'noop' as const, +}; + +function legacySettings(): SettingsStore { + return new SettingsStore(workspaceDir); +} + +function legacySecrets(): SecretsStore { + return new SecretsStore(workspaceDir); +} + +function writeOldSettings(): void { + const store = legacySettings(); + store.upsertProfile(PROD_PROFILE); + store.setActive('legacy-acme'); +} + +function writeOldSecrets(): void { + const store = legacySecrets(); + store.setKey('openai', 'sk-old-openai-key'); + store.setSecret('GITHUB_TOKEN', 'ghp_old_token'); +} + +function snapshot(dirPath: string): Map { + const files = new Map(); + const walk = (current: string) => { + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name); + if (entry.isDirectory()) walk(full); + else files.set(full, fs.readFileSync(full, 'utf8')); + } + }; + walk(dirPath); + return files; +} + +beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'lou-w03a-import-')); + homeDir = path.join(dir, 'home'); + workspaceDir = path.join(dir, 'workspace'); + fs.mkdirSync(workspaceDir, { recursive: true }); + paths = studioPathsFor(homeDir); + settings = new SettingsStore(paths); + secrets = new SecretsStore(paths); + service = new LegacyImportService({ file: paths.legacyImports, settings, secrets, home: paths }); + ref = { id: 'ws-abc123', name: 'workspace', root: workspaceDir, addedAt: '', lastOpenedAt: '' }; +}); + +afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('status detection', () => { + it('a workspace with nothing old offers nothing', async () => { + expect(await service.status(ref)).toEqual({ workspaceId: ref.id, found: [], handled: null }); + }); + + it('settings.json alone is found', async () => { + writeOldSettings(); + expect((await service.status(ref)).found).toEqual(['settings']); + }); + + it('secrets.json without secrets.key is NOT found (ciphertext cannot cross keys)', async () => { + fs.mkdirSync(path.join(workspaceDir, '.lousho'), { recursive: true }); + fs.writeFileSync(path.join(workspaceDir, '.lousho', 'secrets.json'), '{"openai":{"iv":"x"}}'); + expect((await service.status(ref)).found).toEqual([]); + }); + + it('settings.json + secrets.json + secrets.key are both found', async () => { + writeOldSettings(); + writeOldSecrets(); + expect((await service.status(ref)).found).toEqual(['settings', 'secrets']); + }); + + it('a pre-W03 install whose .lousho IS the home offers nothing', async () => { + // LOUSHO_STUDIO_HOME=/.lousho: the "old" files are the home's own. + const oldHome = path.join(workspaceDir, '.lousho'); + fs.mkdirSync(oldHome, { recursive: true }); + const oldPaths = studioPathsFor(oldHome); + const oldSettings = new SettingsStore(oldPaths); + oldSettings.upsertProfile(PROD_PROFILE); + const oldSecrets = new SecretsStore(oldPaths); + oldSecrets.setKey('openai', 'sk-same'); + const sameService = new LegacyImportService({ + file: oldPaths.legacyImports, + settings: oldSettings, + secrets: oldSecrets, + home: oldPaths, + }); + expect((await sameService.status(ref)).found).toEqual([]); + }); +}); + +describe('importFrom', () => { + it('copies profiles, provider keys and named secrets; records imported; originals untouched', async () => { + writeOldSettings(); + writeOldSecrets(); + const before = snapshot(path.join(workspaceDir, '.lousho')); + + const result = await service.importFrom(ref); + expect(result).toEqual({ profiles: 1, providerKeys: 1, namedSecrets: 1 }); + + // The new home serves the same values. + expect(settings.list().profiles.map((p) => p.id)).toContain('legacy-acme'); + expect(settings.activeProfile().id).toBe('legacy-acme'); // home had no settings.json -> old active wins + expect(secrets.getKey('openai')).toBe('sk-old-openai-key'); + expect(secrets.getSecret('GITHUB_TOKEN')).toBe('ghp_old_token'); + + // The old files are byte-identical. + const after = snapshot(path.join(workspaceDir, '.lousho')); + expect(after).toEqual(before); + + // The outcome is recorded; a second status reports 'imported'. + expect((await service.status(ref)).handled).toBe('imported'); + const record = JSON.parse(fs.readFileSync(paths.legacyImports, 'utf8')); + expect(record).toMatchObject({ schemaVersion: 1, workspaces: { [ref.id]: { outcome: 'imported' } } }); + }); + + it('never overwrites a value the home already has', async () => { + writeOldSecrets(); + secrets.setKey('openai', 'sk-NEW-home-key'); // home already has one + const result = await service.importFrom(ref); + expect(result).toEqual({ profiles: 0, providerKeys: 0, namedSecrets: 1 }); + expect(secrets.getKey('openai')).toBe('sk-NEW-home-key'); + expect(secrets.getSecret('GITHUB_TOKEN')).toBe('ghp_old_token'); + }); + + it('keeps the home activeProfileId when the home already had settings.json', async () => { + writeOldSettings(); + settings.upsertProfile({ ...PROD_PROFILE, id: 'home-only' }); // creates the home's file + settings.setActive('local'); + await service.importFrom(ref); + expect(settings.activeProfile().id).toBe('local'); + expect(settings.list().profiles.map((p) => p.id)).toContain('legacy-acme'); + }); + + it('skip records skipped without copying anything', async () => { + writeOldSettings(); + await service.skip(ref); + expect((await service.status(ref)).handled).toBe('skipped'); + expect(settings.list().profiles.map((p) => p.id)).not.toContain('legacy-acme'); + }); +}); diff --git a/apps/agent-forge/server/workspace/__tests__/studioPaths.test.ts b/apps/agent-forge/server/workspace/__tests__/studioPaths.test.ts index f95e02fdc..8db2a6d2d 100644 --- a/apps/agent-forge/server/workspace/__tests__/studioPaths.test.ts +++ b/apps/agent-forge/server/workspace/__tests__/studioPaths.test.ts @@ -1,6 +1,7 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as os from 'node:os'; import * as path from 'node:path'; -import { configureStudioHome, studioPaths, studioPathsFor } from '../studioPaths'; +import { configureStudioHome, defaultStudioHome, studioPaths, studioPathsFor } from '../studioPaths'; describe('studioPathsFor', () => { it('derives every Studio-global file under the home directory', () => { @@ -14,10 +15,29 @@ describe('studioPathsFor', () => { spend: path.join('/h', 'passes', 'spend.jsonl'), alerts: path.join('/h', 'inbox', 'alerts.json'), serve: path.join('/h', 'serve.json'), + legacyImports: path.join('/h', 'legacy-imports.json'), }); }); }); +describe('defaultStudioHome', () => { + const ENV = 'LOUSHO_STUDIO_HOME'; + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('is ~/.lousho/studio when LOUSHO_STUDIO_HOME is unset', () => { + vi.stubEnv(ENV, ''); + delete process.env[ENV]; + expect(defaultStudioHome()).toBe(path.join(os.homedir(), '.lousho', 'studio')); + }); + + it('is path.resolve(LOUSHO_STUDIO_HOME) when set', () => { + vi.stubEnv(ENV, 'relative/home'); + expect(defaultStudioHome()).toBe(path.resolve('relative/home')); + }); +}); + describe('studioPaths', () => { it('throws before configureStudioHome runs', async () => { vi.resetModules(); diff --git a/apps/agent-forge/server/workspace/legacyImport.ts b/apps/agent-forge/server/workspace/legacyImport.ts new file mode 100644 index 000000000..dcabf7c6c --- /dev/null +++ b/apps/agent-forge/server/workspace/legacyImport.ts @@ -0,0 +1,169 @@ +/** + * One-time import of a workspace's old project-level global state into + * the Studio home (W03a; PLAN §19.2's "Import settings and keys from + * "). + * + * Before W03a every Studio-global store lived at `/.lousho/`; + * now they live in the Studio home (`~/.lousho/studio`). A registered + * workspace that still has `settings.json`/`secrets.json` there can copy + * them in once - this service detects what is missing, copies only what + * the home does not already have (existing values are NEVER overwritten), + * and records the outcome in `legacy-imports.json` so the offer is made + * once. The old files are only ever read, never written. + * + * `secrets` is only offered when BOTH `secrets.json` and `secrets.key` + * exist under the old `.lousho/` - the key is the only way to decrypt + * the old ciphertext, and copying it across keys would produce garbage. + * It is also not offered when the old paths ARE the home's files (a + * pre-W03 install that ran with `LOUSHO_STUDIO_HOME=/.lousho` + * has nothing to import). + */ +import { promises as fsPromises } from 'node:fs'; +import * as path from 'node:path'; +import type { LegacyImportItem, LegacyImportResult, LegacyImportStatus } from '../../shared/wireTypes'; +import type { WorkspaceRef } from '../../shared/workspace'; +import { readJsonFile, writeJsonAtomic } from '../storage/atomic'; +import { SecretsStore } from '../secretsStore'; +import { SettingsStore } from '../settingsStore'; +import type { StudioPaths } from './studioPaths'; + +/** The `legacy-imports.json` document: one outcome record per workspace id. */ +interface LegacyImportsFile { + schemaVersion: 1; + workspaces: Record; +} + +export interface LegacyImportDeps { + /** `studioPaths().legacyImports`. */ + file: string; + /** The app's settings store (the new home's). */ + settings: SettingsStore; + /** The app's secrets store (the new home's). */ + secrets: SecretsStore; + /** The home's own file locations - a legacy path equal to one of these is the home itself, not an import source. */ + home: Pick; + now?: () => Date; +} + +export class LegacyImportService { + private readonly now: () => Date; + + constructor(private readonly deps: LegacyImportDeps) { + this.now = deps.now ?? (() => new Date()); + } + + /** What the workspace's old `.lousho/` still offers, plus its handled state (`null` while it can still be offered). */ + async status(ref: WorkspaceRef): Promise { + const [found, record] = await Promise.all([this.found(ref), this.record(ref.id)]); + return { workspaceId: ref.id, found, handled: record?.outcome ?? null }; + } + + /** + * Copies every found item into the home, never overwriting what is + * already there, then records 'imported'. Callers answer 409 first when + * `found` is empty (`status().found.length === 0`). + */ + async importFrom(ref: WorkspaceRef): Promise { + const result: LegacyImportResult = { profiles: 0, providerKeys: 0, namedSecrets: 0 }; + const found = await this.found(ref); + if (found.includes('settings')) result.profiles = await this.importProfiles(ref); + if (found.includes('secrets')) { + const secrets = await this.importSecrets(ref); + result.providerKeys = secrets.providerKeys; + result.namedSecrets = secrets.namedSecrets; + } + await this.mark(ref.id, 'imported'); + return result; + } + + /** Records 'skipped' - the banner offer is dismissed for this workspace. */ + async skip(ref: WorkspaceRef): Promise { + await this.mark(ref.id, 'skipped'); + } + + /** The old `.lousho/` files that exist and are not the home's own files. */ + private async found(ref: WorkspaceRef): Promise { + const dir = path.join(ref.root, '.lousho'); + const found: LegacyImportItem[] = []; + const settingsFile = path.join(dir, 'settings.json'); + if ((await isFile(settingsFile)) && !samePath(settingsFile, this.deps.home.settings)) found.push('settings'); + const secretsFile = path.join(dir, 'secrets.json'); + const secretsKey = path.join(dir, 'secrets.key'); + if ( + (await isFile(secretsFile)) && + (await isFile(secretsKey)) && + !samePath(secretsFile, this.deps.home.secrets) && + !samePath(secretsKey, this.deps.home.secretsKey) + ) { + found.push('secrets'); + } + return found; + } + + /** + * Every old profile whose id the home lacks is added. `activeProfileId` + * follows the old file only when the home had no `settings.json` at all + * before the import (an existing home's choice is never overridden). + */ + private async importProfiles(ref: WorkspaceRef): Promise { + const homeHadFile = await isFile(this.deps.home.settings); + const old = new SettingsStore(ref.root).list(); + const settings = this.deps.settings; + const known = new Set(settings.list().profiles.map((profile) => profile.id)); + let copied = 0; + for (const profile of old.profiles) { + if (known.has(profile.id)) continue; + settings.upsertProfile(profile); + known.add(profile.id); + copied += 1; + } + if (!homeHadFile && known.has(old.activeProfileId)) settings.setActive(old.activeProfileId); + return copied; + } + + /** Provider keys and named secrets the home lacks; values are copied store-to-store and never logged or returned. */ + private async importSecrets(ref: WorkspaceRef): Promise<{ providerKeys: number; namedSecrets: number }> { + const old = new SecretsStore(ref.root); + const secrets = this.deps.secrets; + let providerKeys = 0; + let namedSecrets = 0; + for (const status of old.list()) { + if (!status.hasKey) continue; + const key = old.getKey(status.provider); + if (key !== undefined && secrets.getKey(status.provider) === undefined) { + secrets.setKey(status.provider, key); + providerKeys += 1; + } + } + for (const status of old.listSecrets()) { + if (!status.hasValue) continue; + const value = old.getSecret(status.name); + if (value !== undefined && secrets.getSecret(status.name) === undefined) { + secrets.setSecret(status.name, value); + namedSecrets += 1; + } + } + return { providerKeys, namedSecrets }; + } + + private async record(id: string): Promise { + const doc = await readJsonFile(this.deps.file); + return doc?.workspaces?.[id]; + } + + private async mark(id: string, outcome: 'imported' | 'skipped'): Promise { + const doc = (await readJsonFile(this.deps.file)) ?? { schemaVersion: 1 as const, workspaces: {} }; + doc.workspaces[id] = { outcome, at: this.now().toISOString() }; + await writeJsonAtomic(this.deps.file, doc); + } +} + +async function isFile(file: string): Promise { + const stat = await fsPromises.stat(file).catch(() => undefined); + return stat?.isFile() === true; +} + +/** `path.resolve` on both sides so Windows drive-letter/case and `..` segments compare equal (the ticket's gotcha). */ +function samePath(a: string, b: string): boolean { + return path.resolve(a) === path.resolve(b); +} diff --git a/apps/agent-forge/server/workspace/studioPaths.ts b/apps/agent-forge/server/workspace/studioPaths.ts index d48c73b80..a2ac80dba 100644 --- a/apps/agent-forge/server/workspace/studioPaths.ts +++ b/apps/agent-forge/server/workspace/studioPaths.ts @@ -5,9 +5,10 @@ * settings, secrets, Author sessions, the pass spend log, the workspace * registry itself. Those files live under the Studio home directory, and * their paths come from here so no code joins `baseDir` with `.lousho` - * ad hoc. Today `home` is still `/.lousho` (the files stay - * exactly where they were); moving it to `~/.lousho/studio` is a later - * change of one default, not a repo-wide rewrite. + * ad hoc. The home is `~/.lousho/studio` (or `LOUSHO_STUDIO_HOME`) - + * `defaultStudioHome()` - so global state survives across projects; + * `legacyImport.ts` offers to copy a workspace's old project-level + * settings/secrets into it once. * * `studioPaths()` is process-wide and set once while wiring * (`createApp`/`startStudioServer` call `configureStudioHome`). Request @@ -15,8 +16,20 @@ * wiring time and close over them, so a request's workspace can't change * which global file a handler touches. */ +import * as os from 'node:os'; import * as path from 'node:path'; +/** + * The Studio home directory when the caller names none: + * `LOUSHO_STUDIO_HOME` (resolved to absolute) when set, else + * `/.lousho/studio`. Read once while wiring - tests set the + * env var (or pass `studioHome`) so they never touch a real home. + */ +export function defaultStudioHome(): string { + const env = process.env.LOUSHO_STUDIO_HOME; + return env !== undefined && env.trim() !== '' ? path.resolve(env) : path.join(os.homedir(), '.lousho', 'studio'); +} + /** Every Studio-global file, derived from the Studio home directory. */ export interface StudioPaths { /** The Studio home directory every other key lives under. */ @@ -37,6 +50,8 @@ export interface StudioPaths { alerts: string; /** Published-agent serve config. */ serve: string; + /** One-time legacy settings/secrets import records, per workspace id. */ + legacyImports: string; } /** The `StudioPaths` for a given home directory - pure; sets nothing. */ @@ -51,6 +66,7 @@ export function studioPathsFor(home: string): StudioPaths { spend: path.join(home, 'passes', 'spend.jsonl'), alerts: path.join(home, 'inbox', 'alerts.json'), serve: path.join(home, 'serve.json'), + legacyImports: path.join(home, 'legacy-imports.json'), }; } diff --git a/apps/agent-forge/server/workspace/workspaceRoutes.ts b/apps/agent-forge/server/workspace/workspaceRoutes.ts index ede7482d7..4859de9be 100644 --- a/apps/agent-forge/server/workspace/workspaceRoutes.ts +++ b/apps/agent-forge/server/workspace/workspaceRoutes.ts @@ -18,12 +18,20 @@ */ import { promises as fsPromises } from 'node:fs'; import type { Express, Request, Response } from 'express'; -import type { AddWorkspaceRequest, WorkspaceList, WorkspaceListItem, WorkspaceScanResult } from '../../shared/wireTypes'; +import type { + AddWorkspaceRequest, + LegacyImportResult, + LegacyImportStatus, + WorkspaceList, + WorkspaceListItem, + WorkspaceScanResult, +} from '../../shared/wireTypes'; import { WORKSPACE_ID_RE, type WorkspaceRef } from '../../shared/workspace'; import { asyncRoute, paramId, sendError } from '../http/routeUtils'; import { AgentRepo } from '../storage/agentRepo'; import { observeProjectOf } from '../observed/observeProject'; import { isAgentDirPattern, type CodeAgentCache } from './codeAgentScan'; +import type { LegacyImportService } from './legacyImport'; import { WorkspaceExistsError, WorkspaceInUseError, @@ -50,6 +58,8 @@ export interface WorkspaceRegistryRouteDeps { * drops the removed workspace's cached scan. */ scanCache?: CodeAgentCache; + /** The one-time old-settings/secrets import service (W03a; `legacy-imports.json` under the Studio home). */ + legacyImport: LegacyImportService; } /** @@ -78,7 +88,15 @@ function byNameThenId(a: WorkspaceListItem, b: WorkspaceListItem): number { /** Registers the three registry routes on `app`; mounted next to `registerWorkspaceRoute` in `createApp`. */ export function registerWorkspaceRegistryRoutes(app: Express, deps: WorkspaceRegistryRouteDeps): void { - const { registry, launchId, forget, scanCache } = deps; + const { registry, launchId, forget, scanCache, legacyImport } = deps; + + /** `:ws` resolved to its registry ref; answers the shared 404 and yields `undefined` when unregistered. */ + const workspaceRef = async (req: Request, res: Response): Promise => { + const id = paramId(req, 'ws'); + const ref = await registry.get(id); + if (ref === undefined) sendError(res, 404, `No workspace '${id}'`, 'LOUSHO_WORKSPACE_NOT_FOUND'); + return ref; + }; // The workspace switcher's/Settings card's list: every registered // workspace plus the per-request availability facts, `defaultId` naming @@ -146,66 +164,107 @@ export function registerWorkspaceRegistryRoutes(app: Express, deps: WorkspaceReg }) ); - if (scanCache === undefined) return; - - /** The registered entry for `:ws`, or the 404 already sent. */ - async function scanEntry(req: Request, res: Response): Promise { - const ws = paramId(req, 'ws'); - if (!WORKSPACE_ID_RE.test(ws)) { - sendError(res, 400, 'Invalid workspace id', 'LOUSHO_WORKSPACE_ID_INVALID'); - return undefined; - } - const entry = await registry.get(ws); - if (entry === undefined) { - sendError(res, 404, `No workspace '${ws}'`, 'LOUSHO_WORKSPACE_NOT_FOUND'); - return undefined; + // W08a: the workspace's cached code-agent scan - scans on first read, + // then serves the stored result until a POST or a DELETE refreshes it. + // Registered only when the app wired a scan cache. + if (scanCache !== undefined) { + /** The registered entry for `:ws`, or the 404 already sent. */ + async function scanEntry(req: Request, res: Response): Promise { + const ws = paramId(req, 'ws'); + if (!WORKSPACE_ID_RE.test(ws)) { + sendError(res, 400, 'Invalid workspace id', 'LOUSHO_WORKSPACE_ID_INVALID'); + return undefined; + } + const entry = await registry.get(ws); + if (entry === undefined) { + sendError(res, 404, `No workspace '${ws}'`, 'LOUSHO_WORKSPACE_NOT_FOUND'); + return undefined; + } + return entry; } - return entry; + + app.get( + '/workspaces/:ws/scan', + asyncRoute(async (req, res) => { + const entry = await scanEntry(req, res); + if (entry === undefined) return; + const result = scanCache.get(entry.id) ?? (await scanCache.scan(entry)); + res.json(result satisfies WorkspaceScanResult); + }) + ); + + // W08a: rescan with optional new `agentDirs` patterns (1-20 valid + // globs), persisted on the registry entry before the scan runs. + app.post( + '/workspaces/:ws/scan', + asyncRoute(async (req, res) => { + const entry = await scanEntry(req, res); + if (entry === undefined) return; + const { agentDirs } = (req.body ?? {}) as { agentDirs?: unknown }; + if (agentDirs !== undefined) { + const valid = + Array.isArray(agentDirs) && + agentDirs.length >= 1 && + agentDirs.length <= 20 && + agentDirs.every((p) => typeof p === 'string' && isAgentDirPattern(p)); + if (!valid) { + sendError( + res, + 400, + "Each pattern is a folder path like agents/*/ (no '..' or '**').", + 'LOUSHO_AGENT_DIRS_INVALID' + ); + return; + } + const updated = await registry.setAgentDirs(entry.id, agentDirs as string[]); + if (updated === undefined) { + sendError(res, 404, `No workspace '${entry.id}'`, 'LOUSHO_WORKSPACE_NOT_FOUND'); + return; + } + entry.agentDirs = updated.agentDirs; + } + res.json(await scanCache.scan(entry)); + }) + ); } - // W08a: the workspace's cached code-agent scan - scans on first read, - // then serves the stored result until a POST or a DELETE refreshes it. + // W03a's one-time import offer: which old project-level settings/secrets + // the workspace's `.lousho/` still holds that the Studio home lacks. app.get( - '/workspaces/:ws/scan', + '/workspaces/:ws/legacy-import', asyncRoute(async (req, res) => { - const entry = await scanEntry(req, res); - if (entry === undefined) return; - const result = scanCache.get(entry.id) ?? (await scanCache.scan(entry)); - res.json(result satisfies WorkspaceScanResult); + const ref = await workspaceRef(req, res); + if (ref === undefined) return; + const body: LegacyImportStatus = await legacyImport.status(ref); + res.json(body); }) ); - // W08a: rescan with optional new `agentDirs` patterns (1-20 valid - // globs), persisted on the registry entry before the scan runs. + // Copies what is missing into the home (never overwriting); 409 when + // the folder has nothing left to offer. app.post( - '/workspaces/:ws/scan', + '/workspaces/:ws/legacy-import', asyncRoute(async (req, res) => { - const entry = await scanEntry(req, res); - if (entry === undefined) return; - const { agentDirs } = (req.body ?? {}) as { agentDirs?: unknown }; - if (agentDirs !== undefined) { - const valid = - Array.isArray(agentDirs) && - agentDirs.length >= 1 && - agentDirs.length <= 20 && - agentDirs.every((p) => typeof p === 'string' && isAgentDirPattern(p)); - if (!valid) { - sendError( - res, - 400, - "Each pattern is a folder path like agents/*/ (no '..' or '**').", - 'LOUSHO_AGENT_DIRS_INVALID' - ); - return; - } - const updated = await registry.setAgentDirs(entry.id, agentDirs as string[]); - if (updated === undefined) { - sendError(res, 404, `No workspace '${entry.id}'`, 'LOUSHO_WORKSPACE_NOT_FOUND'); - return; - } - entry.agentDirs = updated.agentDirs; + const ref = await workspaceRef(req, res); + if (ref === undefined) return; + const status = await legacyImport.status(ref); + if (status.found.length === 0) { + sendError(res, 409, 'Nothing to import from this folder.', 'LOUSHO_LEGACY_IMPORT_EMPTY'); + return; } - res.json(await scanCache.scan(entry)); + const body: LegacyImportResult = await legacyImport.importFrom(ref); + res.json(body); + }) + ); + + // Dismisses the offer for this workspace. + app.post( + '/workspaces/:ws/legacy-import/skip', + asyncRoute(async (req, res) => { + const ref = await workspaceRef(req, res); + if (ref === undefined) return; + await legacyImport.skip(ref); + res.status(204).end(); }) ); } diff --git a/apps/agent-forge/shared/wireTypes.ts b/apps/agent-forge/shared/wireTypes.ts index 006713132..8f4680a5f 100644 --- a/apps/agent-forge/shared/wireTypes.ts +++ b/apps/agent-forge/shared/wireTypes.ts @@ -641,6 +641,27 @@ export interface AddWorkspaceRequest { root: string; } +/** The global-state files a workspace's old project-level `.lousho/` can still hold (W03a's one-time import). */ +export type LegacyImportItem = 'settings' | 'secrets'; + +/** + * `GET /workspaces/:ws/legacy-import`'s response: which old files the + * workspace's `.lousho/` still has that the Studio home lacks, and + * whether the offer was already handled (`null` while it still can be). + */ +export interface LegacyImportStatus { + workspaceId: string; + found: LegacyImportItem[]; + handled: 'imported' | 'skipped' | null; +} + +/** `POST /workspaces/:ws/legacy-import`'s response: how many entries were copied (existing home values are never overwritten). */ +export interface LegacyImportResult { + profiles: number; + providerKeys: number; + namedSecrets: number; +} + /** One `POST /playground/run` message (server/playground.ts; the Playground screen's editor rows). */ export interface PlaygroundMessage { role: 'system' | 'user' | 'assistant'; diff --git a/apps/agent-forge/src/runtime/runtimeClient.ts b/apps/agent-forge/src/runtime/runtimeClient.ts index 397590f26..af2c10d44 100644 --- a/apps/agent-forge/src/runtime/runtimeClient.ts +++ b/apps/agent-forge/src/runtime/runtimeClient.ts @@ -72,6 +72,8 @@ import type { TemplateSummary, ToolPin, TraceDetailPayload, + LegacyImportResult, + LegacyImportStatus, TraceSummaryPayload, HostStatus, VersionListItem, @@ -574,6 +576,29 @@ class RuntimeClient implements AgentApi { return this.forWorkspace(id).request(`/workspaces/${encodeURIComponent(id)}/host/restart`, { method: 'POST' }); } + // ------------------------------------------------------------------- + // `/workspaces/:ws/legacy-import` - the one-time old-settings/secrets + // import offer (W03a; server/workspace/legacyImport.ts). + // ------------------------------------------------------------------- + + /** Which old project-level files `ws` still offers to import, and whether the offer was handled. */ + // fallow-ignore-next-line unused-class-member -- contract surface: the import-offer UI lands in a later wave; the route + client ship together here + async legacyImportStatus(ws: string): Promise { + return this.request(`/workspaces/${encodeURIComponent(ws)}/legacy-import`, { method: 'GET' }); + } + + /** Copies the missing settings/secrets into the Studio home; 409s `LOUSHO_LEGACY_IMPORT_EMPTY` when there is nothing. */ + // fallow-ignore-next-line unused-class-member -- contract surface: the import-offer UI lands in a later wave; the route + client ship together here + async importLegacy(ws: string): Promise { + return this.request(`/workspaces/${encodeURIComponent(ws)}/legacy-import`, { method: 'POST' }); + } + + /** Dismisses the import offer for `ws`. */ + // fallow-ignore-next-line unused-class-member -- contract surface: the import-offer UI lands in a later wave; the route + client ship together here + async skipLegacyImport(ws: string): Promise { + await this.request(`/workspaces/${encodeURIComponent(ws)}/legacy-import/skip`, { method: 'POST' }); + } + async run( agentId: string, input: string,