From ff83ca4bdc2ffb303a54e27b2bfd04569e13ce87 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 14 Aug 2026 14:11:12 +0200 Subject: [PATCH] fix(workspaces): restore clean runtime test paths --- packages/desktop/package.json | 1 + .../scripts/dev-build-contract.test.mjs | 11 +++- packages/server/package.json | 1 + .../src/server/agent/agent-manager.test.ts | 56 +++++++++++++++++++ .../server/src/server/agent/agent-manager.ts | 26 ++++----- packages/server/src/server/session.test.ts | 13 ++++- .../src/server/test-utils/session-stubs.ts | 15 ++++- .../websocket-server.relay-reconnect.test.ts | 7 +++ packages/workspace-helper/package.json | 1 + .../workspace-helper/test/standalone.test.mjs | 7 ++- .../workspace-runtime-contract/package.json | 1 + .../test/contract.test.mjs | 5 +- scripts/server-clean-build.test.mjs | 52 +++++++++++++++++ 13 files changed, 171 insertions(+), 25 deletions(-) diff --git a/packages/desktop/package.json b/packages/desktop/package.json index f592b21b4d..027d11ad21 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -23,6 +23,7 @@ "dev": "./scripts/dev.sh", "predev:win": "npm run dev:prerequisites", "dev:win": "powershell ./scripts/dev.ps1", + "pretest:e2e:renderer": "npm --prefix ../.. run build:workspace-runtime-fixture", "test:e2e:renderer": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --config=playwright.config.ts --project=desktop", "test:e2e:browser-tabs": "npm run build:main && node ./e2e/browser-tabs.e2e.mjs", "verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs", diff --git a/packages/desktop/scripts/dev-build-contract.test.mjs b/packages/desktop/scripts/dev-build-contract.test.mjs index 524135f534..7c6994a64e 100644 --- a/packages/desktop/scripts/dev-build-contract.test.mjs +++ b/packages/desktop/scripts/dev-build-contract.test.mjs @@ -19,6 +19,7 @@ import { describe, expect, test } from "vitest"; const desktopDir = path.resolve(import.meta.dirname, ".."); const repoRoot = path.resolve(desktopDir, "../.."); const execFileAsync = promisify(execFile); +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; const prerequisite = "npm run dev:prerequisites"; describe("desktop dev build contract", () => { @@ -38,6 +39,14 @@ describe("desktop dev build contract", () => { ); }); + test("builds the private runtime fixture before renderer acceptance", async () => { + const packageJson = JSON.parse(await readFile(path.join(desktopDir, "package.json"), "utf8")); + + expect(packageJson.scripts["pretest:e2e:renderer"]).toBe( + "npm --prefix ../.. run build:workspace-runtime-fixture", + ); + }); + test.each(["missing", "stale"])( "the public prerequisite repairs %s server artifacts in an isolated checkout", async (initialState) => { @@ -118,7 +127,7 @@ async function createIsolatedBuildCheckout() { async function runPrerequisite(isolatedRoot) { try { - await execFileAsync("npm", ["run", "dev:prerequisites", "--workspace=@getpaseo/desktop"], { + await execFileAsync(npmCommand, ["run", "dev:prerequisites", "--workspace=@getpaseo/desktop"], { cwd: isolatedRoot, timeout: 60_000, }); diff --git a/packages/server/package.json b/packages/server/package.json index a210a6130d..9fcbb51b65 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -53,6 +53,7 @@ "speech:download": "tsx scripts/download-speech-models.ts", "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", + "pretest:unit": "npm --prefix ../.. run build:workspace-runtime-fixture", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 50773a7a61..695fefdcc9 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -1891,6 +1891,62 @@ test("selected workspace availability fails closed without probing or launching } }); +test("host-placed workspaces require host provider availability before create and resume", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-host-workspace-provider-")); + const availabilityScopes: Array = []; + let createCalls = 0; + let resumeCalls = 0; + class UnavailableHostClient extends TestAgentClient { + override async isAvailable(options?: FetchCatalogOptions): Promise { + availabilityScopes.push(options); + return false; + } + + override async createSession(config: AgentSessionConfig): Promise { + createCalls += 1; + return new TestAgentSession(config); + } + + override async resumeSession( + handle: AgentPersistenceHandle, + overrides?: Partial, + ): Promise { + resumeCalls += 1; + return await super.resumeSession(handle, overrides); + } + } + const manager = new AgentManager({ + clients: { codex: new UnavailableHostClient() }, + logger, + resolveProviderWorkspace: async () => null, + }); + const handle: AgentPersistenceHandle = { + provider: "codex", + sessionId: "host-workspace-session", + metadata: { cwd: workdir }, + }; + + try { + await expect( + manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: "local-workspace", + }), + ).rejects.toThrow("Provider 'codex' is not available"); + await expect( + manager.resumeAgentFromPersistence(handle, undefined, undefined, { + workspaceId: "local-workspace", + }), + ).rejects.toThrow("Provider 'codex' is not available"); + + expect(availabilityScopes.length).toBeGreaterThanOrEqual(2); + expect(availabilityScopes.every((scope) => scope === undefined)).toBe(true); + expect(createCalls).toBe(0); + expect(resumeCalls).toBe(0); + } finally { + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("selected workspace without a bound capability never checks host availability", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-missing-workspace-capability-")); let hostAvailabilityCalls = 0; diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 37b8a7778e..bcb09dc7d3 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1141,9 +1141,7 @@ export class AgentManager { options.workspaceId, ); this.requireEnabledProvider(storedConfig.provider); - const client = options.workspaceId - ? this.requireClient(storedConfig.provider) - : await this.requireAvailableClient({ provider: storedConfig.provider }); + const client = this.requireClient(storedConfig.provider); const launchContext = await this.buildLaunchContext( resolvedAgentId, client, @@ -1151,7 +1149,7 @@ export class AgentManager { options?.env, options.workspaceId, ); - await this.requireWorkspaceProviderAvailable( + await this.requireProviderAvailableForLaunch( client, storedConfig.provider, storedConfig.cwd, @@ -1232,11 +1230,6 @@ export class AgentManager { ); const client = this.requireClient(handle.provider); - if (!options?.workspaceId && !(await client.isAvailable())) { - throw new Error( - `Provider '${handle.provider}' is not available. Please ensure the CLI is installed.`, - ); - } const launchContext = await this.buildLaunchContext( resolvedAgentId, client, @@ -1244,7 +1237,7 @@ export class AgentManager { undefined, options?.workspaceId, ); - await this.requireWorkspaceProviderAvailable( + await this.requireProviderAvailableForLaunch( client, storedConfig.provider, storedConfig.cwd, @@ -1307,7 +1300,7 @@ export class AgentManager { undefined, input.workspaceId, ); - await this.requireWorkspaceProviderAvailable( + await this.requireProviderAvailableForLaunch( client, input.provider, storedConfig.cwd, @@ -4687,15 +4680,20 @@ export class AgentManager { return launchContext.paseoTools ? stripInternalPaseoMcpServer(launchConfig) : launchConfig; } - private async requireWorkspaceProviderAvailable( + private async requireProviderAvailableForLaunch( client: AgentClient, provider: AgentProvider, cwd: string, workspaceId: string | undefined, launchContext: AgentLaunchContext, ): Promise { - if (!workspaceId) return; - if (!launchContext.workspace) return; + if (!launchContext.workspace) { + await this.requireAvailableClient({ provider }); + return; + } + if (!workspaceId) { + throw new Error(`Workspace-scoped provider '${provider}' is missing a workspace ID.`); + } const available = await client.isAvailable({ scope: "workspace", cwd, diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index bba131a1ab..19dbb9c0dc 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -51,6 +51,8 @@ import { import type { CheckDetails, ForgeService } from "../services/forge-service.js"; import type { GitHubPullRequestStatusFacts } from "../services/github-facts.js"; import { bindWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js"; +import { createStub } from "./test-utils/class-mocks.js"; +import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; interface SessionHandlerInternals { interruptAgentIfRunning(agentId: string): Promise; @@ -304,6 +306,7 @@ interface SessionForTestOptions { getProjectSlug?: ReturnType; }; workspaceRegistry?: { get: ReturnType }; + workspaceRuntime?: WorkspaceRuntimeService; projectRegistry?: Partial; terminalManager?: SessionOptions["terminalManager"]; serviceProxy?: SessionOptions["serviceProxy"]; @@ -477,6 +480,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { get: vi.fn(), list: vi.fn().mockResolvedValue([]), }, + workspaceRuntime: options.workspaceRuntime, scheduleService: asScheduleService(), checkoutDiffManager: asCheckoutDiffManager(checkoutDiffManager), github: asGitHubService(github), @@ -1832,7 +1836,7 @@ describe("session provider refresh cwd routing", () => { test("get_providers_snapshot_request forwards cwd to the provider authority", async () => { const messages: unknown[] = []; const workspaceCwd = resolvePath("/tmp/session-provider-snapshot"); - const { manager: providerSnapshotManager, getSnapshot } = createProviderSnapshotManagerStub(); + const { manager: providerSnapshotManager, readSnapshot } = createProviderSnapshotManagerStub(); const session = createSessionForTest({ messages, providerSnapshotManager }); await session.handleMessage({ @@ -1841,7 +1845,7 @@ describe("session provider refresh cwd routing", () => { requestId: "snapshot-workspace", }); - expect(getSnapshot).toHaveBeenCalledWith(workspaceCwd); + expect(readSnapshot).toHaveBeenCalledWith({ cwd: workspaceCwd }); }); test("preserves legacy model and mode list requests without cwd as global", async () => { @@ -4328,7 +4332,10 @@ describe("session stash mutation handling", () => { describe("session paseo worktree creation handling", () => { test("forces workspace git refreshes for the source repo and created worktree", async () => { const workspaceGitService = { getSnapshot: vi.fn().mockResolvedValue({}) }; - const session = createSessionForTest({ workspaceGitService }); + const session = createSessionForTest({ + workspaceGitService, + workspaceRuntime: createStub({}), + }); paseoWorktreeServiceMocks.createPaseoWorktree.mockResolvedValue({ repoRoot: "/tmp/repo", worktree: { diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 46db4e1749..a4fe410fc0 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -148,7 +148,10 @@ export function findByType( // --------------------------------------------------------------------------- export interface ProviderSnapshotManagerSpies { - getSnapshot: ReturnType>; + getSnapshot: ReturnType< + typeof vi.fn<[cwd?: string, workspaceId?: string], ProviderSnapshotEntry[]> + >; + readSnapshot: ReturnType>>; refreshSnapshotForCwd: ReturnType>>; refreshSettingsSnapshot: ReturnType>>; warmUpSnapshotForCwd: ReturnType>>; @@ -175,7 +178,13 @@ export interface ProviderSnapshotManagerSpies { export function createProviderSnapshotManagerStub(): { manager: ProviderSnapshotManager; } & ProviderSnapshotManagerSpies { - const getSnapshot = vi.fn<[cwd?: string], ProviderSnapshotEntry[]>(() => []); + const getSnapshot = vi.fn<[cwd?: string, workspaceId?: string], ProviderSnapshotEntry[]>( + () => [], + ); + const readSnapshot = vi.fn<[unknown], Promise>(async (options) => { + const { cwd, workspaceId } = options as { cwd?: string | null; workspaceId?: string }; + return getSnapshot(cwd ?? undefined, workspaceId); + }); const refreshSnapshotForCwd = vi.fn<[unknown], Promise>(async () => {}); const refreshSettingsSnapshot = vi.fn<[unknown], Promise>(async () => {}); const warmUpSnapshotForCwd = vi.fn<[unknown], Promise>(async () => {}); @@ -218,6 +227,7 @@ export function createProviderSnapshotManagerStub(): { const destroy = vi.fn<[], void>(); const stub = { getSnapshot, + readSnapshot, refreshSnapshotForCwd, refreshSettingsSnapshot, warmUpSnapshotForCwd, @@ -244,6 +254,7 @@ export function createProviderSnapshotManagerStub(): { return { manager, getSnapshot, + readSnapshot, refreshSnapshotForCwd, refreshSettingsSnapshot, warmUpSnapshotForCwd, diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index b14e90c7a0..46fea7271d 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -17,6 +17,7 @@ import { TerminalStreamOpcode, } from "@getpaseo/protocol/terminal-stream-protocol"; import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities"; +import type { WorkspaceRuntimeService } from "./workspace-runtime/index.js"; type SocketListener = (...args: unknown[]) => void; @@ -293,6 +294,12 @@ function createServer(options?: { undefined, undefined, createProviderSnapshotManagerStub().manager, + undefined, + undefined, + undefined, + undefined, + undefined, + createStub({}), ); } diff --git a/packages/workspace-helper/package.json b/packages/workspace-helper/package.json index a1ddf164da..9174d5d059 100644 --- a/packages/workspace-helper/package.json +++ b/packages/workspace-helper/package.json @@ -23,6 +23,7 @@ "clean": "node ../../scripts/clean-package-dist.mjs", "build": "tsc -p tsconfig.json --incremental false && node -e \"const fs=require('node:fs');fs.copyFileSync('src/executable.mjs','dist/executable.mjs');fs.chmodSync('dist/executable.mjs',0o755)\"", "build:clean": "npm run clean && npm run build", + "prepack": "npm run build:clean", "test": "npm run build && vitest run test/workspace-helper.posix.test.ts --bail=1", "test:standalone": "node --test test/standalone.test.mjs", "typecheck": "tsgo -p tsconfig.json --noEmit" diff --git a/packages/workspace-helper/test/standalone.test.mjs b/packages/workspace-helper/test/standalone.test.mjs index 9499eb37ab..655ebb97f5 100644 --- a/packages/workspace-helper/test/standalone.test.mjs +++ b/packages/workspace-helper/test/standalone.test.mjs @@ -7,6 +7,7 @@ import assert from "node:assert/strict"; import { promisify } from "node:util"; const run = promisify(execFile); +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; const packageRoot = path.resolve(import.meta.dirname, ".."); test("packed helper works from a fresh project without monorepo source fallback", async (t) => { @@ -15,10 +16,10 @@ test("packed helper works from a fresh project without monorepo source fallback" const packDirectory = path.join(root, "packs"); const project = path.join(root, "project"); await Promise.all([mkdir(packDirectory), mkdir(project)]); - await run("npm", ["run", "build"], { cwd: packageRoot }); + await run(npmCommand, ["run", "build"], { cwd: packageRoot }); const packed = JSON.parse( ( - await run("npm", ["pack", "--json", "--pack-destination", packDirectory], { + await run(npmCommand, ["pack", "--json", "--pack-destination", packDirectory], { cwd: packageRoot, }) ).stdout, @@ -30,7 +31,7 @@ test("packed helper works from a fresh project without monorepo source fallback" dependencies: { "@getpaseo/workspace-helper": `file:${path.join(packDirectory, packed)}` }, }), ); - await run("npm", ["install", "--ignore-scripts"], { cwd: project }); + await run(npmCommand, ["install", "--ignore-scripts"], { cwd: project }); await writeFile(path.join(project, "notes.txt"), "before\n"); const imported = JSON.parse( diff --git a/packages/workspace-runtime-contract/package.json b/packages/workspace-runtime-contract/package.json index a961c9b6d0..4cc545817b 100644 --- a/packages/workspace-runtime-contract/package.json +++ b/packages/workspace-runtime-contract/package.json @@ -21,6 +21,7 @@ "clean": "node ../../scripts/clean-package-dist.mjs", "build": "tsc -p tsconfig.json --incremental false", "build:clean": "npm run clean && npm run build", + "prepack": "npm run build:clean", "test": "npm run build && tsc -p tsconfig.test.json && node --test test/*.test.mjs", "typecheck": "tsgo -p tsconfig.json --noEmit" }, diff --git a/packages/workspace-runtime-contract/test/contract.test.mjs b/packages/workspace-runtime-contract/test/contract.test.mjs index bd1a198a32..da50e41de3 100644 --- a/packages/workspace-runtime-contract/test/contract.test.mjs +++ b/packages/workspace-runtime-contract/test/contract.test.mjs @@ -33,6 +33,7 @@ import { } from "../dist/index.js"; const run = promisify(execFile); +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const examples = JSON.parse(await readFile(path.join(packageRoot, "examples/v1.json"), "utf8")); @@ -521,7 +522,7 @@ test("every export condition resolves from a packed standalone install", async ( t.after(() => rm(root, { recursive: true, force: true })); const pack = JSON.parse( ( - await run("npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", root], { + await run(npmCommand, ["pack", "--json", "--ignore-scripts", "--pack-destination", root], { cwd: packageRoot, }) ).stdout, @@ -529,7 +530,7 @@ test("every export condition resolves from a packed standalone install", async ( const archive = path.join(root, pack[0].filename); const installRoot = path.join(root, "standalone"); await mkdir(installRoot); - await run("npm", ["install", "--ignore-scripts", "--no-package-lock", archive], { + await run(npmCommand, ["install", "--ignore-scripts", "--no-package-lock", archive], { cwd: installRoot, }); const installed = path.join(installRoot, "node_modules/@getpaseo/workspace-runtime-contract"); diff --git a/scripts/server-clean-build.test.mjs b/scripts/server-clean-build.test.mjs index 150e285d99..d150b3cd40 100644 --- a/scripts/server-clean-build.test.mjs +++ b/scripts/server-clean-build.test.mjs @@ -56,6 +56,58 @@ test("build:server:clean constructs public prerequisites without runtime impleme await assert.rejects(access(path.join(isolatedRoot, "runtimes"))); }); +test("public runtime packages construct complete tarballs from source", async (t) => { + const isolatedRoot = await mkdtemp(path.join(os.tmpdir(), "paseo-runtime-packages-")); + t.after(() => rm(isolatedRoot, { recursive: true, force: true })); + + await copyBuildCheckout(isolatedRoot); + await createNodeModulesOverlay(isolatedRoot); + await createPackageNodeModulesOverlays(isolatedRoot); + + for (const relativePath of [ + "packages/workspace-runtime-contract/dist", + "packages/workspace-helper/dist", + ]) { + await assert.rejects(access(path.join(isolatedRoot, relativePath)), relativePath); + } + + const packDirectory = path.join(isolatedRoot, "packs"); + await mkdir(packDirectory); + const packages = [ + { + workspace: "@getpaseo/workspace-runtime-contract", + files: ["dist/index.js", "dist/index.d.ts"], + }, + { + workspace: "@getpaseo/workspace-helper", + files: ["dist/index.js", "dist/index.d.ts", "dist/executable.mjs"], + executable: "dist/executable.mjs", + }, + ]; + + for (const expected of packages) { + const packed = JSON.parse( + ( + await run( + "npm", + [ + "pack", + "--json", + `--workspace=${expected.workspace}`, + "--pack-destination", + packDirectory, + ], + { cwd: isolatedRoot, timeout: 120_000 }, + ) + ).stdout, + )[0]; + const files = new Map(packed.files.map((file) => [file.path, file])); + for (const expectedFile of expected.files) assert.ok(files.has(expectedFile), expectedFile); + if (expected.executable) assert.equal(files.get(expected.executable)?.mode, 0o755); + await access(path.join(packDirectory, packed.filename)); + } +}); + async function copyBuildCheckout(isolatedRoot) { await Promise.all([ ...["package.json", "package-lock.json", "tsconfig.base.json", "tsconfig.json"].map((file) =>