Skip to content

Commit 4447fe9

Browse files
authored
handle cancel pendings jobs (#5881)
1 parent 67ab469 commit 4447fe9

File tree

2 files changed

+198
-16
lines changed

2 files changed

+198
-16
lines changed

frontend/src/lib/components/WorkerRepl.svelte

Lines changed: 147 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
import { onMount } from 'svelte'
33
import { Terminal } from 'xterm'
44
import 'xterm/css/xterm.css'
5-
import { runScriptAndPollResult } from './jobs/utils'
5+
import { pollJobResult, runScript } from './jobs/utils'
66
import { workspaceStore } from '$lib/stores'
77
import { Badge, Button, Drawer, DrawerContent, Skeleton } from './common'
88
import DarkModeObserver from './DarkModeObserver.svelte'
9-
import { Library, Play } from 'lucide-svelte'
9+
import { Eye, Library, Play, RefreshCw, Square } from 'lucide-svelte'
1010
import Editor from './Editor.svelte'
1111
import WorkspaceScriptPicker from './flows/pickers/WorkspaceScriptPicker.svelte'
1212
import ToggleHubWorkspace from './ToggleHubWorkspace.svelte'
@@ -17,6 +17,10 @@
1717
import { FitAddon } from '@xterm/addon-fit'
1818
import { Readline } from 'xterm-readline'
1919
import Tooltip from './Tooltip.svelte'
20+
import { JobService, type QueuedJob } from '$lib/gen'
21+
import { sendUserToast } from '$lib/toast'
22+
import Select from './Select.svelte'
23+
import { emptyString } from '$lib/utils'
2024
2125
let container: HTMLDivElement
2226
let term: Terminal
@@ -29,12 +33,17 @@
2933
let editor = $state<Editor | null>(null)
3034
let darkMode = $state(false)
3135
let pick_existing: 'workspace' | 'hub' = $state('workspace')
36+
let jobId = $state('')
37+
let selectedJobId = $state('')
3238
let codeViewer: Drawer | undefined = $state()
3339
let filter = $state('')
3440
let { tag }: Props = $props()
3541
let code: string = $state('')
3642
let working_directory = $state('~')
3743
let homeDirectory: string = '~'
44+
let pendingsJobs: Array<QueuedJob> = $state([])
45+
let loadingPendingJobs = $state(false)
46+
let isCancelingJob = $state(false)
3847
let prompt = $derived(
3948
`$-${working_directory === '/' ? '/' : working_directory.split('/').at(-1)} `
4049
)
@@ -86,7 +95,7 @@
8695
}
8796
}
8897
89-
let result: any = await runScriptAndPollResult({
98+
jobId = await runScript({
9099
workspace: $workspaceStore!,
91100
requestBody: {
92101
language: 'bash',
@@ -95,6 +104,9 @@
95104
args: {}
96105
}
97106
})
107+
108+
let result: any = await pollJobResult(jobId, $workspaceStore!)
109+
98110
if (isOnlyCdCommand) {
99111
working_directory = (result as string).replace(/(\r\n|\n|\r)/g, '')
100112
result = ''
@@ -109,6 +121,40 @@
109121
110122
const rl = new Readline()
111123
124+
async function listPendingJobs() {
125+
try {
126+
loadingPendingJobs = true
127+
pendingsJobs = await JobService.listQueue({
128+
workspace: $workspaceStore!,
129+
tag,
130+
running: true
131+
})
132+
} catch (error) {
133+
sendUserToast(error.body || error.message)
134+
} finally {
135+
loadingPendingJobs = false
136+
}
137+
}
138+
139+
async function listPendingJobsAndUpdateSelectedJobid() {
140+
await listPendingJobs()
141+
if (!pendingsJobs.find((pendingJob) => pendingJob.id === selectedJobId)) {
142+
selectedJobId = ''
143+
}
144+
}
145+
146+
async function cancelJob(jobId: string) {
147+
try {
148+
await JobService.cancelQueuedJob({
149+
workspace: $workspaceStore! ?? '',
150+
id: jobId,
151+
requestBody: {}
152+
})
153+
} catch (err) {
154+
sendUserToast(err.BodyDropPivotTarget, true)
155+
}
156+
}
157+
112158
onMount(async () => {
113159
term = new Terminal({
114160
cursorBlink: true,
@@ -133,6 +179,11 @@
133179
setTimeout(readLine)
134180
}
135181
182+
rl.setCtrlCHandler(async () => {
183+
await cancelJob(jobId)
184+
rl.read(prompt).then(processLine)
185+
})
186+
136187
const fitAddon = new FitAddon()
137188
term.loadAddon(fitAddon)
138189
term.open(container)
@@ -176,9 +227,8 @@
176227
}
177228
178229
async function onScriptPick(e: { detail: { path: string } }) {
179-
codeObj = undefined
180-
codeViewer?.openDrawer?.()
181230
codeObj = await getScriptByPath(e.detail.path ?? '')
231+
codeViewer?.openDrawer?.()
182232
}
183233
184234
async function replacePromptWithCommand(command: string) {
@@ -192,6 +242,8 @@
192242
await handleCommand(input)
193243
term.write(prompt)
194244
}
245+
246+
listPendingJobs()
195247
</script>
196248

197249
<DarkModeObserver bind:darkMode />
@@ -221,8 +273,94 @@
221273
</Drawer>
222274

223275
<div class="h-screen flex flex-col">
224-
<div class="m-1">
225-
<div class="flex flex-col">
276+
<div class="m-1 flex flex-col gap-2">
277+
<div class="flex flex-col gap-1">
278+
{#if pendingsJobs.length === 0}
279+
<Button
280+
loading={loadingPendingJobs}
281+
variant="border"
282+
color="light"
283+
wrapperClasses="self-stretch"
284+
on:click={async () => {
285+
await listPendingJobs()
286+
if (pendingsJobs.length === 0) {
287+
sendUserToast('No pending ssh jobs found')
288+
}
289+
}}
290+
startIcon={{ icon: RefreshCw }}
291+
>
292+
Load pending ssh jobs</Button
293+
>
294+
{:else}
295+
<div class="flex gap-1">
296+
<Select
297+
loading={loadingPendingJobs}
298+
class="grow shrink"
299+
bind:value={selectedJobId}
300+
items={pendingsJobs.map((pendingJob) => ({ value: pendingJob.id }))}
301+
placeholder="Choose a pending job id"
302+
clearable
303+
disablePortal
304+
/>
305+
<Button
306+
variant="border"
307+
color="light"
308+
disabled={emptyString(selectedJobId)}
309+
wrapperClasses="self-stretch"
310+
on:click={listPendingJobsAndUpdateSelectedJobid}
311+
startIcon={{ icon: RefreshCw }}
312+
iconOnly
313+
/>
314+
<Button
315+
color="light"
316+
size="xs"
317+
variant="border"
318+
disabled={emptyString(selectedJobId)}
319+
startIcon={{ icon: Eye }}
320+
on:click={async () => {
321+
const jobId = pendingsJobs.find((pendingsJob) => pendingsJob.id === selectedJobId)?.id
322+
if (jobId) {
323+
const job = await JobService.getJob({ workspace: $workspaceStore!, id: jobId })
324+
codeObj = {
325+
content: job.raw_code ?? '',
326+
language: job.language ?? 'bash'
327+
}
328+
codeViewer?.openDrawer()
329+
} else {
330+
pendingsJobs = pendingsJobs.filter((pendingJob) => pendingJob.id !== selectedJobId)
331+
selectedJobId = ''
332+
}
333+
}}
334+
iconOnly
335+
/>
336+
<Button
337+
loading={isCancelingJob}
338+
disabled={emptyString(selectedJobId)}
339+
color="red"
340+
size="xs"
341+
variant="border"
342+
startIcon={{ icon: Square }}
343+
on:click={async () => {
344+
try {
345+
isCancelingJob = true
346+
await cancelJob(selectedJobId)
347+
sendUserToast('Job cancelled successfully')
348+
pendingsJobs = pendingsJobs.filter((pendingJob) => pendingJob.id !== selectedJobId)
349+
selectedJobId = ''
350+
} catch (error) {
351+
sendUserToast(error.body || error.message, true)
352+
await listPendingJobsAndUpdateSelectedJobid()
353+
} finally {
354+
isCancelingJob = false
355+
}
356+
}}
357+
iconOnly
358+
/>
359+
</div>
360+
{/if}
361+
</div>
362+
363+
<div>
226364
<div class="flex justify-start w-full mb-2">
227365
<div class="flex flex-row">
228366
<Badge
@@ -239,9 +377,9 @@
239377
</div>
240378
<input type="text" disabled bind:value={working_directory} />
241379
</div>
242-
</div>
243380

244-
<div bind:this={container}></div>
381+
<div bind:this={container}></div>
382+
</div>
245383
</div>
246384
<div class="flex flex-col h-full gap-1 mt-2">
247385
<div class="flex flex-row w-full justify-between">

frontend/src/lib/components/jobs/utils.ts

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,57 @@
11
import { JobService, type RunScriptByPathData, type RunScriptPreviewData } from '$lib/gen'
22

3+
function isRunScriptByPathData(
4+
arg: RunScriptPreviewData | RunScriptByPathData
5+
): arg is RunScriptByPathData {
6+
return (arg as RunScriptByPathData).path !== undefined
7+
}
38

4-
function isRunScriptByPathData(arg:RunScriptPreviewData | RunScriptByPathData): arg is RunScriptByPathData {
5-
return (arg as RunScriptByPathData).path !== undefined;
9+
type RunScriptOptions = {
10+
maxRetries?: number
11+
withJobData?: boolean
612
}
713

8-
export async function runScriptAndPollResult(
9-
data: RunScriptPreviewData | RunScriptByPathData,
10-
{ maxRetries = 7, withJobData }: { maxRetries?: number; withJobData?: boolean } = {}
14+
/**
15+
* @function runScript
16+
* @param {RunScriptPreviewData | RunScriptByPathData} data - Data for running the script.
17+
* @returns {Promise<string>} A UUID representing the running script.
18+
*
19+
* @example
20+
* const uuid = await runScript(data)
21+
*/
22+
export async function runScript(data: RunScriptPreviewData | RunScriptByPathData) {
23+
const uuid = (
24+
isRunScriptByPathData(data)
25+
? await JobService.runScriptByPath(data)
26+
: await JobService.runScriptPreview(data)
27+
) as string
28+
29+
return uuid
30+
}
31+
32+
/**
33+
* @function pollJobResult
34+
* @description Polls a job result by UUID until success, failure, or max retries reached.
35+
* @param {string} uuid - Job UUID.
36+
* @param {string} workspace - Workspace identifier.
37+
* @param {RunScriptOptions} [options] - Optional settings like retries and job data inclusion.
38+
* @returns {Promise<unknown>} Final job result or throws error if it fails.
39+
*
40+
* @example
41+
* const result = await pollJobResult(uuid, 'my-workspace', { maxRetries: 5, withJobData: true });
42+
*/
43+
export async function pollJobResult(
44+
uuid: string,
45+
workspace: string,
46+
{ maxRetries = 7, withJobData }: RunScriptOptions = {}
1147
): Promise<unknown> {
12-
const uuid = (isRunScriptByPathData(data) ? await JobService.runScriptByPath(data) : await JobService.runScriptPreview(data)) as string
1348
let attempts = 0
1449
while (attempts < maxRetries) {
1550
try {
1651
await new Promise((resolve) => setTimeout(resolve, 500 * (attempts || 0.75)))
1752
const job = await JobService.getCompletedJobResultMaybe({
1853
id: uuid,
19-
workspace: data.workspace
54+
workspace
2055
})
2156
if (job.success) {
2257
if (withJobData) {
@@ -41,3 +76,12 @@ export async function runScriptAndPollResult(
4176

4277
throw 'Could not get job result, should not get here'
4378
}
79+
80+
export async function runScriptAndPollResult(
81+
data: RunScriptPreviewData | RunScriptByPathData,
82+
runScriptOptions?: RunScriptOptions
83+
): Promise<unknown> {
84+
const uuid = await runScript(data)
85+
86+
return await pollJobResult(uuid, data.workspace, runScriptOptions)
87+
}

0 commit comments

Comments
 (0)