jules-cli.ts
1 import { runCliProcess, sleep, truncateCliOutput } from '@/server/agents/process' 2 import type { JulesSessionInfo } from './jules-api' 3 4 const JULES_CLI_BIN = 'jules' 5 const DEFAULT_TIMEOUT_MS = 120_000 6 const DEFAULT_POLL_INTERVAL_MS = 30_000 7 8 export interface JulesCreateResult { 9 success: boolean 10 sessionIds: string[] 11 stdoutPreview: string 12 stderrPreview: string 13 error?: string 14 } 15 16 export interface JulesListResult { 17 success: boolean 18 sessions: JulesSessionInfo[] 19 stdoutPreview: string 20 stderrPreview: string 21 error?: string 22 } 23 24 export interface JulesPullResult { 25 success: boolean 26 sessionId: string 27 patchPreview: string 28 applied: boolean 29 stdoutPreview: string 30 stderrPreview: string 31 error?: string 32 } 33 34 export interface JulesCancelResult { 35 success: boolean 36 message: string 37 } 38 39 export async function createJulesSessions( 40 prompt: string, 41 input?: { 42 repoOwner?: string 43 repoName?: string 44 parallel?: number 45 timeoutMs?: number 46 cwd?: string 47 }, 48 ): Promise<JulesCreateResult> { 49 const task = prompt.trim() 50 if (!task) { 51 return { 52 success: false, 53 sessionIds: [], 54 stdoutPreview: '', 55 stderrPreview: '', 56 error: 'Prompt is required.', 57 } 58 } 59 60 const args = ['remote', 'new', '--session', task] 61 if (input?.repoOwner && input.repoName) { 62 args.push('--repo', `${input.repoOwner}/${input.repoName}`) 63 } 64 if (input?.parallel && Number.isFinite(input.parallel) && input.parallel > 1) { 65 args.push('--parallel', String(Math.min(5, Math.max(1, Math.round(input.parallel))))) 66 } 67 68 const result = await runCliProcess({ 69 bin: JULES_CLI_BIN, 70 args, 71 timeoutMs: clampTimeoutMs(input?.timeoutMs), 72 cwd: input?.cwd, 73 }) 74 75 const stdoutPreview = truncateCliOutput(result.stdout, 2_000) 76 const stderrPreview = truncateCliOutput(result.stderr, 1_200) 77 if (result.exitCode !== 0 || result.signal || result.timedOut) { 78 return { 79 success: false, 80 sessionIds: [], 81 stdoutPreview, 82 stderrPreview, 83 error: buildFailureMessage(result), 84 } 85 } 86 87 return { 88 success: true, 89 sessionIds: extractSessionIds(result.stdout), 90 stdoutPreview, 91 stderrPreview, 92 } 93 } 94 95 export async function listJulesSessions(input?: { 96 prompt?: string 97 timeoutMs?: number 98 cwd?: string 99 }): Promise<JulesListResult> { 100 const result = await runCliProcess({ 101 bin: JULES_CLI_BIN, 102 args: ['remote', 'list', '--session'], 103 timeoutMs: clampTimeoutMs(input?.timeoutMs), 104 cwd: input?.cwd, 105 }) 106 107 const stdoutPreview = truncateCliOutput(result.stdout, 2_000) 108 const stderrPreview = truncateCliOutput(result.stderr, 1_200) 109 if (result.exitCode !== 0 || result.signal || result.timedOut) { 110 return { 111 success: false, 112 sessions: [], 113 stdoutPreview, 114 stderrPreview, 115 error: buildFailureMessage(result), 116 } 117 } 118 119 const sessions = parseSessionTable(result.stdout) 120 const filtered = 121 input?.prompt?.trim() 122 ? sessions.filter((session) => 123 (session.description ?? '') 124 .toLowerCase() 125 .includes(input.prompt!.trim().toLowerCase()), 126 ) 127 : sessions 128 return { 129 success: true, 130 sessions: filtered, 131 stdoutPreview, 132 stderrPreview, 133 } 134 } 135 136 export async function pullJulesSessionForReview( 137 sessionId: string, 138 input?: { 139 apply?: boolean 140 timeoutMs?: number 141 cwd?: string 142 }, 143 ): Promise<JulesPullResult> { 144 const trimmed = sessionId.trim() 145 const args = ['remote', 'pull', '--session', trimmed] 146 if (input?.apply) { 147 args.push('--apply') 148 } 149 const result = await runCliProcess({ 150 bin: JULES_CLI_BIN, 151 args, 152 timeoutMs: clampTimeoutMs(input?.timeoutMs), 153 cwd: input?.cwd, 154 }) 155 156 const stdoutPreview = truncateCliOutput(result.stdout, 2_000) 157 const stderrPreview = truncateCliOutput(result.stderr, 1_200) 158 if (result.exitCode !== 0 || result.signal || result.timedOut) { 159 return { 160 success: false, 161 sessionId: trimmed, 162 patchPreview: stdoutPreview, 163 applied: input?.apply === true, 164 stdoutPreview, 165 stderrPreview, 166 error: buildFailureMessage(result), 167 } 168 } 169 170 return { 171 success: true, 172 sessionId: trimmed, 173 patchPreview: stdoutPreview, 174 applied: input?.apply === true, 175 stdoutPreview, 176 stderrPreview, 177 } 178 } 179 180 export async function cancelJulesSession( 181 sessionId: string, 182 ): Promise<JulesCancelResult> { 183 const trimmed = sessionId.trim() 184 if (!trimmed) { 185 return { 186 success: false, 187 message: 'Session id is required.', 188 } 189 } 190 return { 191 success: false, 192 message: 193 'Jules CLI does not currently expose a cancel command. Configure API access for remote cancellation.', 194 } 195 } 196 197 export async function listJulesRepos(input?: { 198 timeoutMs?: number 199 cwd?: string 200 }): Promise<{ 201 success: boolean 202 repos: Array<{ repoOwner: string; repoName: string }> 203 error?: string 204 }> { 205 const result = await runCliProcess({ 206 bin: JULES_CLI_BIN, 207 args: ['remote', 'list', '--repo'], 208 timeoutMs: clampTimeoutMs(input?.timeoutMs), 209 cwd: input?.cwd, 210 }) 211 if (result.exitCode !== 0 || result.signal || result.timedOut) { 212 return { 213 success: false, 214 repos: [], 215 error: buildFailureMessage(result), 216 } 217 } 218 return { 219 success: true, 220 repos: parseRepoTable(result.stdout), 221 } 222 } 223 224 export async function waitForSessions( 225 sessionIds: string[], 226 input?: { 227 pollIntervalMs?: number 228 timeoutMs?: number 229 cwd?: string 230 onProgress?: ( 231 completed: string[], 232 failed: string[], 233 active: string[], 234 activeSessions: JulesSessionInfo[], 235 ) => void | Promise<void> 236 }, 237 ): Promise<{ completed: string[]; failed: string[]; timedOut: boolean }> { 238 const remaining = new Set(sessionIds.map((id) => id.trim()).filter(Boolean)) 239 const completed: string[] = [] 240 const failed: string[] = [] 241 const timeoutMs = clampTimeoutMs(input?.timeoutMs, 15 * 60_000) 242 const pollIntervalMs = clampPollIntervalMs(input?.pollIntervalMs) 243 const startedAtMs = Date.now() 244 245 while (remaining.size > 0) { 246 if (Date.now() - startedAtMs > timeoutMs) { 247 return { completed, failed, timedOut: true } 248 } 249 const listed = await listJulesSessions({ 250 timeoutMs, 251 cwd: input?.cwd, 252 }) 253 if (!listed.success) { 254 throw new Error(listed.error ?? 'Unable to list Jules sessions while waiting.') 255 } 256 257 const activeSessions = listed.sessions.filter((session) => 258 remaining.has(session.id), 259 ) 260 for (const session of activeSessions) { 261 const normalizedStatus = normalizeStatus(session.status) 262 if (normalizedStatus === 'completed') { 263 if (remaining.delete(session.id)) completed.push(session.id) 264 } else if (normalizedStatus === 'failed' || normalizedStatus === 'error') { 265 if (remaining.delete(session.id)) failed.push(session.id) 266 } 267 } 268 269 if (input?.onProgress) { 270 await input.onProgress(completed, failed, [...remaining], activeSessions) 271 } 272 if (remaining.size === 0) break 273 await sleep(pollIntervalMs) 274 } 275 276 return { 277 completed, 278 failed, 279 timedOut: false, 280 } 281 } 282 283 function parseSessionTable(stdout: string): JulesSessionInfo[] { 284 const sessions: JulesSessionInfo[] = [] 285 for (const rawLine of stdout.split(/\r?\n/)) { 286 const line = rawLine.trim() 287 if (!line || /^ID\s+/i.test(line)) continue 288 const parts = line.split(/\s{2,}/).map((part) => part.trim()).filter(Boolean) 289 if (parts.length < 2) continue 290 const id = parts[0] 291 if (!/^\d{6,}$/.test(id)) continue 292 293 const status = parts[parts.length - 1] ?? 'unknown' 294 const repo = 295 parts.find((part) => /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(part)) ?? null 296 const description = parts[1] ?? null 297 const lastActive = parts.length >= 4 ? parts[parts.length - 2] : null 298 sessions.push({ 299 id, 300 description, 301 repo, 302 status: normalizeStatus(status), 303 updatedAt: lastActive, 304 }) 305 } 306 return sessions 307 } 308 309 function parseRepoTable( 310 stdout: string, 311 ): Array<{ repoOwner: string; repoName: string }> { 312 const repos: Array<{ repoOwner: string; repoName: string }> = [] 313 const seen = new Set<string>() 314 const matches = stdout.matchAll(/\b([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)\b/g) 315 for (const match of matches) { 316 const slug = match[1] 317 if (!slug || seen.has(slug)) continue 318 const [repoOwner, repoName] = slug.split('/') 319 if (!repoOwner || !repoName) continue 320 seen.add(slug) 321 repos.push({ repoOwner, repoName }) 322 } 323 return repos 324 } 325 326 function extractSessionIds(value: string): string[] { 327 const ids = new Set<string>() 328 const matches = value.matchAll(/\b\d{6,}\b/g) 329 for (const match of matches) { 330 const id = match[0] 331 if (id) ids.add(id) 332 } 333 return [...ids] 334 } 335 336 function normalizeStatus(value: string): string { 337 return value.trim().toLowerCase().replace(/\s+/g, '_') 338 } 339 340 function buildFailureMessage(result: { 341 exitCode: number | null 342 signal: NodeJS.Signals | null 343 timedOut: boolean 344 }): string { 345 if (result.timedOut) return 'Jules CLI command timed out.' 346 if (result.signal) return `Jules CLI command terminated by signal ${result.signal}.` 347 if (typeof result.exitCode === 'number') { 348 return `Jules CLI command exited with code ${result.exitCode}.` 349 } 350 return 'Jules CLI command failed.' 351 } 352 353 function clampTimeoutMs(value: number | undefined, fallback = DEFAULT_TIMEOUT_MS): number { 354 if (value == null || !Number.isFinite(value)) return fallback 355 return Math.min(900_000, Math.max(5_000, Math.round(value))) 356 } 357 358 function clampPollIntervalMs(value: number | undefined): number { 359 if (value == null || !Number.isFinite(value)) return DEFAULT_POLL_INTERVAL_MS 360 return Math.min(300_000, Math.max(1_000, Math.round(value))) 361 }