| @@ -0,0 +1,411 @@ |
| | 1 | import { app, Menu, Notification, Tray, nativeImage, type MenuItemConstructorOptions } from "electron"; |
| | 2 | import { ConvexHttpClient } from "convex/browser"; |
| | 3 | import { api } from "@executor/convex/_generated/api"; |
| | 4 | import type { Id } from "@executor/convex/_generated/dataModel"; |
| | 5 | |
| | 6 | type ApprovalDecision = "approved" | "denied"; |
| | 7 | |
| | 8 | type PendingApprovalRecord = { |
| | 9 | id: string; |
| | 10 | taskId: string; |
| | 11 | toolPath: string; |
| | 12 | createdAt: number; |
| | 13 | }; |
| | 14 | |
| | 15 | type WorkspaceListItem = { |
| | 16 | id: Id<"workspaces">; |
| | 17 | name: string; |
| | 18 | }; |
| | 19 | |
| | 20 | type AnonymousContext = { |
| | 21 | sessionId: string; |
| | 22 | workspaceId: Id<"workspaces">; |
| | 23 | }; |
| | 24 | |
| | 25 | type RuntimeConfig = { |
| | 26 | convexUrl: string; |
| | 27 | workspaceId?: Id<"workspaces">; |
| | 28 | sessionId?: string; |
| | 29 | authToken?: string; |
| | 30 | pollIntervalMs: number; |
| | 31 | }; |
| | 32 | |
| | 33 | type RuntimeContext = { |
| | 34 | workspaceId: Id<"workspaces">; |
| | 35 | sessionId?: string; |
| | 36 | }; |
| | 37 | |
| | 38 | const DEFAULT_POLL_INTERVAL_MS = 5_000; |
| | 39 | |
| | 40 | const DEFAULT_ICON_DATA_URL = |
| | 41 | "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/6ioAAAAASUVORK5CYII="; |
| | 42 | |
| | 43 | function parseWorkspaceId(value: string | undefined): Id<"workspaces"> | undefined { |
| | 44 | if (!value) { |
| | 45 | return undefined; |
| | 46 | } |
| | 47 | const trimmed = value.trim(); |
| | 48 | return trimmed.length > 0 ? (trimmed as Id<"workspaces">) : undefined; |
| | 49 | } |
| | 50 | |
| | 51 | function parseOptionalString(value: string | undefined): string | undefined { |
| | 52 | if (!value) { |
| | 53 | return undefined; |
| | 54 | } |
| | 55 | const trimmed = value.trim(); |
| | 56 | return trimmed.length > 0 ? trimmed : undefined; |
| | 57 | } |
| | 58 | |
| | 59 | function parsePollInterval(value: string | undefined): number { |
| | 60 | if (!value) { |
| | 61 | return DEFAULT_POLL_INTERVAL_MS; |
| | 62 | } |
| | 63 | const parsed = Number.parseInt(value, 10); |
| | 64 | if (Number.isNaN(parsed) || parsed < 1_000) { |
| | 65 | throw new Error("EXECUTOR_POLL_INTERVAL_MS must be a number >= 1000"); |
| | 66 | } |
| | 67 | return parsed; |
| | 68 | } |
| | 69 | |
| | 70 | function readConfigFromEnv(): RuntimeConfig { |
| | 71 | const convexUrl = parseOptionalString(process.env.CONVEX_URL); |
| | 72 | if (!convexUrl) { |
| | 73 | throw new Error("CONVEX_URL is required"); |
| | 74 | } |
| | 75 | |
| | 76 | return { |
| | 77 | convexUrl, |
| | 78 | workspaceId: parseWorkspaceId(process.env.EXECUTOR_WORKSPACE_ID), |
| | 79 | sessionId: parseOptionalString(process.env.EXECUTOR_SESSION_ID), |
| | 80 | authToken: parseOptionalString(process.env.EXECUTOR_AUTH_TOKEN), |
| | 81 | pollIntervalMs: parsePollInterval(process.env.EXECUTOR_POLL_INTERVAL_MS), |
| | 82 | }; |
| | 83 | } |
| | 84 | |
| | 85 | function truncate(value: string, maxLength: number): string { |
| | 86 | if (value.length <= maxLength) { |
| | 87 | return value; |
| | 88 | } |
| | 89 | if (maxLength <= 1) { |
| | 90 | return value.slice(0, maxLength); |
| | 91 | } |
| | 92 | return `${value.slice(0, maxLength - 1)}…`; |
| | 93 | } |
| | 94 | |
| | 95 | function formatTimeAgo(timestamp: number): string { |
| | 96 | const elapsedMs = Date.now() - timestamp; |
| | 97 | if (!Number.isFinite(elapsedMs) || elapsedMs < 0) { |
| | 98 | return "now"; |
| | 99 | } |
| | 100 | |
| | 101 | const elapsedSec = Math.floor(elapsedMs / 1_000); |
| | 102 | if (elapsedSec < 60) { |
| | 103 | return `${elapsedSec}s ago`; |
| | 104 | } |
| | 105 | |
| | 106 | const elapsedMin = Math.floor(elapsedSec / 60); |
| | 107 | if (elapsedMin < 60) { |
| | 108 | return `${elapsedMin}m ago`; |
| | 109 | } |
| | 110 | |
| | 111 | const elapsedHours = Math.floor(elapsedMin / 60); |
| | 112 | if (elapsedHours < 24) { |
| | 113 | return `${elapsedHours}h ago`; |
| | 114 | } |
| | 115 | |
| | 116 | const elapsedDays = Math.floor(elapsedHours / 24); |
| | 117 | return `${elapsedDays}d ago`; |
| | 118 | } |
| | 119 | |
| | 120 | function isMacOS(): boolean { |
| | 121 | return process.platform === "darwin"; |
| | 122 | } |
| | 123 | |
| | 124 | class ApprovalMenubarApp { |
| | 125 | private readonly convex: ConvexHttpClient; |
| | 126 | private readonly inFlightApprovalIds = new Set<string>(); |
| | 127 | private readonly seenApprovalIds = new Set<string>(); |
| | 128 | private readonly icon = nativeImage.createFromDataURL(DEFAULT_ICON_DATA_URL); |
| | 129 | private context: RuntimeContext | null = null; |
| | 130 | private tray: Tray | null = null; |
| | 131 | private approvals: PendingApprovalRecord[] = []; |
| | 132 | private pollingTimer: ReturnType<typeof setInterval> | null = null; |
| | 133 | private refreshInFlight = false; |
| | 134 | private lastError: string | null = null; |
| | 135 | |
| | 136 | constructor(private readonly config: RuntimeConfig) { |
| | 137 | this.convex = new ConvexHttpClient(config.convexUrl); |
| | 138 | if (config.authToken) { |
| | 139 | this.convex.setAuth(config.authToken); |
| | 140 | } |
| | 141 | } |
| | 142 | |
| | 143 | async start(): Promise<void> { |
| | 144 | this.context = await this.resolveContext(); |
| | 145 | this.tray = new Tray(this.icon); |
| | 146 | this.tray.setToolTip("Executor task approvals"); |
| | 147 | |
| | 148 | if (isMacOS()) { |
| | 149 | this.tray.setTitle("Exec"); |
| | 150 | } |
| | 151 | |
| | 152 | this.updateMenu(); |
| | 153 | await this.refreshApprovals({ initial: true }); |
| | 154 | |
| | 155 | this.pollingTimer = setInterval(() => { |
| | 156 | void this.refreshApprovals({ initial: false }); |
| | 157 | }, this.config.pollIntervalMs); |
| | 158 | } |
| | 159 | |
| | 160 | stop(): void { |
| | 161 | if (this.pollingTimer) { |
| | 162 | clearInterval(this.pollingTimer); |
| | 163 | this.pollingTimer = null; |
| | 164 | } |
| | 165 | } |
| | 166 | |
| | 167 | private async resolveContext(): Promise<RuntimeContext> { |
| | 168 | let sessionId = this.config.sessionId; |
| | 169 | let workspaceId = this.config.workspaceId; |
| | 170 | |
| | 171 | if (!this.config.authToken) { |
| | 172 | const bootstrapped = await this.convex.mutation(api.workspace.bootstrapAnonymousSession, { |
| | 173 | sessionId, |
| | 174 | }) as AnonymousContext; |
| | 175 | |
| | 176 | sessionId = bootstrapped.sessionId; |
| | 177 | if (!workspaceId) { |
| | 178 | workspaceId = bootstrapped.workspaceId; |
| | 179 | } |
| | 180 | } |
| | 181 | |
| | 182 | if (!workspaceId) { |
| | 183 | const workspaces = await this.convex.query(api.workspaces.list, { |
| | 184 | sessionId, |
| | 185 | }) as WorkspaceListItem[]; |
| | 186 | |
| | 187 | workspaceId = workspaces[0]?.id; |
| | 188 | } |
| | 189 | |
| | 190 | if (!workspaceId) { |
| | 191 | throw new Error( |
| | 192 | "Could not determine workspace. Set EXECUTOR_WORKSPACE_ID or provide session/auth that can access a workspace.", |
| | 193 | ); |
| | 194 | } |
| | 195 | |
| | 196 | return { |
| | 197 | workspaceId, |
| | 198 | sessionId, |
| | 199 | }; |
| | 200 | } |
| | 201 | |
| | 202 | private async refreshApprovals(options: { initial: boolean }): Promise<void> { |
| | 203 | if (!this.context || this.refreshInFlight) { |
| | 204 | return; |
| | 205 | } |
| | 206 | |
| | 207 | this.refreshInFlight = true; |
| | 208 | try { |
| | 209 | const approvals = await this.convex.query(api.workspace.listPendingApprovals, { |
| | 210 | workspaceId: this.context.workspaceId, |
| | 211 | sessionId: this.context.sessionId, |
| | 212 | }) as PendingApprovalRecord[]; |
| | 213 | |
| | 214 | this.lastError = null; |
| | 215 | this.handleNotifications(approvals, options.initial); |
| | 216 | this.approvals = approvals; |
| | 217 | } catch (error) { |
| | 218 | this.lastError = error instanceof Error ? error.message : "Failed to load approvals"; |
| | 219 | } finally { |
| | 220 | this.refreshInFlight = false; |
| | 221 | this.updateMenu(); |
| | 222 | } |
| | 223 | } |
| | 224 | |
| | 225 | private handleNotifications(nextApprovals: PendingApprovalRecord[], initial: boolean): void { |
| | 226 | const currentIds = new Set(nextApprovals.map((approval) => approval.id)); |
| | 227 | |
| | 228 | if (initial) { |
| | 229 | this.seenApprovalIds.clear(); |
| | 230 | currentIds.forEach((id) => this.seenApprovalIds.add(id)); |
| | 231 | return; |
| | 232 | } |
| | 233 | |
| | 234 | const newApprovals = nextApprovals.filter((approval) => !this.seenApprovalIds.has(approval.id)); |
| | 235 | this.seenApprovalIds.clear(); |
| | 236 | currentIds.forEach((id) => this.seenApprovalIds.add(id)); |
| | 237 | |
| | 238 | if (newApprovals.length === 0) { |
| | 239 | return; |
| | 240 | } |
| | 241 | |
| | 242 | const newest = newApprovals[newApprovals.length - 1]; |
| | 243 | const body = newApprovals.length === 1 |
| | 244 | ? `Task ${newest.taskId} is waiting on ${newest.toolPath}` |
| | 245 | : `${newApprovals.length} approvals are waiting for review`; |
| | 246 | |
| | 247 | new Notification({ |
| | 248 | title: "Executor approval required", |
| | 249 | body, |
| | 250 | silent: false, |
| | 251 | }).show(); |
| | 252 | } |
| | 253 | |
| | 254 | private setTrayTitle(count: number): void { |
| | 255 | if (!this.tray) { |
| | 256 | return; |
| | 257 | } |
| | 258 | |
| | 259 | const label = count > 0 ? `Exec ${count}` : "Exec"; |
| | 260 | if (isMacOS()) { |
| | 261 | this.tray.setTitle(label); |
| | 262 | } |
| | 263 | this.tray.setToolTip(`Executor approvals (${count} pending)`); |
| | 264 | } |
| | 265 | |
| | 266 | private updateMenu(): void { |
| | 267 | if (!this.tray || !this.context) { |
| | 268 | return; |
| | 269 | } |
| | 270 | |
| | 271 | this.setTrayTitle(this.approvals.length); |
| | 272 | |
| | 273 | const menuTemplate: MenuItemConstructorOptions[] = [ |
| | 274 | { |
| | 275 | label: `Workspace: ${this.context.workspaceId}`, |
| | 276 | enabled: false, |
| | 277 | }, |
| | 278 | { |
| | 279 | label: `${this.approvals.length} pending approval${this.approvals.length === 1 ? "" : "s"}`, |
| | 280 | enabled: false, |
| | 281 | }, |
| | 282 | ]; |
| | 283 | |
| | 284 | if (this.lastError) { |
| | 285 | menuTemplate.push( |
| | 286 | { type: "separator" }, |
| | 287 | { |
| | 288 | label: `Error: ${truncate(this.lastError, 64)}`, |
| | 289 | enabled: false, |
| | 290 | }, |
| | 291 | ); |
| | 292 | } |
| | 293 | |
| | 294 | menuTemplate.push({ type: "separator" }); |
| | 295 | |
| | 296 | if (this.approvals.length === 0) { |
| | 297 | menuTemplate.push({ |
| | 298 | label: "No pending approvals", |
| | 299 | enabled: false, |
| | 300 | }); |
| | 301 | } else { |
| | 302 | this.approvals.forEach((approval) => { |
| | 303 | const isResolving = this.inFlightApprovalIds.has(approval.id); |
| | 304 | menuTemplate.push({ |
| | 305 | label: truncate(`${approval.toolPath} (${formatTimeAgo(approval.createdAt)})`, 56), |
| | 306 | submenu: [ |
| | 307 | { |
| | 308 | label: `Task: ${approval.taskId}`, |
| | 309 | enabled: false, |
| | 310 | }, |
| | 311 | { |
| | 312 | label: `Approval ID: ${approval.id}`, |
| | 313 | enabled: false, |
| | 314 | }, |
| | 315 | { |
| | 316 | type: "separator", |
| | 317 | }, |
| | 318 | { |
| | 319 | label: isResolving ? "Approving..." : "Approve", |
| | 320 | enabled: !isResolving, |
| | 321 | click: () => { |
| | 322 | void this.resolveApproval(approval.id, "approved"); |
| | 323 | }, |
| | 324 | }, |
| | 325 | { |
| | 326 | label: isResolving ? "Denying..." : "Deny", |
| | 327 | enabled: !isResolving, |
| | 328 | click: () => { |
| | 329 | void this.resolveApproval(approval.id, "denied"); |
| | 330 | }, |
| | 331 | }, |
| | 332 | ], |
| | 333 | }); |
| | 334 | }); |
| | 335 | } |
| | 336 | |
| | 337 | menuTemplate.push( |
| | 338 | { type: "separator" }, |
| | 339 | { |
| | 340 | label: "Refresh now", |
| | 341 | click: () => { |
| | 342 | void this.refreshApprovals({ initial: false }); |
| | 343 | }, |
| | 344 | }, |
| | 345 | { |
| | 346 | label: "Quit", |
| | 347 | role: "quit", |
| | 348 | }, |
| | 349 | ); |
| | 350 | |
| | 351 | this.tray.setContextMenu(Menu.buildFromTemplate(menuTemplate)); |
| | 352 | } |
| | 353 | |
| | 354 | private async resolveApproval(approvalId: string, decision: ApprovalDecision): Promise<void> { |
| | 355 | if (!this.context || this.inFlightApprovalIds.has(approvalId)) { |
| | 356 | return; |
| | 357 | } |
| | 358 | |
| | 359 | this.inFlightApprovalIds.add(approvalId); |
| | 360 | this.updateMenu(); |
| | 361 | |
| | 362 | try { |
| | 363 | await this.convex.mutation(api.executor.resolveApproval, { |
| | 364 | workspaceId: this.context.workspaceId, |
| | 365 | sessionId: this.context.sessionId, |
| | 366 | approvalId, |
| | 367 | decision, |
| | 368 | }); |
| | 369 | |
| | 370 | new Notification({ |
| | 371 | title: decision === "approved" ? "Approval approved" : "Approval denied", |
| | 372 | body: `${approvalId} was ${decision}`, |
| | 373 | silent: false, |
| | 374 | }).show(); |
| | 375 | |
| | 376 | await this.refreshApprovals({ initial: false }); |
| | 377 | } catch (error) { |
| | 378 | this.lastError = error instanceof Error ? error.message : "Failed to resolve approval"; |
| | 379 | new Notification({ |
| | 380 | title: "Executor approval action failed", |
| | 381 | body: this.lastError, |
| | 382 | silent: false, |
| | 383 | }).show(); |
| | 384 | } finally { |
| | 385 | this.inFlightApprovalIds.delete(approvalId); |
| | 386 | this.updateMenu(); |
| | 387 | } |
| | 388 | } |
| | 389 | } |
| | 390 | |
| | 391 | let approvalApp: ApprovalMenubarApp | null = null; |
| | 392 | |
| | 393 | app.whenReady().then(async () => { |
| | 394 | if (isMacOS()) { |
| | 395 | app.dock?.hide(); |
| | 396 | } |
| | 397 | |
| | 398 | const config = readConfigFromEnv(); |
| | 399 | approvalApp = new ApprovalMenubarApp(config); |
| | 400 | await approvalApp.start(); |
| | 401 | }).catch((error) => { |
| | 402 | const message = error instanceof Error ? error.message : String(error); |
| | 403 | console.error(`[executor-menubar] ${message}`); |
| | 404 | app.quit(); |
| | 405 | }); |
| | 406 | |
| | 407 | app.on("window-all-closed", () => {}); |
| | 408 | |
| | 409 | app.on("before-quit", () => { |
| | 410 | approvalApp?.stop(); |
| | 411 | }); |