Skip to content

Commit c111b48

Browse files
committed
Migrate workspace API to tRPC
1 parent 855fff6 commit c111b48

File tree

14 files changed

+368
-229
lines changed

14 files changed

+368
-229
lines changed

apps/array/src/main/di/container.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { OAuthService } from "../services/oauth/service.js";
1212
import { ShellService } from "../services/shell/service.js";
1313
import { TaskLinkService } from "../services/task-link/service.js";
1414
import { UpdatesService } from "../services/updates/service.js";
15+
import { WorkspaceService } from "../services/workspace/service.js";
1516
import { MAIN_TOKENS } from "./tokens.js";
1617

1718
export const container = new Container({
@@ -30,3 +31,4 @@ container.bind(MAIN_TOKENS.OAuthService).to(OAuthService);
3031
container.bind(MAIN_TOKENS.ShellService).to(ShellService);
3132
container.bind(MAIN_TOKENS.UpdatesService).to(UpdatesService);
3233
container.bind(MAIN_TOKENS.TaskLinkService).to(TaskLinkService);
34+
container.bind(MAIN_TOKENS.WorkspaceService).to(WorkspaceService);

apps/array/src/main/di/tokens.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,5 @@ export const MAIN_TOKENS = Object.freeze({
1818
ShellService: Symbol.for("Main.ShellService"),
1919
UpdatesService: Symbol.for("Main.UpdatesService"),
2020
TaskLinkService: Symbol.for("Main.TaskLinkService"),
21+
WorkspaceService: Symbol.for("Main.WorkspaceService"),
2122
});

apps/array/src/main/index.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ import {
4545
} from "./services/posthog-analytics.js";
4646
import type { TaskLinkService } from "./services/task-link/service";
4747
import type { UpdatesService } from "./services/updates/service.js";
48-
import { registerWorkspaceIpc } from "./services/workspace/index.js";
4948

5049
const __filename = fileURLToPath(import.meta.url);
5150
const __dirname = path.dirname(__filename);
@@ -375,4 +374,3 @@ ipcMain.handle("app:get-version", () => app.getVersion());
375374
// Register IPC handlers via services
376375
registerGitIpc();
377376
registerAgentIpc(taskControllers, () => mainWindow);
378-
registerWorkspaceIpc(() => mainWindow);

apps/array/src/main/preload.ts

Lines changed: 0 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
11
import type { ContentBlock } from "@agentclientprotocol/sdk";
22
import { contextBridge, type IpcRendererEvent, ipcRenderer } from "electron";
33
import { exposeElectronTRPC } from "trpc-electron/main";
4-
import type {
5-
CreateWorkspaceOptions,
6-
ScriptExecutionResult,
7-
Workspace,
8-
WorkspaceInfo,
9-
WorkspaceTerminalInfo,
10-
} from "../shared/types";
114
import "electron-log/preload";
125

136
process.once("loaded", () => {
@@ -151,46 +144,4 @@ contextBridge.exposeInMainWorld("electronAPI", {
151144
onClearStorage: (listener: () => void): (() => void) =>
152145
createVoidIpcListener("clear-storage", listener),
153146
getAppVersion: (): Promise<string> => ipcRenderer.invoke("app:get-version"),
154-
// Workspace API
155-
workspace: {
156-
create: (options: CreateWorkspaceOptions): Promise<WorkspaceInfo> =>
157-
ipcRenderer.invoke("workspace:create", options),
158-
delete: (taskId: string, mainRepoPath: string): Promise<void> =>
159-
ipcRenderer.invoke("workspace:delete", taskId, mainRepoPath),
160-
verify: (taskId: string): Promise<boolean> =>
161-
ipcRenderer.invoke("workspace:verify", taskId),
162-
getInfo: (taskId: string): Promise<WorkspaceInfo | null> =>
163-
ipcRenderer.invoke("workspace:get-info", taskId),
164-
getAll: (): Promise<Record<string, Workspace>> =>
165-
ipcRenderer.invoke("workspace:get-all"),
166-
runStart: (
167-
taskId: string,
168-
worktreePath: string,
169-
worktreeName: string,
170-
): Promise<ScriptExecutionResult> =>
171-
ipcRenderer.invoke(
172-
"workspace:run-start",
173-
taskId,
174-
worktreePath,
175-
worktreeName,
176-
),
177-
isRunning: (taskId: string): Promise<boolean> =>
178-
ipcRenderer.invoke("workspace:is-running", taskId),
179-
getTerminals: (taskId: string): Promise<WorkspaceTerminalInfo[]> =>
180-
ipcRenderer.invoke("workspace:get-terminals", taskId),
181-
onTerminalCreated: (
182-
listener: IpcEventListener<WorkspaceTerminalInfo & { taskId: string }>,
183-
): (() => void) =>
184-
createIpcListener("workspace:terminal-created", listener),
185-
onError: (
186-
listener: IpcEventListener<{ taskId: string; message: string }>,
187-
): (() => void) => createIpcListener("workspace:error", listener),
188-
onWarning: (
189-
listener: IpcEventListener<{
190-
taskId: string;
191-
title: string;
192-
message: string;
193-
}>,
194-
): (() => void) => createIpcListener("workspace:warning", listener),
195-
},
196147
});
Lines changed: 10 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,83 +1,10 @@
1-
import type { BrowserWindow } from "electron";
2-
import type {
3-
CreateWorkspaceOptions,
4-
ScriptExecutionResult,
5-
Workspace,
6-
WorkspaceInfo,
7-
WorkspaceTerminalInfo,
8-
} from "../../../shared/types";
9-
import { createIpcHandler } from "../../lib/ipcHandler";
10-
import { WorkspaceService } from "./workspaceService";
11-
12-
let workspaceService: WorkspaceService | null = null;
13-
14-
const handle = createIpcHandler("workspace");
15-
16-
function getService(): WorkspaceService {
17-
if (!workspaceService) {
18-
throw new Error("Workspace service not initialized");
19-
}
20-
return workspaceService;
21-
}
22-
23-
export function registerWorkspaceIpc(
24-
getMainWindow: () => BrowserWindow | null,
25-
): void {
26-
workspaceService = new WorkspaceService({ getMainWindow });
27-
28-
handle<[CreateWorkspaceOptions], WorkspaceInfo>(
29-
"workspace:create",
30-
async (_event, options) => getService().createWorkspace(options),
31-
);
32-
33-
handle<[string, string], void>(
34-
"workspace:delete",
35-
async (_event, taskId, mainRepoPath) =>
36-
getService().deleteWorkspace(taskId, mainRepoPath),
37-
);
38-
39-
handle<[string], boolean>(
40-
"workspace:verify",
41-
async (_event, taskId) => getService().verifyWorkspaceExists(taskId),
42-
{ rethrow: false, fallback: false },
43-
);
44-
45-
handle<[string], WorkspaceInfo | null>(
46-
"workspace:get-info",
47-
(_event, taskId) => getService().getWorkspaceInfo(taskId),
48-
{ rethrow: false, fallback: null },
49-
);
50-
51-
handle<[string, string, string], ScriptExecutionResult>(
52-
"workspace:run-start",
53-
async (_event, taskId, worktreePath, worktreeName) =>
54-
getService().runStartScripts(taskId, worktreePath, worktreeName),
55-
{
56-
rethrow: false,
57-
fallback: { success: false, terminalSessionIds: [], errors: ["Failed"] },
58-
},
59-
);
60-
61-
handle<[string], boolean>(
62-
"workspace:is-running",
63-
(_event, taskId) => workspaceService?.isWorkspaceRunning(taskId) ?? false,
64-
{ rethrow: false, fallback: false },
65-
);
66-
67-
handle<[string], WorkspaceTerminalInfo[]>(
68-
"workspace:get-terminals",
69-
(_event, taskId) => workspaceService?.getWorkspaceTerminals(taskId) ?? [],
70-
{ rethrow: false, fallback: [] },
71-
);
72-
73-
handle<[], Record<string, Workspace>>(
74-
"workspace:get-all",
75-
async () => workspaceService?.getAllWorkspaces() ?? {},
76-
{ rethrow: false, fallback: {} },
77-
);
78-
}
79-
80-
export { loadConfig, normalizeScripts } from "./configLoader";
81-
export type { ArrayConfig, ConfigValidationResult } from "./configSchema";
82-
export { arrayConfigSchema, validateConfig } from "./configSchema";
83-
export { WorkspaceService } from "./workspaceService";
1+
// Re-export from service
2+
3+
// Re-export from config
4+
export { loadConfig, normalizeScripts } from "./configLoader.js";
5+
export type { ArrayConfig, ConfigValidationResult } from "./configSchema.js";
6+
export { arrayConfigSchema, validateConfig } from "./configSchema.js";
7+
// Re-export schemas
8+
export * from "./schemas.js";
9+
export type { WorkspaceServiceEvents } from "./service.js";
10+
export { WorkspaceService, WorkspaceServiceEvent } from "./service.js";
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { z } from "zod";
2+
3+
// Base schemas
4+
export const workspaceModeSchema = z.enum(["worktree", "root", "cloud"]);
5+
6+
export const worktreeInfoSchema = z.object({
7+
worktreePath: z.string(),
8+
worktreeName: z.string(),
9+
branchName: z.string(),
10+
baseBranch: z.string(),
11+
createdAt: z.string(),
12+
});
13+
14+
export const workspaceTerminalInfoSchema = z.object({
15+
sessionId: z.string(),
16+
scriptType: z.enum(["init", "start"]),
17+
command: z.string(),
18+
label: z.string(),
19+
status: z.enum(["running", "completed", "failed"]),
20+
exitCode: z.number().optional(),
21+
});
22+
23+
export const workspaceInfoSchema = z.object({
24+
taskId: z.string(),
25+
mode: workspaceModeSchema,
26+
worktree: worktreeInfoSchema.nullable(),
27+
terminalSessionIds: z.array(z.string()),
28+
hasStartScripts: z.boolean().optional(),
29+
});
30+
31+
export const workspaceSchema = z.object({
32+
taskId: z.string(),
33+
folderId: z.string(),
34+
folderPath: z.string(),
35+
mode: workspaceModeSchema,
36+
worktreePath: z.string().nullable(),
37+
worktreeName: z.string().nullable(),
38+
branchName: z.string().nullable(),
39+
baseBranch: z.string().nullable(),
40+
createdAt: z.string(),
41+
terminalSessionIds: z.array(z.string()),
42+
hasStartScripts: z.boolean().optional(),
43+
});
44+
45+
export const scriptExecutionResultSchema = z.object({
46+
success: z.boolean(),
47+
terminalSessionIds: z.array(z.string()),
48+
errors: z.array(z.string()).optional(),
49+
});
50+
51+
// Input schemas
52+
export const createWorkspaceInput = z.object({
53+
taskId: z.string(),
54+
mainRepoPath: z.string(),
55+
folderId: z.string(),
56+
folderPath: z.string(),
57+
mode: workspaceModeSchema,
58+
branch: z.string().optional(),
59+
});
60+
61+
export const deleteWorkspaceInput = z.object({
62+
taskId: z.string(),
63+
mainRepoPath: z.string(),
64+
});
65+
66+
export const verifyWorkspaceInput = z.object({
67+
taskId: z.string(),
68+
});
69+
70+
export const getWorkspaceInfoInput = z.object({
71+
taskId: z.string(),
72+
});
73+
74+
export const runStartScriptsInput = z.object({
75+
taskId: z.string(),
76+
worktreePath: z.string(),
77+
worktreeName: z.string(),
78+
});
79+
80+
export const isWorkspaceRunningInput = z.object({
81+
taskId: z.string(),
82+
});
83+
84+
export const getWorkspaceTerminalsInput = z.object({
85+
taskId: z.string(),
86+
});
87+
88+
// Output schemas
89+
export const createWorkspaceOutput = workspaceInfoSchema;
90+
export const verifyWorkspaceOutput = z.boolean();
91+
export const getWorkspaceInfoOutput = workspaceInfoSchema.nullable();
92+
export const getAllWorkspacesOutput = z.record(z.string(), workspaceSchema);
93+
export const runStartScriptsOutput = scriptExecutionResultSchema;
94+
export const isWorkspaceRunningOutput = z.boolean();
95+
export const getWorkspaceTerminalsOutput = z.array(workspaceTerminalInfoSchema);
96+
97+
// Event payload schemas (for subscriptions)
98+
export const workspaceTerminalCreatedPayload =
99+
workspaceTerminalInfoSchema.extend({
100+
taskId: z.string(),
101+
});
102+
103+
export const workspaceErrorPayload = z.object({
104+
taskId: z.string(),
105+
message: z.string(),
106+
});
107+
108+
export const workspaceWarningPayload = z.object({
109+
taskId: z.string(),
110+
title: z.string(),
111+
message: z.string(),
112+
});
113+
114+
// Type exports
115+
export type WorkspaceMode = z.infer<typeof workspaceModeSchema>;
116+
export type WorktreeInfo = z.infer<typeof worktreeInfoSchema>;
117+
export type WorkspaceTerminalInfo = z.infer<typeof workspaceTerminalInfoSchema>;
118+
export type WorkspaceInfo = z.infer<typeof workspaceInfoSchema>;
119+
export type Workspace = z.infer<typeof workspaceSchema>;
120+
export type ScriptExecutionResult = z.infer<typeof scriptExecutionResultSchema>;
121+
122+
export type CreateWorkspaceInput = z.infer<typeof createWorkspaceInput>;
123+
export type DeleteWorkspaceInput = z.infer<typeof deleteWorkspaceInput>;
124+
export type VerifyWorkspaceInput = z.infer<typeof verifyWorkspaceInput>;
125+
export type GetWorkspaceInfoInput = z.infer<typeof getWorkspaceInfoInput>;
126+
export type RunStartScriptsInput = z.infer<typeof runStartScriptsInput>;
127+
export type IsWorkspaceRunningInput = z.infer<typeof isWorkspaceRunningInput>;
128+
export type GetWorkspaceTerminalsInput = z.infer<
129+
typeof getWorkspaceTerminalsInput
130+
>;
131+
132+
export type WorkspaceTerminalCreatedPayload = z.infer<
133+
typeof workspaceTerminalCreatedPayload
134+
>;
135+
export type WorkspaceErrorPayload = z.infer<typeof workspaceErrorPayload>;
136+
export type WorkspaceWarningPayload = z.infer<typeof workspaceWarningPayload>;

apps/array/src/main/services/workspace/scriptRunner.ts

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
import { exec } from "node:child_process";
22
import * as fs from "node:fs";
33
import { promisify } from "node:util";
4-
import type { BrowserWindow } from "electron";
5-
import type {
6-
ScriptExecutionResult,
7-
WorkspaceTerminalInfo,
8-
} from "../../../shared/types";
94
import { randomSuffix } from "../../../shared/utils/id";
105
import { logger } from "../../lib/logger";
116
import { shellManager } from "../../lib/shellManager";
7+
import { getMainWindow } from "../../trpc/context.js";
8+
import type {
9+
ScriptExecutionResult,
10+
WorkspaceTerminalCreatedPayload,
11+
WorkspaceTerminalInfo,
12+
} from "./schemas.js";
1213

1314
const execAsync = promisify(exec);
1415
const log = logger.scope("workspace:scripts");
@@ -18,14 +19,14 @@ function generateSessionId(taskId: string, scriptType: string): string {
1819
}
1920

2021
export interface ScriptRunnerOptions {
21-
getMainWindow: () => BrowserWindow | null;
22+
onTerminalCreated: (info: WorkspaceTerminalCreatedPayload) => void;
2223
}
2324

2425
export class ScriptRunner {
25-
private getMainWindow: () => BrowserWindow | null;
26+
private onTerminalCreated: (info: WorkspaceTerminalCreatedPayload) => void;
2627

2728
constructor(options: ScriptRunnerOptions) {
28-
this.getMainWindow = options.getMainWindow;
29+
this.onTerminalCreated = options.onTerminalCreated;
2930
}
3031

3132
async executeScriptsWithTerminal(
@@ -48,7 +49,7 @@ export class ScriptRunner {
4849
};
4950
}
5051

51-
const mainWindow = this.getMainWindow();
52+
const mainWindow = getMainWindow();
5253
if (!mainWindow) {
5354
return {
5455
success: false,
@@ -72,7 +73,7 @@ export class ScriptRunner {
7273

7374
terminalSessionIds.push(sessionId);
7475

75-
this.emitTerminalCreated({
76+
this.onTerminalCreated({
7677
taskId,
7778
sessionId,
7879
scriptType,
@@ -158,17 +159,6 @@ export class ScriptRunner {
158159
getTaskSessions(taskId: string): string[] {
159160
return shellManager.getSessionsByPrefix(`workspace-${taskId}-`);
160161
}
161-
162-
private emitTerminalCreated(
163-
info: WorkspaceTerminalInfo & { taskId: string },
164-
): void {
165-
const mainWindow = this.getMainWindow();
166-
if (!mainWindow) {
167-
log.warn("No main window available to emit terminal created event");
168-
return;
169-
}
170-
mainWindow.webContents.send("workspace:terminal-created", info);
171-
}
172162
}
173163

174164
export function cleanupWorkspaceSessions(taskId: string): void {

0 commit comments

Comments
 (0)