Skip to content

Commit 73d75c1

Browse files
committed
fix: add scripts IPC infrastructure after rebase
- Add WORKSPACE_LIST_SCRIPTS and WORKSPACE_EXECUTE_SCRIPT IPC channels - Add listScripts and executeScript to IPCApi interface - Add IPC handlers in ipcMain.ts for scripts discovery and execution - Add preload and browser API bindings for scripts - Fix test imports (WorkspaceChatMessage, DeleteMessage from ipc.ts) - Fix WorkspaceStore.test.ts mock setup for correct callback pattern - Add script stubs to App.stories.tsx mocks This restores scripts functionality after the oRPC system was removed from main. Scripts are now accessible via standard IPC channels. Change-Id: I10cf7e1201020efdbc0ffde0d6a89a0464d0d7a7 Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 89843b0 commit 73d75c1

File tree

8 files changed

+131
-45
lines changed

8 files changed

+131
-45
lines changed

src/browser/App.stories.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ function setupMockAPI(options: {
8585
success: true,
8686
data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 },
8787
}),
88+
listScripts: () => Promise.resolve({ success: true, data: [] }),
89+
executeScript: () =>
90+
Promise.resolve({
91+
success: true,
92+
data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 },
93+
}),
8894
},
8995
projects: {
9096
list: () => Promise.resolve(Array.from(mockProjects.entries())),
@@ -1255,6 +1261,12 @@ main
12551261
data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 },
12561262
});
12571263
},
1264+
listScripts: () => Promise.resolve({ success: true, data: [] }),
1265+
executeScript: () =>
1266+
Promise.resolve({
1267+
success: true,
1268+
data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 },
1269+
}),
12581270
},
12591271
},
12601272
});
@@ -1463,6 +1475,12 @@ These tables should render cleanly without any disruptive copy or download actio
14631475
success: true,
14641476
data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 },
14651477
}),
1478+
listScripts: () => Promise.resolve({ success: true, data: [] }),
1479+
executeScript: () =>
1480+
Promise.resolve({
1481+
success: true,
1482+
data: { success: true, output: "", exitCode: 0, wall_duration_ms: 0 },
1483+
}),
14661484
},
14671485
},
14681486
});

