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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions packages/api/src/agent-workspace-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,14 @@ export type AgentWorkspaceId = cliComponents['schemas']['WorkspaceId'];
* Matches the contract in @kortex-hub/kortex-workspace-configuration.
*/
export type AgentWorkspaceConfiguration = configComponents['schemas']['WorkspaceConfiguration'];

/**
* Options for creating (initializing) a new workspace via `kortex-cli init`.
*/
export interface AgentWorkspaceCreateOptions {
sourcePath: string;
agent: string;
runtime?: string;
name?: string;
project?: string;
}
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,12 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import { parse as parseYAML } from 'yaml';

import type { IPCHandle } from '/@/plugin/api.js';
import type { CliToolRegistry } from '/@/plugin/cli-tool-registry.js';
import type { Proxy } from '/@/plugin/proxy.js';
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
rg -n "import type \{ Proxy( as .+)? \}" packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts
rg -n "as unknown as Proxy\\b" packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts

Repository: kortex-hub/kortex

Length of output: 138


🏁 Script executed:

#!/bin/bash
# Get full context around the import and usage
sed -n '25,65p' packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts | cat -n

# Search for all uses of "Proxy" (not just imports) in the file to ensure fix is complete
rg -n "\bProxy\b" packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts

Repository: kortex-hub/kortex

Length of output: 1701


Rename imported Proxy type to avoid lint-blocking global shadowing.

The Proxy import at line 27 shadows the global Proxy object, triggering lint/suspicious/noShadowRestrictedNames and blocking CI.

Proposed fix
-import type { Proxy } from '/@/plugin/proxy.js';
+import type { Proxy as KortexProxy } from '/@/plugin/proxy.js';
...
-} as unknown as Proxy;
+} as unknown as KortexProxy;
🧰 Tools
🪛 Biome (2.4.9)

[error] 27-27: Do not shadow the global "Proxy" property.

