/ src / server / tools / jules-cli.ts
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  }