Add a goosed over HTTP integration test, and test the developer tool PATH (#7178)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -28,11 +28,6 @@ Object.defineProperty(window, 'history', {
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('./utils/providerUtils', () => ({
|
||||
initializeSystem: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('./utils/costDatabase', () => ({
|
||||
initializeCostDatabase: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
@@ -34,7 +34,7 @@ import RecipeActivities from './recipes/RecipeActivities';
|
||||
import { useToolCount } from './alerts/useToolCount';
|
||||
import { getThinkingMessage, getTextAndImageContent } from '../types/message';
|
||||
import ParameterInputModal from './ParameterInputModal';
|
||||
import { substituteParameters } from '../utils/providerUtils';
|
||||
import { substituteParameters } from '../utils/parameterSubstitution';
|
||||
import { useModelAndProvider } from './ModelAndProviderContext';
|
||||
import CreateRecipeFromSessionModal from './recipes/CreateRecipeFromSessionModal';
|
||||
import { toastSuccess } from '../toasts';
|
||||
|
||||
@@ -2,12 +2,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { OllamaSetup } from './OllamaSetup';
|
||||
import * as ollamaDetection from '../utils/ollamaDetection';
|
||||
import * as providerUtils from '../utils/providerUtils';
|
||||
import { toastService } from '../toasts';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('../utils/ollamaDetection');
|
||||
vi.mock('../utils/providerUtils');
|
||||
vi.mock('../toasts');
|
||||
|
||||
// Mock useConfig hook
|
||||
@@ -162,8 +160,6 @@ describe('OllamaSetup', () => {
|
||||
});
|
||||
|
||||
it('should handle successful connection', async () => {
|
||||
vi.mocked(providerUtils.initializeSystem).mockResolvedValue(undefined);
|
||||
|
||||
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -174,20 +170,12 @@ describe('OllamaSetup', () => {
|
||||
expect(mockUpsert).toHaveBeenCalledWith('GOOSE_PROVIDER', 'ollama', false);
|
||||
expect(mockUpsert).toHaveBeenCalledWith('GOOSE_MODEL', 'gpt-oss:20b', false);
|
||||
expect(mockUpsert).toHaveBeenCalledWith('OLLAMA_HOST', 'localhost', false);
|
||||
expect(providerUtils.initializeSystem).toHaveBeenCalledWith(
|
||||
'ollama',
|
||||
'gpt-oss:20b',
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(toastService.success).toHaveBeenCalled();
|
||||
expect(mockOnSuccess).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle connection failure', async () => {
|
||||
const testError = new Error('Initialization failed');
|
||||
vi.mocked(providerUtils.initializeSystem).mockRejectedValue(testError);
|
||||
|
||||
render(<OllamaSetup onSuccess={mockOnSuccess} onCancel={mockOnCancel} />);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Card } from '../ui/card';
|
||||
import GooseLogo from '../GooseLogo';
|
||||
import MarkdownContent from '../MarkdownContent';
|
||||
import { substituteParameters } from '../../utils/providerUtils';
|
||||
import { substituteParameters } from '../../utils/parameterSubstitution';
|
||||
|
||||
interface RecipeActivitiesProps {
|
||||
append: (text: string) => void;
|
||||
|
||||
+304
-233
@@ -1,16 +1,21 @@
|
||||
import Electron from 'electron';
|
||||
import fs from 'node:fs';
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import { createServer } from 'net';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import log from './utils/logger';
|
||||
import { App } from 'electron';
|
||||
import { createServer } from 'net';
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
import { status } from './api';
|
||||
import { Client } from './api/client';
|
||||
import { ExternalGoosedConfig } from './utils/settings';
|
||||
import { Client, createClient, createConfig } from './api/client';
|
||||
|
||||
export interface Logger {
|
||||
info: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
}
|
||||
|
||||
export const defaultLogger: Logger = {
|
||||
info: (...args) => console.log('[goosed]', ...args),
|
||||
error: (...args) => console.error('[goosed]', ...args),
|
||||
};
|
||||
|
||||
export const findAvailablePort = (): Promise<number> => {
|
||||
return new Promise((resolve, _reject) => {
|
||||
@@ -19,244 +24,310 @@ export const findAvailablePort = (): Promise<number> => {
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const { port } = server.address() as { port: number };
|
||||
server.close(() => {
|
||||
log.info(`Found available port: ${port}`);
|
||||
resolve(port);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Check if goosed server is ready by polling the status endpoint
|
||||
export const checkServerStatus = async (client: Client, errorLog: string[]): Promise<boolean> => {
|
||||
const interval = 100; // ms
|
||||
const maxAttempts = 100; // 10s
|
||||
export interface FindBinaryOptions {
|
||||
isPackaged?: boolean;
|
||||
resourcesPath?: string;
|
||||
}
|
||||
|
||||
const fatal = (line: string) => {
|
||||
const trimmed = line.trim().toLowerCase();
|
||||
return trimmed.startsWith("thread 'main' panicked at") || trimmed.startsWith('error:');
|
||||
};
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
if (errorLog.some(fatal)) {
|
||||
log.error('Detected fatal error in server logs');
|
||||
return false;
|
||||
export const findGoosedBinaryPath = (options: FindBinaryOptions = {}): string => {
|
||||
const pathFromEnv = process.env.GOOSED_BINARY;
|
||||
if (pathFromEnv) {
|
||||
if (fs.existsSync(pathFromEnv) && fs.statSync(pathFromEnv).isFile()) {
|
||||
return path.resolve(pathFromEnv);
|
||||
} else {
|
||||
throw new Error(`Invalid GOOSED_BINARY path: ${pathFromEnv} (pwd is ${process.cwd()})`);
|
||||
}
|
||||
}
|
||||
const { isPackaged = false, resourcesPath } = options;
|
||||
const binaryName = process.platform === 'win32' ? 'goosed.exe' : 'goosed';
|
||||
|
||||
const possiblePaths: string[] = [];
|
||||
|
||||
// Packaged app paths
|
||||
if (isPackaged && resourcesPath) {
|
||||
possiblePaths.push(path.join(resourcesPath, 'bin', binaryName));
|
||||
possiblePaths.push(path.join(resourcesPath, binaryName));
|
||||
}
|
||||
|
||||
// Development paths
|
||||
possiblePaths.push(
|
||||
path.join(process.cwd(), 'src', 'bin', binaryName),
|
||||
path.join(process.cwd(), '..', '..', 'target', 'release', binaryName),
|
||||
path.join(process.cwd(), '..', '..', 'target', 'debug', binaryName)
|
||||
);
|
||||
|
||||
for (const p of possiblePaths) {
|
||||
try {
|
||||
await status({ client, throwOnError: true });
|
||||
return true;
|
||||
if (fs.existsSync(p) && fs.statSync(p).isFile()) {
|
||||
return p;
|
||||
}
|
||||
} catch {
|
||||
if (attempt === maxAttempts) {
|
||||
log.error(`Server failed to respond after ${(interval * maxAttempts) / 1000} seconds`);
|
||||
}
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
export interface GoosedResult {
|
||||
baseUrl: string;
|
||||
workingDir: string;
|
||||
process: ChildProcess;
|
||||
errorLog: string[];
|
||||
}
|
||||
|
||||
const connectToExternalBackend = (workingDir: string, url: string): GoosedResult => {
|
||||
log.info(`Using external goosed backend at ${url}`);
|
||||
|
||||
const mockProcess = {
|
||||
pid: undefined,
|
||||
kill: () => {
|
||||
log.info(`Not killing external process that is managed externally`);
|
||||
},
|
||||
} as ChildProcess;
|
||||
|
||||
return { baseUrl: url, workingDir, process: mockProcess, errorLog: [] };
|
||||
};
|
||||
|
||||
interface GooseProcessEnv {
|
||||
[key: string]: string | undefined;
|
||||
|
||||
HOME: string;
|
||||
USERPROFILE: string;
|
||||
APPDATA: string;
|
||||
LOCALAPPDATA: string;
|
||||
PATH: string;
|
||||
GOOSE_PORT: string;
|
||||
GOOSE_SERVER__SECRET_KEY?: string;
|
||||
}
|
||||
|
||||
export interface StartGoosedOptions {
|
||||
app: App;
|
||||
serverSecret: string;
|
||||
dir: string;
|
||||
env?: Partial<GooseProcessEnv>;
|
||||
externalGoosed?: ExternalGoosedConfig;
|
||||
}
|
||||
|
||||
export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedResult> => {
|
||||
const { app, serverSecret, dir: inputDir, env = {}, externalGoosed } = options;
|
||||
const isWindows = process.platform === 'win32';
|
||||
const homeDir = os.homedir();
|
||||
const dir = path.resolve(path.normalize(inputDir));
|
||||
|
||||
if (externalGoosed?.enabled && externalGoosed.url) {
|
||||
return connectToExternalBackend(dir, externalGoosed.url);
|
||||
}
|
||||
|
||||
if (process.env.GOOSE_EXTERNAL_BACKEND) {
|
||||
const port = process.env.GOOSE_PORT || '3000';
|
||||
return connectToExternalBackend(dir, `http://127.0.0.1:${port}`);
|
||||
}
|
||||
|
||||
let goosedPath = getGoosedBinaryPath(app);
|
||||
|
||||
const resolvedGoosedPath = path.resolve(goosedPath);
|
||||
|
||||
const port = await findAvailablePort();
|
||||
const stderrLines: string[] = [];
|
||||
|
||||
log.info(`Starting goosed from: ${resolvedGoosedPath} on port ${port} in dir ${dir}`);
|
||||
|
||||
const additionalEnv: GooseProcessEnv = {
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir,
|
||||
APPDATA: process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'),
|
||||
LOCALAPPDATA: process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local'),
|
||||
PATH: `${path.dirname(resolvedGoosedPath)}${path.delimiter}${process.env.PATH || ''}`,
|
||||
GOOSE_PORT: String(port),
|
||||
GOOSE_SERVER__SECRET_KEY: serverSecret,
|
||||
...env,
|
||||
} as GooseProcessEnv;
|
||||
|
||||
const processEnv: GooseProcessEnv = { ...process.env, ...additionalEnv } as GooseProcessEnv;
|
||||
|
||||
if (isWindows && !resolvedGoosedPath.toLowerCase().endsWith('.exe')) {
|
||||
goosedPath = resolvedGoosedPath + '.exe';
|
||||
} else {
|
||||
goosedPath = resolvedGoosedPath;
|
||||
}
|
||||
log.info(`Binary path resolved to: ${goosedPath}`);
|
||||
|
||||
const spawnOptions = {
|
||||
cwd: dir,
|
||||
env: processEnv,
|
||||
stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
detached: isWindows,
|
||||
shell: false,
|
||||
};
|
||||
|
||||
const safeSpawnOptions = {
|
||||
...spawnOptions,
|
||||
env: Object.keys(spawnOptions.env || {}).reduce(
|
||||
(acc, key) => {
|
||||
if (key.includes('SECRET') || key.includes('PASSWORD') || key.includes('TOKEN')) {
|
||||
acc[key] = '[REDACTED]';
|
||||
} else {
|
||||
acc[key] = spawnOptions.env![key] || '';
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
};
|
||||
log.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));
|
||||
|
||||
const safeArgs = ['agent'];
|
||||
|
||||
const goosedProcess: ChildProcess = spawn(goosedPath, safeArgs, spawnOptions);
|
||||
|
||||
if (isWindows && goosedProcess.unref) {
|
||||
goosedProcess.unref();
|
||||
}
|
||||
|
||||
goosedProcess.stdout?.on('data', (data: Buffer) => {
|
||||
log.info(`goosed stdout for port ${port} and dir ${dir}: ${data.toString()}`);
|
||||
});
|
||||
|
||||
goosedProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data
|
||||
.toString()
|
||||
.split('\n')
|
||||
.filter((l) => l.trim());
|
||||
lines.forEach((line) => {
|
||||
log.error(`goosed stderr for port ${port} and dir ${dir}: ${line}`);
|
||||
stderrLines.push(line);
|
||||
});
|
||||
});
|
||||
|
||||
goosedProcess.on('close', (code: number | null) => {
|
||||
log.info(`goosed process exited with code ${code} for port ${port} and dir ${dir}`);
|
||||
});
|
||||
|
||||
goosedProcess.on('error', (err: Error) => {
|
||||
log.error(`Failed to start goosed on port ${port} and dir ${dir}`, err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
const try_kill_goose = () => {
|
||||
try {
|
||||
if (isWindows) {
|
||||
const pid = goosedProcess.pid?.toString() || '0';
|
||||
spawn('taskkill', ['/pid', pid, '/T', '/F'], { shell: false });
|
||||
} else {
|
||||
goosedProcess.kill?.();
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('Error while terminating goosed process:', error);
|
||||
}
|
||||
};
|
||||
|
||||
app.on('will-quit', () => {
|
||||
log.info('App quitting, terminating goosed server');
|
||||
try_kill_goose();
|
||||
});
|
||||
|
||||
log.info(`Goosed server successfully started on port ${port}`);
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
workingDir: dir,
|
||||
process: goosedProcess,
|
||||
errorLog: stderrLines,
|
||||
};
|
||||
};
|
||||
|
||||
const getGoosedBinaryPath = (app: Electron.App): string => {
|
||||
let executableName = process.platform === 'win32' ? 'goosed.exe' : 'goosed';
|
||||
|
||||
let possiblePaths: string[];
|
||||
if (!app.isPackaged) {
|
||||
possiblePaths = [
|
||||
path.join(process.cwd(), 'src', 'bin', executableName),
|
||||
path.join(process.cwd(), 'bin', executableName),
|
||||
path.join(process.cwd(), '..', '..', 'target', 'debug', executableName),
|
||||
path.join(process.cwd(), '..', '..', 'target', 'release', executableName),
|
||||
];
|
||||
} else {
|
||||
possiblePaths = [path.join(process.resourcesPath, 'bin', executableName)];
|
||||
}
|
||||
|
||||
for (const binPath of possiblePaths) {
|
||||
try {
|
||||
const resolvedPath = path.resolve(binPath);
|
||||
|
||||
if (fs.existsSync(resolvedPath)) {
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (stats.isFile()) {
|
||||
return resolvedPath;
|
||||
} else {
|
||||
log.error(`Path exists but is not a regular file: ${resolvedPath}`);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(`Error checking path ${binPath}:`, error);
|
||||
// continue
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Could not find ${executableName} binary in any of the expected locations: ${possiblePaths.join(
|
||||
', '
|
||||
)}`
|
||||
`Goosed binary not found in any of the possible paths: ${possiblePaths.join(', ')}`
|
||||
);
|
||||
};
|
||||
|
||||
export const checkServerStatus = async (client: Client, errorLog: string[]): Promise<boolean> => {
|
||||
const timeout = 10000;
|
||||
const interval = 100;
|
||||
const maxAttempts = Math.ceil(timeout / interval);
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
if (errorLog.some(isFatalError)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await status({ client, throwOnError: true });
|
||||
return true;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, interval));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
export const isFatalError = (line: string): boolean => {
|
||||
const fatalPatterns = [/panicked at/, /RUST_BACKTRACE/, /fatal error/i];
|
||||
return fatalPatterns.some((pattern) => pattern.test(line));
|
||||
};
|
||||
|
||||
export const buildGoosedEnv = (
|
||||
port: number,
|
||||
secretKey: string,
|
||||
binaryPath?: string
|
||||
): Record<string, string> => {
|
||||
// Environment variable naming follows the config crate convention:
|
||||
// - GOOSE_ prefix with _ separator for top-level fields (GOOSE_PORT, GOOSE_HOST)
|
||||
// - __ separator for nested fields (GOOSE_SERVER__SECRET_KEY)
|
||||
const homeDir = process.env.HOME || os.homedir();
|
||||
const env: Record<string, string> = {
|
||||
GOOSE_PORT: port.toString(),
|
||||
GOOSE_SERVER__SECRET_KEY: secretKey,
|
||||
HOME: homeDir,
|
||||
};
|
||||
|
||||
// Windows-specific environment variables
|
||||
if (process.platform === 'win32') {
|
||||
env.USERPROFILE = homeDir;
|
||||
env.APPDATA = process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming');
|
||||
env.LOCALAPPDATA = process.env.LOCALAPPDATA || path.join(homeDir, 'AppData', 'Local');
|
||||
}
|
||||
|
||||
// Add binary directory to PATH for any dependencies
|
||||
const pathKey = process.platform === 'win32' ? 'Path' : 'PATH';
|
||||
const currentPath = process.env[pathKey] || '';
|
||||
if (binaryPath) {
|
||||
env[pathKey] = `${path.dirname(binaryPath)}${path.delimiter}${currentPath}`;
|
||||
} else if (currentPath) {
|
||||
env[pathKey] = currentPath;
|
||||
}
|
||||
|
||||
return env;
|
||||
};
|
||||
|
||||
// Configuration for external goosed server
|
||||
export interface ExternalGoosedConfig {
|
||||
enabled: boolean;
|
||||
url?: string;
|
||||
secret?: string;
|
||||
}
|
||||
|
||||
export interface StartGoosedOptions {
|
||||
dir?: string;
|
||||
serverSecret: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
externalGoosed?: ExternalGoosedConfig;
|
||||
isPackaged?: boolean;
|
||||
resourcesPath?: string;
|
||||
logger?: Logger;
|
||||
}
|
||||
|
||||
export interface GoosedResult {
|
||||
baseUrl: string;
|
||||
workingDir: string;
|
||||
process: ChildProcess | null;
|
||||
errorLog: string[];
|
||||
cleanup: () => Promise<void>;
|
||||
client: Client;
|
||||
}
|
||||
|
||||
const goosedClientForUrlAndSecret = (url: string, secret: string): Client => {
|
||||
return createClient(
|
||||
createConfig({
|
||||
baseUrl: url,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': secret,
|
||||
},
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
export const startGoosed = async (options: StartGoosedOptions): Promise<GoosedResult> => {
|
||||
const {
|
||||
dir,
|
||||
isPackaged = false,
|
||||
resourcesPath,
|
||||
serverSecret,
|
||||
env: additionalEnv = {},
|
||||
externalGoosed,
|
||||
logger = defaultLogger,
|
||||
} = options;
|
||||
|
||||
const errorLog: string[] = [];
|
||||
const workingDir = dir || os.homedir();
|
||||
|
||||
if (externalGoosed?.enabled && externalGoosed.url) {
|
||||
const url = externalGoosed.url.replace(/\/$/, '');
|
||||
logger.info(`Using external goosed backend at ${url}`);
|
||||
|
||||
return {
|
||||
baseUrl: url,
|
||||
workingDir,
|
||||
process: null,
|
||||
errorLog,
|
||||
cleanup: async () => {
|
||||
logger.info('Not killing external process that is managed externally');
|
||||
},
|
||||
client: goosedClientForUrlAndSecret(url, serverSecret),
|
||||
};
|
||||
}
|
||||
|
||||
if (process.env.GOOSE_EXTERNAL_BACKEND) {
|
||||
const port = process.env.GOOSE_PORT || '3000';
|
||||
const url = `http://127.0.0.1:${port}`;
|
||||
logger.info(`Using external goosed backend from env at ${url}`);
|
||||
|
||||
return {
|
||||
baseUrl: url,
|
||||
workingDir,
|
||||
process: null,
|
||||
errorLog,
|
||||
cleanup: async () => {
|
||||
logger.info('Not killing external process that is managed externally');
|
||||
},
|
||||
client: goosedClientForUrlAndSecret(url, serverSecret),
|
||||
};
|
||||
}
|
||||
|
||||
const goosedPath = findGoosedBinaryPath({ isPackaged, resourcesPath });
|
||||
|
||||
const port = await findAvailablePort();
|
||||
logger.info(`Starting goosed from: ${goosedPath} on port ${port} in dir ${workingDir}`);
|
||||
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
const spawnEnv = {
|
||||
...process.env,
|
||||
...buildGoosedEnv(port, serverSecret, goosedPath),
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(additionalEnv)) {
|
||||
if (value !== undefined) {
|
||||
spawnEnv[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
const spawnOptions = {
|
||||
env: spawnEnv,
|
||||
cwd: workingDir,
|
||||
windowsHide: true,
|
||||
detached: isWindows,
|
||||
shell: false as const,
|
||||
stdio: ['ignore', 'pipe', 'pipe'] as ['ignore', 'pipe', 'pipe'],
|
||||
};
|
||||
|
||||
const safeSpawnOptions = {
|
||||
...spawnOptions,
|
||||
env: Object.fromEntries(
|
||||
Object.entries(spawnOptions.env).map(([k, v]) =>
|
||||
k.toLowerCase().includes('secret') || k.toLowerCase().includes('key')
|
||||
? [k, '[REDACTED]']
|
||||
: [k, v]
|
||||
)
|
||||
),
|
||||
};
|
||||
logger.info('Spawn options:', JSON.stringify(safeSpawnOptions, null, 2));
|
||||
|
||||
const goosedProcess = spawn(goosedPath, ['agent'], spawnOptions);
|
||||
|
||||
goosedProcess.stdout?.on('data', (data: Buffer) => {
|
||||
logger.info(`goosed stdout for port ${port} and dir ${workingDir}: ${data.toString()}`);
|
||||
});
|
||||
|
||||
goosedProcess.stderr?.on('data', (data: Buffer) => {
|
||||
const lines = data.toString().split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
errorLog.push(line);
|
||||
if (isFatalError(line)) {
|
||||
logger.error(`goosed stderr for port ${port} and dir ${workingDir}: ${line}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
goosedProcess.on('exit', (code) => {
|
||||
logger.info(`goosed process exited with code ${code} for port ${port} and dir ${workingDir}`);
|
||||
});
|
||||
|
||||
goosedProcess.on('error', (err) => {
|
||||
logger.error(`Failed to start goosed on port ${port} and dir ${workingDir}`, err);
|
||||
errorLog.push(err.message);
|
||||
});
|
||||
|
||||
const cleanup = async (): Promise<void> => {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (!goosedProcess || goosedProcess.killed) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
goosedProcess.on('close', () => {
|
||||
resolve();
|
||||
});
|
||||
|
||||
logger.info('Terminating goosed server');
|
||||
try {
|
||||
if (process.platform === 'win32') {
|
||||
spawn('taskkill', ['/pid', goosedProcess.pid!.toString(), '/f', '/t']);
|
||||
} else {
|
||||
goosedProcess.kill('SIGTERM');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error while terminating goosed process:', error);
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if (goosedProcess && !goosedProcess.killed && process.platform !== 'win32') {
|
||||
goosedProcess.kill('SIGKILL');
|
||||
}
|
||||
resolve();
|
||||
}, 5000);
|
||||
});
|
||||
};
|
||||
|
||||
logger.info(`Goosed server successfully started on port ${port}`);
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
workingDir,
|
||||
process: goosedProcess,
|
||||
errorLog,
|
||||
cleanup,
|
||||
client: goosedClientForUrlAndSecret(baseUrl, serverSecret),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Recipe, scanRecipe } from '../recipe';
|
||||
import { createUserMessage } from '../types/message';
|
||||
import { Message } from '../api';
|
||||
|
||||
import { substituteParameters } from '../utils/providerUtils';
|
||||
import { substituteParameters } from '../utils/parameterSubstitution';
|
||||
import { updateSessionUserRecipeValues } from '../api';
|
||||
import { useChatContext } from '../contexts/ChatContext';
|
||||
import { ChatType } from '../types/chat';
|
||||
|
||||
+18
-13
@@ -23,7 +23,8 @@ import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { spawn } from 'child_process';
|
||||
import 'dotenv/config';
|
||||
import { checkServerStatus, startGoosed } from './goosed';
|
||||
import { checkServerStatus } from './goosed';
|
||||
import { startGoosed } from './goosed';
|
||||
import { expandTilde } from './utils/pathUtils';
|
||||
import log from './utils/logger';
|
||||
import { ensureWinShims } from './utils/winShims';
|
||||
@@ -43,7 +44,7 @@ import {
|
||||
} from './utils/autoUpdater';
|
||||
import { UPDATES_ENABLED } from './updates';
|
||||
import './utils/recipeHash';
|
||||
import { Client, createClient, createConfig } from './api/client';
|
||||
import { Client } from './api/client';
|
||||
import { GooseApp } from './api';
|
||||
import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer';
|
||||
import { BLOCKED_PROTOCOLS, WEB_PROTOCOLS } from './utils/urlSecurity';
|
||||
@@ -483,14 +484,27 @@ const createChat = async (
|
||||
const serverSecret = getServerSecret(settings);
|
||||
|
||||
const goosedResult = await startGoosed({
|
||||
app,
|
||||
serverSecret,
|
||||
dir: dir || os.homedir(),
|
||||
env: { GOOSE_PATH_ROOT: process.env.GOOSE_PATH_ROOT },
|
||||
externalGoosed: settings.externalGoosed,
|
||||
isPackaged: app.isPackaged,
|
||||
resourcesPath: app.isPackaged ? process.resourcesPath : undefined,
|
||||
logger: log,
|
||||
});
|
||||
|
||||
const { baseUrl, workingDir, process: goosedProcess, errorLog } = goosedResult;
|
||||
app.on('will-quit', async () => {
|
||||
log.info('App quitting, terminating goosed server');
|
||||
await goosedResult.cleanup();
|
||||
});
|
||||
|
||||
const {
|
||||
baseUrl,
|
||||
workingDir,
|
||||
process: goosedProcess,
|
||||
errorLog,
|
||||
client: goosedClient,
|
||||
} = goosedResult;
|
||||
|
||||
const mainWindowState = windowStateKeeper({
|
||||
defaultWidth: 940,
|
||||
@@ -543,15 +557,6 @@ const createChat = async (
|
||||
.catch((err) => log.info('failed to install react dev tools:', err));
|
||||
}
|
||||
|
||||
const goosedClient = createClient(
|
||||
createConfig({
|
||||
baseUrl,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Secret-Key': serverSecret,
|
||||
},
|
||||
})
|
||||
);
|
||||
goosedClients.set(mainWindow.id, goosedClient);
|
||||
|
||||
const serverReady = await checkServerStatus(goosedClient, errorLog);
|
||||
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { substituteParameters } from '../providerUtils';
|
||||
import { substituteParameters } from '../parameterSubstitution';
|
||||
|
||||
describe('providerUtils', () => {
|
||||
describe('parameterSubstitution', () => {
|
||||
describe('substituteParameters', () => {
|
||||
it('should substitute simple parameters', () => {
|
||||
const text = 'Hello {{name}}, welcome to {{app}}!';
|
||||
@@ -83,12 +83,12 @@ describe('providerUtils', () => {
|
||||
it('should handle complex substitution scenario', () => {
|
||||
const text = `
|
||||
Welcome {{user_name}}!
|
||||
|
||||
|
||||
Your account details:
|
||||
- ID: {{user_id}}
|
||||
- Email: {{user_email}}
|
||||
- App: {{app_name}}
|
||||
|
||||
|
||||
Thank you for using {{app_name}}!
|
||||
`;
|
||||
|
||||
@@ -102,12 +102,12 @@ describe('providerUtils', () => {
|
||||
const result = substituteParameters(text, params);
|
||||
const expected = `
|
||||
Welcome John Doe!
|
||||
|
||||
|
||||
Your account details:
|
||||
- ID: 12345
|
||||
- Email: john@example.com
|
||||
- App: MyApp
|
||||
|
||||
|
||||
Thank you for using MyApp!
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
export const substituteParameters = (text: string, params: Record<string, string>): string => {
|
||||
let substitutedText = text;
|
||||
|
||||
for (const key in params) {
|
||||
// Escape special characters in the key (parameter) and match optional whitespace
|
||||
const regex = new RegExp(`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*}}`, 'g');
|
||||
substitutedText = substitutedText.replace(regex, params[key]);
|
||||
}
|
||||
|
||||
return substitutedText;
|
||||
};
|
||||
@@ -1,77 +0,0 @@
|
||||
import {
|
||||
initializeBundledExtensions,
|
||||
syncBundledExtensions,
|
||||
} from '../components/settings/extensions';
|
||||
import type { ExtensionConfig, FixedExtensionEntry } from '../components/ConfigContext';
|
||||
import { Recipe, updateAgentProvider, updateFromSession } from '../api';
|
||||
|
||||
// Helper function to substitute parameters in text
|
||||
export const substituteParameters = (text: string, params: Record<string, string>): string => {
|
||||
let substitutedText = text;
|
||||
|
||||
for (const key in params) {
|
||||
// Escape special characters in the key (parameter) and match optional whitespace
|
||||
const regex = new RegExp(`{{\\s*${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*}}`, 'g');
|
||||
substitutedText = substitutedText.replace(regex, params[key]);
|
||||
}
|
||||
|
||||
return substitutedText;
|
||||
};
|
||||
|
||||
export const initializeSystem = async (
|
||||
sessionId: string,
|
||||
provider: string,
|
||||
model: string,
|
||||
options?: {
|
||||
getExtensions?: (b: boolean) => Promise<FixedExtensionEntry[]>;
|
||||
addExtension?: (name: string, config: ExtensionConfig, enabled: boolean) => Promise<void>;
|
||||
recipeParameters?: Record<string, string> | null;
|
||||
recipe?: Recipe;
|
||||
}
|
||||
) => {
|
||||
try {
|
||||
console.log(
|
||||
'initializing agent with provider',
|
||||
provider,
|
||||
'model',
|
||||
model,
|
||||
'sessionId',
|
||||
sessionId
|
||||
);
|
||||
await updateAgentProvider({
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
provider,
|
||||
model,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
if (!sessionId) {
|
||||
console.log('This will not end well');
|
||||
}
|
||||
await updateFromSession({
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
throwOnError: true,
|
||||
});
|
||||
|
||||
if (!options?.getExtensions || !options?.addExtension) {
|
||||
console.warn('Extension helpers not provided in alpha mode');
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize or sync built-in extensions into config.yaml
|
||||
let refreshedExtensions = await options.getExtensions(false);
|
||||
|
||||
if (refreshedExtensions.length === 0) {
|
||||
await initializeBundledExtensions(options.addExtension);
|
||||
} else {
|
||||
await syncBundledExtensions(refreshedExtensions, options.addExtension);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize agent:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user