(lint/suspicious/noShadowRestrictedNames)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts` at
line 27, The imported type named Proxy shadows the global Proxy and triggers the
lint rule; rename the import to a non-conflicting alias (e.g., ProxyType) in the
import statement in agent-workspace-manager.spec.ts and update all references to
that type within the file (search for usages of Proxy and replace with the new
alias) so the linter no longer flags noShadowRestrictedNames while preserving
the same type.

import { Exec } from '/@/plugin/util/exec.js';
import type { AgentWorkspaceSummary } from '/@api/agent-workspace-info.js';
import type { AgentWorkspaceCreateOptions, AgentWorkspaceSummary } from '/@api/agent-workspace-info.js';
import type { ApiSenderType } from '/@api/api-sender/api-sender-type.js';
import type { CliToolInfo } from '/@api/cli-tool-info.js';

import { AgentWorkspaceManager } from './agent-workspace-manager.js';

Expand Down Expand Up @@ -59,18 +61,34 @@ const proxy = {
isEnabled: vi.fn().mockReturnValue(false),
} as unknown as Proxy;
const exec = new Exec(proxy);
const cliToolRegistry = {
getCliToolInfos: vi
.fn()
.mockReturnValue([
{ name: 'kortex', path: '/home/user/.config/kortex-extensions/kortex-cli/kortex-cli-package/kortex-cli' },
]),
} as unknown as CliToolRegistry;

const KORTEX_CLI_PATH = '/home/user/.config/kortex-extensions/kortex-cli/kortex-cli-package/kortex-cli';

function mockExecResult(stdout: string): RunResult {
return { command: 'kortex-cli', stdout, stderr: '' };
return { command: KORTEX_CLI_PATH, stdout, stderr: '' };
}

beforeEach(() => {
vi.resetAllMocks();
manager = new AgentWorkspaceManager(apiSender, ipcHandle, exec);
vi.mocked(cliToolRegistry.getCliToolInfos).mockReturnValue([
{ name: 'kortex', path: KORTEX_CLI_PATH },
] as unknown as CliToolInfo[]);
manager = new AgentWorkspaceManager(apiSender, ipcHandle, exec, cliToolRegistry);
manager.init();
});

describe('init', () => {
test('registers IPC handler for create', () => {
expect(ipcHandle).toHaveBeenCalledWith('agent-workspace:create', expect.any(Function));
});

test('registers IPC handler for list', () => {
expect(ipcHandle).toHaveBeenCalledWith('agent-workspace:list', expect.any(Function));
});
Expand All @@ -92,13 +110,90 @@ describe('init', () => {
});
});

describe('getCliPath', () => {
test('falls back to kortex-cli when no CLI tool is registered', async () => {
vi.mocked(cliToolRegistry.getCliToolInfos).mockReturnValue([]);
vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ items: [] })));

await manager.list();

expect(exec.exec).toHaveBeenCalledWith('kortex-cli', ['workspace', 'list', '--output', 'json'], undefined);
});
});

describe('create', () => {
const defaultOptions: AgentWorkspaceCreateOptions = {
sourcePath: '/tmp/my-project',
agent: 'claude',
runtime: 'podman',
};

test('executes kortex-cli init with required flags and returns the workspace id', async () => {
vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ id: 'ws-new' })));

const result = await manager.create(defaultOptions);

expect(exec.exec).toHaveBeenCalledWith(KORTEX_CLI_PATH, [
'init',
'/tmp/my-project',
'--runtime',
'podman',
'--agent',
'claude',
'--output',
'json',
]);
expect(result).toEqual({ id: 'ws-new' });
});

test('defaults runtime to podman when not specified', async () => {
vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ id: 'ws-new' })));

await manager.create({ sourcePath: '/tmp/my-project', agent: 'claude' });

expect(exec.exec).toHaveBeenCalledWith(KORTEX_CLI_PATH, expect.arrayContaining(['--runtime', 'podman']));
});

test('includes optional name flag when provided', async () => {
vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ id: 'ws-new' })));

await manager.create({ ...defaultOptions, name: 'my-workspace' });

expect(exec.exec).toHaveBeenCalledWith(KORTEX_CLI_PATH, expect.arrayContaining(['--name', 'my-workspace']));
});

test('includes optional project flag when provided', async () => {
vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ id: 'ws-new' })));

await manager.create({ ...defaultOptions, project: 'my-project' });

expect(exec.exec).toHaveBeenCalledWith(KORTEX_CLI_PATH, expect.arrayContaining(['--project', 'my-project']));
});

test('emits agent-workspace-update event', async () => {
vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ id: 'ws-new' })));

await manager.create(defaultOptions);

expect(apiSender.send).toHaveBeenCalledWith('agent-workspace-update');
});

test('rejects when source directory does not exist', async () => {
vi.spyOn(exec, 'exec').mockRejectedValue(new Error('sources directory does not exist: /tmp/not-found'));

await expect(manager.create({ ...defaultOptions, sourcePath: '/tmp/not-found' })).rejects.toThrow(
'sources directory does not exist: /tmp/not-found',
);
});
});

describe('list', () => {
test('executes kortex-cli workspace list and returns items', async () => {
vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ items: TEST_SUMMARIES })));

const result = await manager.list();

expect(exec.exec).toHaveBeenCalledWith('kortex-cli', ['workspace', 'list', '--output', 'json']);
expect(exec.exec).toHaveBeenCalledWith(KORTEX_CLI_PATH, ['workspace', 'list', '--output', 'json'], undefined);
expect(result).toHaveLength(2);
expect(result.map(s => s.id)).toEqual(['ws-1', 'ws-2']);
});
Expand Down Expand Up @@ -129,7 +224,11 @@ describe('remove', () => {

const result = await manager.remove('ws-1');

expect(exec.exec).toHaveBeenCalledWith('kortex-cli', ['workspace', 'remove', 'ws-1', '--output', 'json']);
expect(exec.exec).toHaveBeenCalledWith(
KORTEX_CLI_PATH,
['workspace', 'remove', 'ws-1', '--output', 'json'],
undefined,
);
expect(result).toEqual({ id: 'ws-1' });
});

Expand All @@ -156,7 +255,7 @@ describe('getConfiguration', () => {

const result = await manager.getConfiguration('ws-1');

expect(exec.exec).toHaveBeenCalledWith('kortex-cli', ['workspace', 'list', '--output', 'json']);
expect(exec.exec).toHaveBeenCalledWith(KORTEX_CLI_PATH, ['workspace', 'list', '--output', 'json'], undefined);
expect(readFile).toHaveBeenCalledWith('/tmp/ws1/.kortex.yaml', 'utf-8');
expect(parseYAML).toHaveBeenCalledWith('name: test-workspace-1\n');
expect(result).toEqual({ name: 'test-workspace-1' });
Expand Down Expand Up @@ -184,7 +283,11 @@ describe('start', () => {

const result = await manager.start('ws-1');

expect(exec.exec).toHaveBeenCalledWith('kortex-cli', ['workspace', 'start', 'ws-1', '--output', 'json']);
expect(exec.exec).toHaveBeenCalledWith(
KORTEX_CLI_PATH,
['workspace', 'start', 'ws-1', '--output', 'json'],
undefined,
);
expect(result).toEqual({ id: 'ws-1' });
});

Expand All @@ -209,7 +312,11 @@ describe('stop', () => {

const result = await manager.stop('ws-1');

expect(exec.exec).toHaveBeenCalledWith('kortex-cli', ['workspace', 'stop', 'ws-1', '--output', 'json']);
expect(exec.exec).toHaveBeenCalledWith(
KORTEX_CLI_PATH,
['workspace', 'stop', 'ws-1', '--output', 'json'],
undefined,
);
expect(result).toEqual({ id: 'ws-1' });
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ import { inject, injectable, preDestroy } from 'inversify';
import { parse as parseYAML } from 'yaml';

import { IPCHandle } from '/@/plugin/api.js';
import { CliToolRegistry } from '/@/plugin/cli-tool-registry.js';
import { Exec } from '/@/plugin/util/exec.js';
import type {
AgentWorkspaceConfiguration,
AgentWorkspaceCreateOptions,
AgentWorkspaceId,
AgentWorkspaceSummary,
} from '/@api/agent-workspace-info.js';
Expand All @@ -43,11 +45,53 @@ export class AgentWorkspaceManager implements Disposable {
private readonly ipcHandle: IPCHandle,
@inject(Exec)
private readonly exec: Exec,
@inject(CliToolRegistry)
private readonly cliToolRegistry: CliToolRegistry,
) {}

private async execKortex<T>(args: string[]): Promise<T> {
const result = await this.exec.exec('kortex-cli', ['workspace', ...args, '--output', 'json']);
return JSON.parse(result.stdout) as T;
private getCliPath(): string {
const tool = this.cliToolRegistry.getCliToolInfos().find(t => t.name === 'kortex');
if (tool?.path) {
return tool.path;
}
return 'kortex-cli';
}

private async execKortex<T>(args: string[], options?: { cwd?: string }): Promise<T> {
const cliPath = this.getCliPath();
const fullArgs = ['workspace', ...args, '--output', 'json'];
console.log(`Executing: ${cliPath} ${fullArgs.join(' ')}`);
try {
const result = await this.exec.exec(cliPath, fullArgs, options);
return JSON.parse(result.stdout) as T;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.log(`kortex-cli failed: ${cliPath} ${fullArgs.join(' ')} — ${message}`);
throw err;
}
}

async create(options: AgentWorkspaceCreateOptions): Promise<AgentWorkspaceId> {
const cliPath = this.getCliPath();
const runtime = options.runtime ?? 'podman';
const args = ['init', options.sourcePath, '--runtime', runtime, '--agent', options.agent, '--output', 'json'];
if (options.name) {
args.push('--name', options.name);
}
if (options.project) {
args.push('--project', options.project);
}
console.log(`Executing: ${cliPath} ${args.join(' ')}`);
try {
const result = await this.exec.exec(cliPath, args);
const workspaceId = JSON.parse(result.stdout) as AgentWorkspaceId;
this.apiSender.send('agent-workspace-update');
return workspaceId;
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
console.log(`kortex-cli failed: ${cliPath} ${args.join(' ')} — ${message}`);
throw err;
}
}
Comment on lines +74 to 95
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Track init through TaskManager.

This shells out to an external CLI and can easily outlive the form-local spinner, but it never creates a task or publishes status the rest of the app can observe. Mirroring the other main-process CLI flows here would give users durable progress and failure reporting even if they navigate away. As per coding guidelines, "Long-running operations should use TaskManager to create and manage tasks with appropriate status updates".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts` around
lines 74 - 95, The create method shells out via this.exec.exec in
agent-workspace-manager.ts but doesn't use TaskManager so long-running init
calls can outlive UI; update create(AgentWorkspaceCreateOptions) to create and
run a Task via TaskManager (e.g., createTask/runTask) around the call to
this.exec.exec, publish incremental status (starting, success, failure) and wire
cancellation to abort the exec if TaskManager supports it, then send the
existing this.apiSender.send('agent-workspace-update') on success and rethrow
errors after marking the task failed; ensure you reference the create method,
this.exec.exec call, TaskManager APIs you have in the codebase, and keep
existing JSON parsing of result.stdout and error logging behavior.