src/browser/api.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,10 @@ const webApi: IPCApi = {
274274
executeBash: (workspaceId, script, options) =>
275275
invokeIPC(IPC_CHANNELS.WORKSPACE_EXECUTE_BASH, workspaceId, script, options),
276276
openTerminal: (workspaceId) => invokeIPC(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspaceId),
277+
listScripts: (workspaceId) =>
278+
invokeIPC(IPC_CHANNELS.WORKSPACE_LIST_SCRIPTS, workspaceId),
279+
executeScript: (workspaceId, scriptName, args) =>
280+
invokeIPC(IPC_CHANNELS.WORKSPACE_EXECUTE_SCRIPT, workspaceId, scriptName, args),
277281
activity: {
278282
list: async (): Promise<Record<string, WorkspaceActivitySnapshot>> => {
279283
const response = await invokeIPC<Record<string, unknown>>(

src/browser/stores/WorkspaceStore.test.ts

Lines changed: 24 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FrontendWorkspaceMetadata } from "@/common/types/workspace";
2-
import type { WorkspaceChatMessage } from "@/common/orpc/types";
2+
import type { WorkspaceChatMessage } from "@/common/types/ipc";
33
import { createMuxMessage } from "@/common/types/message";
44
import type { BashToolResult } from "@/common/types/tools";
55
import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
@@ -43,14 +43,8 @@ global.window = mockWindow as unknown as Window & typeof globalThis;
4343
// Mock dispatchEvent
4444
global.window.dispatchEvent = jest.fn();
4545

46-
// Helper to get IPC callback in a type-safe way
47-
function getOnChatCallback<T = { type: string }>(): (data: T) => void {
48-
const mock = mockWindow.api.workspace.onChat as jest.Mock<
49-
() => void,
50-
[string, (data: T) => void]
51-
>;
52-
return mock.mock.calls[0][1];
53-
}
46+
// Reference to mock for easier access
47+
const mockOnChat = mockWindow.api.workspace.onChat as jest.Mock;
5448

5549
// Helper to create and add a workspace
5650
function createAndAddWorkspace(
@@ -72,36 +66,24 @@ function createAndAddWorkspace(
7266
}
7367

7468
// Helper to get callback from mock for pushing messages
75-
let pendingMessages: WorkspaceChatMessage[] = [];
76-
let resolvers: Array<(msg: WorkspaceChatMessage) => void> = [];
77-
78-
function getOnChatCallback<T extends WorkspaceChatMessage>(): (msg: T) => void {
79-
return (msg: T) => {
80-
if (resolvers.length > 0) {
81-
const resolver = resolvers.shift()!;
82-
resolver(msg);
83-
} else {
84-
pendingMessages.push(msg);
85-
}
86-
};
87-
}
8869

89-
// Set up mock to use push-based message queue
90-
mockOnChat.mockImplementation(async function* (): AsyncGenerator<
91-
WorkspaceChatMessage,
92-
void,
93-
unknown
94-
> {
95-
while (true) {
96-
if (pendingMessages.length > 0) {
97-
yield pendingMessages.shift()!;
98-
} else {
99-
const msg = await new Promise<WorkspaceChatMessage>((resolve) => {
100-
resolvers.push(resolve);
101-
});
102-
yield msg;
103-
}
70+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
71+
function getOnChatCallback<T = any>(): (msg: T) => void {
72+
if (!currentChatCallback) {
73+
throw new Error("No chat callback registered - was addWorkspace called?");
10474
}
75+
return currentChatCallback as (msg: T) => void;
76+
}
77+
78+
// Track current chat callback for tests to push messages
79+
let currentChatCallback: ((msg: WorkspaceChatMessage) => void) | null = null;
80+
81+
// Set up mock to capture the callback and allow tests to push messages
82+
mockOnChat.mockImplementation((_workspaceId: string, callback: (msg: WorkspaceChatMessage) => void) => {
83+
currentChatCallback = callback;
84+
return () => {
85+
currentChatCallback = null;
86+
};
10587
});
10688

10789
describe("WorkspaceStore", () => {
@@ -112,8 +94,7 @@ describe("WorkspaceStore", () => {
11294
jest.clearAllMocks();
11395
mockExecuteBash.mockClear();
11496
mockOnChat.mockClear();
115-
pendingMessages = [];
116-
resolvers = [];
97+
currentChatCallback = null;
11798
mockOnModelUsed = jest.fn();
11899
store = new WorkspaceStore(mockOnModelUsed);
119100
});
@@ -279,16 +260,15 @@ describe("WorkspaceStore", () => {
279260
runtimeConfig: DEFAULT_RUNTIME_CONFIG,
280261
};
281262

282-
// Add workspace
263+
// Add workspace - this will set currentChatCallback
283264
store.addWorkspace(metadata1);
284-
const unsubscribeSpy = jest.fn();
285-
(mockWindow.api.workspace.onChat as jest.Mock).mockReturnValue(unsubscribeSpy);
286265

287266
// Sync with empty map (removes all workspaces)
267+
// This should unsubscribe from the workspace
288268
store.syncWorkspaces(new Map());
289269

290-
// Note: The unsubscribe function from the first add won't be captured
291-
// since we mocked it before. In real usage, this would be called.
270+
// Verify workspace was removed by checking states
271+
expect(store.getAllStates().size).toBe(0);
292272
});
293273
});
294274

src/browser/utils/messages/StreamingMessageAggregator.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { createMuxMessage } from "@/common/types/message";
22
import type { BashToolResult } from "@/common/types/tools";
3-
import type { DeleteMessage } from "@/common/orpc/types";
3+
import type { DeleteMessage } from "@/common/types/ipc";
44
import { describe, test, expect } from "bun:test";
55
import { StreamingMessageAggregator } from "./StreamingMessageAggregator";
66

src/common/constants/ipc-constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ export const IPC_CHANNELS = {
3535
WORKSPACE_STREAM_HISTORY: "workspace:streamHistory",
3636
WORKSPACE_GET_INFO: "workspace:getInfo",
3737
WORKSPACE_EXECUTE_BASH: "workspace:executeBash",
38+
WORKSPACE_LIST_SCRIPTS: "workspace:listScripts",
39+
WORKSPACE_EXECUTE_SCRIPT: "workspace:executeScript",
3840
WORKSPACE_OPEN_TERMINAL: "workspace:openTerminal",
3941
WORKSPACE_CHAT_GET_HISTORY: "workspace:chat:getHistory",
4042
WORKSPACE_CHAT_GET_FULL_REPLAY: "workspace:chat:getFullReplay",

src/common/types/ipc.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { Result } from "./result";
2+
import type { ScriptInfo } from "@/utils/scripts/discovery";
23
import type {
34
FrontendWorkspaceMetadata,
45
WorkspaceMetadata,
@@ -338,6 +339,12 @@ export interface IPCApi {
338339
}
339340
): Promise<Result<BashToolResult, string>>;
340341
openTerminal(workspacePath: string): Promise<void>;
342+
listScripts(workspaceId: string): Promise<Result<ScriptInfo[], string>>;
343+
executeScript(
344+
workspaceId: string,
345+
scriptName: string,
346+
args?: string[]
347+
): Promise<Result<BashToolResult, string>>;
341348

342349
// Event subscriptions (renderer-only)
343350
// These methods are designed to send current state immediately upon subscription,

src/desktop/preload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ const api: IPCApi = {
9696
openTerminal: (workspaceId) => {
9797
return ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_OPEN_TERMINAL, workspaceId);
9898
},
99+
listScripts: (workspaceId) =>
100+
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_LIST_SCRIPTS, workspaceId),
101+
executeScript: (workspaceId, scriptName, args) =>
102+
ipcRenderer.invoke(IPC_CHANNELS.WORKSPACE_EXECUTE_SCRIPT, workspaceId, scriptName, args),
99103

100104
onChat: (workspaceId: string, callback) => {
101105
const channel = getChatChannel(workspaceId);

src/node/services/ipcMain.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ import { InitStateManager } from "@/node/services/initStateManager";
3939
import { createRuntime } from "@/node/runtime/runtimeFactory";
4040
import type { RuntimeConfig } from "@/common/types/runtime";
4141
import { isSSHRuntime } from "@/common/types/runtime";
42+
import { listScripts } from "@/utils/scripts/discovery";
43+
import { runWorkspaceScript } from "@/node/services/scriptRunner";
4244
import { validateProjectPath } from "@/node/utils/pathUtils";
4345
import { PTYService } from "@/node/services/ptyService";
4446
import type { TerminalWindowManager } from "@/desktop/terminalWindowManager";
@@ -1426,6 +1428,75 @@ export class IpcMain {
14261428
}
14271429
});
14281430

1431+
// Scripts IPC handlers
1432+
ipcMain.handle(
1433+
IPC_CHANNELS.WORKSPACE_LIST_SCRIPTS,
1434+
async (_event, workspaceId: string) => {
1435+
try {
1436+
const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId);
1437+
if (!metadataResult.success) {
1438+
return Err(`Failed to get workspace metadata: ${metadataResult.error}`);
1439+
}
1440+
1441+
const metadata = metadataResult.data;
1442+
const runtimeConfig = metadata.runtimeConfig ?? DEFAULT_RUNTIME_CONFIG;
1443+
const runtime = createRuntime(runtimeConfig);
1444+
const workspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name);
1445+
1446+
const scripts = await listScripts(runtime, workspacePath);
1447+
return Ok(scripts);
1448+
} catch (error) {
1449+
const message = error instanceof Error ? error.message : String(error);
1450+
return Err(`Failed to list scripts: ${message}`);
1451+
}
1452+
}
1453+
);
1454+
1455+
ipcMain.handle(
1456+
IPC_CHANNELS.WORKSPACE_EXECUTE_SCRIPT,
1457+
async (
1458+
_event,
1459+
workspaceId: string,
1460+
scriptName: string,
1461+
args?: string[]
1462+
) => {
1463+
try {
1464+
const metadataResult = await this.aiService.getWorkspaceMetadata(workspaceId);
1465+
if (!metadataResult.success) {
1466+
return Err(`Failed to get workspace metadata: ${metadataResult.error}`);
1467+
}
1468+
1469+
const metadata = metadataResult.data;
1470+
const runtimeConfig = metadata.runtimeConfig ?? DEFAULT_RUNTIME_CONFIG;
1471+
const runtime = createRuntime(runtimeConfig);
1472+
const workspacePath = runtime.getWorkspacePath(metadata.projectPath, metadata.name);
1473+
1474+
// Load project secrets
1475+
const projectSecrets = this.config.getProjectSecrets(metadata.projectPath);
1476+
1477+
const result = await runWorkspaceScript(
1478+
runtime,
1479+
workspacePath,
1480+
scriptName,
1481+
args ?? [],
1482+
{
1483+
secrets: secretsToRecord(projectSecrets),
1484+
timeoutSecs: 300,
1485+
}
1486+
);
1487+
1488+
if (!result.success) {
1489+
return Err(result.error);
1490+
}
1491+
1492+
return Ok(result.data.toolResult);
1493+
} catch (error) {
1494+
const message = error instanceof Error ? error.message : String(error);
1495+
return Err(`Failed to execute script: ${message}`);
1496+
}
1497+
}
1498+
);
1499+
14291500
// Debug IPC - only for testing
14301501
ipcMain.handle(
14311502
IPC_CHANNELS.DEBUG_TRIGGER_STREAM_ERROR,

0 commit comments

Comments
 (0)