async list(): Promise<AgentWorkspaceSummary[]> {
Expand Down Expand Up @@ -84,6 +128,13 @@ export class AgentWorkspaceManager implements Disposable {
}

init(): void {
this.ipcHandle(
'agent-workspace:create',
async (_listener: unknown, options: AgentWorkspaceCreateOptions): Promise<AgentWorkspaceId> => {
return this.create(options);
},
);

this.ipcHandle('agent-workspace:list', async (): Promise<AgentWorkspaceSummary[]> => {
return this.list();
});
Expand Down
2 changes: 1 addition & 1 deletion packages/main/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,7 @@ export class PluginSystem {
container.bind<MCPExchanges>(MCPExchanges).toSelf().inSingletonScope();
container.bind<ProviderRegistry>(ProviderRegistry).toSelf().inSingletonScope();
container.bind<MCPManager>(MCPManager).toSelf().inSingletonScope();
container.bind<CliToolRegistry>(CliToolRegistry).toSelf().inSingletonScope();
container.bind<AgentWorkspaceManager>(AgentWorkspaceManager).toSelf().inSingletonScope();
container.bind<FlowManager>(FlowManager).toSelf().inSingletonScope();
container.bind<SkillManager>(SkillManager).toSelf().inSingletonScope();
Expand Down Expand Up @@ -767,7 +768,6 @@ export class PluginSystem {
libpodApiInit.init();

container.bind<AuthenticationImpl>(AuthenticationImpl).toSelf().inSingletonScope();
container.bind<CliToolRegistry>(CliToolRegistry).toSelf().inSingletonScope();
container.bind<ImageCheckerImpl>(ImageCheckerImpl).toSelf().inSingletonScope();
container.bind<ImageFilesRegistry>(ImageFilesRegistry).toSelf().inSingletonScope();
container.bind<Troubleshooting>(Troubleshooting).toSelf().inSingletonScope();
Expand Down
14 changes: 13 additions & 1 deletion packages/preload/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ import type {
import type { DynamicToolUIPart, UIMessageChunk } from 'ai';
import { contextBridge, ipcRenderer } from 'electron';

import type { AgentWorkspaceConfiguration, AgentWorkspaceId, AgentWorkspaceSummary } from '/@api/agent-workspace-info';
import type {
AgentWorkspaceConfiguration,
AgentWorkspaceCreateOptions,
AgentWorkspaceId,
AgentWorkspaceSummary,
} from '/@api/agent-workspace-info';
import type { ApiSenderType } from '/@api/api-sender/api-sender-type';
import type { AuthenticationProviderInfo } from '/@api/authentication/authentication';
import type { DetectFlowFieldsParams, DetectFlowFieldsResult } from '/@api/chat/detect-flow-fields-schema.ts';
Expand Down Expand Up @@ -314,6 +319,13 @@ export function initExposure(): void {
});

// Agent Workspaces
contextBridge.exposeInMainWorld(
'createAgentWorkspace',
async (options: AgentWorkspaceCreateOptions): Promise<AgentWorkspaceId> => {
return ipcInvoke('agent-workspace:create', options);
},
);

contextBridge.exposeInMainWorld('listAgentWorkspaces', async (): Promise<AgentWorkspaceSummary[]> => {
return ipcInvoke('agent-workspace:list');
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,11 @@ function cancel(): void {
handleNavigation({ page: NavigationPage.AGENT_WORKSPACES });
}

function startWorkspace(): void {
if (!sessionName.trim()) return;
let creating = $state(false);
let error = $state('');

async function startWorkspace(): Promise<void> {
if (!sessionName.trim() || !workingDir.trim() || !selectedAgent) return;

const config = {
name: sessionName,
Expand All @@ -126,6 +129,21 @@ function startWorkspace(): void {
mcpServers: selectedMcpIds,
};
console.log('Starting workspace with config:', config);

creating = true;
error = '';
try {
await window.createAgentWorkspace({
sourcePath: workingDir,
agent: selectedAgent,
name: sessionName,
});
Comment on lines +136 to +140
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Forward runtime in the create request.

This call only sends sourcePath, agent, and name. Because AgentWorkspaceManager.create() defaults a missing runtime to 'podman', the new flow still hard-codes the default instead of propagating runtime from the UI.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte`
around lines 136 - 140, The create call in AgentWorkspaceCreate.svelte currently
invokes window.createAgentWorkspace with only sourcePath, agent and name which
causes AgentWorkspaceManager.create() to default runtime to 'podman'; update the
call to include the runtime selected in the UI (e.g., pass runtime: runtime or
runtime: selectedRuntime alongside sourcePath: workingDir, agent: selectedAgent,
name: sessionName) so the UI's runtime selection is forwarded to
window.createAgentWorkspace and ultimately to AgentWorkspaceManager.create().

handleNavigation({ page: NavigationPage.AGENT_WORKSPACES });
} catch (err: unknown) {
error = err instanceof Error ? err.message : String(err);
} finally {
creating = false;
}
}
</script>

Expand Down Expand Up @@ -264,6 +282,10 @@ function startWorkspace(): void {
{/if}
</section>

{#if error}
<div class="text-sm text-red-400 bg-red-900/20 rounded-lg p-3">{error}</div>
{/if}

<!-- Footer actions -->
<div class="flex items-center justify-between pt-4 border-t border-[var(--pd-content-card-border)]">
<div class="flex items-center gap-3 text-sm text-[var(--pd-content-card-text)] opacity-70">
Expand All @@ -272,7 +294,9 @@ function startWorkspace(): void {
</div>
<div class="flex gap-3">
<Button onclick={cancel}>Cancel</Button>
<Button disabled={!sessionName.trim()} onclick={startWorkspace}>Start Workspace</Button>
<Button disabled={!sessionName.trim() || !workingDir.trim() || !selectedAgent || creating} onclick={startWorkspace}>
{creating ? 'Creating...' : 'Start Workspace'}
</Button>
</div>
</div>

Expand Down
Loading