Skip to content

Commit 7f5050b

Browse files
feat(jobs): add Jobs.stop() method [PLT-100574]
Add stop method to the Jobs service that stops one or more jobs by their UUID keys. Resolves keys to integer IDs in batches of 50, then calls the StopJobs OData action. Supports SoftStop and Kill strategies. Also adds text responseType handling to api-client for endpoints returning empty 200 responses. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 64634fa commit 7f5050b

9 files changed

Lines changed: 342 additions & 3 deletions

File tree

docs/oauth-scopes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ This page lists the specific OAuth scopes required in external app for each SDK
1515
|--------|-------------|
1616
| `getAll()` | `OR.Jobs` or `OR.Jobs.Read` |
1717
| `getOutput()` | `OR.Jobs` or `OR.Jobs.Read` |
18+
| `stop()` | `OR.Jobs` |
1819

1920
## Attachments
2021

src/core/http/api-client.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ export class ApiClient {
113113
return blob as T;
114114
}
115115

116+
// Handle text response type for endpoints that return empty or plain text bodies
117+
if (options.responseType === RESPONSE_TYPES.TEXT) {
118+
const text = await response.text();
119+
return (text || undefined) as T;
120+
}
121+
116122
// Check if we're expecting XML
117123
const acceptHeader = headers['Accept'] || headers['accept'];
118124
if (acceptHeader === CONTENT_TYPES.XML) {

src/models/orchestrator/jobs.models.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { JobGetAllOptions, RawJobGetResponse } from './jobs.types';
1+
import { JobGetAllOptions, RawJobGetResponse, JobStopOptions, JobStopData } from './jobs.types';
22
import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../utils/pagination';
3+
import { OperationResponse } from '../common/types';
34

45
// Combined type for job data with methods
56
export type JobGetResponse = RawJobGetResponse & JobMethods;
@@ -100,6 +101,43 @@ export interface JobServiceModel {
100101
* ```
101102
*/
102103
getOutput(jobKey: string, folderId: number): Promise<Record<string, unknown> | null>;
104+
105+
/**
106+
* Stops one or more jobs by their UUID keys.
107+
*
108+
* Resolves the provided job UUID keys to integer IDs, then sends a stop request to the Orchestrator.
109+
* Keys are processed in chunks of 50 to avoid URL length limits. Throws if any keys cannot be resolved.
110+
*
111+
* @param jobKeys - Array of job UUID keys to stop (e.g., from {@link JobGetResponse}.key)
112+
* @param folderId - The folder ID where the jobs reside (required)
113+
* @param options - Optional {@link JobStopOptions} including stop strategy
114+
* @returns Promise resolving to an {@link OperationResponse}<{@link JobStopData}> with the resolved job IDs
115+
*
116+
* @example
117+
* ```typescript
118+
* import { Jobs } from '@uipath/uipath-typescript/jobs';
119+
*
120+
* const jobs = new Jobs(sdk);
121+
*
122+
* // Stop a single job with default soft stop
123+
* const result = await jobs.stop(
124+
* ['c80c3b30-f010-4eb8-82d4-b67bc615e137'],
125+
* 123
126+
* );
127+
*
128+
* // Force-kill multiple jobs
129+
* const killResult = await jobs.stop(
130+
* ['c80c3b30-f010-4eb8-82d4-b67bc615e137', '24ef1040-454d-4184-b994-c641ee32318d'],
131+
* 123,
132+
* { strategy: StopStrategy.Kill }
133+
* );
134+
* ```
135+
*/
136+
stop(
137+
jobKeys: string[],
138+
folderId: number,
139+
options?: JobStopOptions
140+
): Promise<OperationResponse<JobStopData>>;
103141
}
104142

105143
/**

src/models/orchestrator/jobs.types.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,3 +166,23 @@ export type JobGetAllOptions = RequestOptions & PaginationOptions & {
166166
folderId?: number;
167167
}
168168

169+
/**
170+
* Options for stopping jobs
171+
*/
172+
export interface JobStopOptions {
173+
/**
174+
* The stop strategy to use.
175+
* - `SoftStop` — sends a cancellation request and waits for the job to finish gracefully
176+
* - `Kill` — forcefully terminates the job immediately
177+
* @default StopStrategy.SoftStop
178+
*/
179+
strategy?: StopStrategy;
180+
}
181+
182+
/**
183+
* Data returned from a stop operation
184+
*/
185+
export interface JobStopData {
186+
/** The resolved integer IDs of the jobs that were stopped */
187+
jobIds: number[];
188+
}

src/services/orchestrator/jobs/jobs.ts

Lines changed: 90 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { FolderScopedService } from '../../folder-scoped';
2-
import { RawJobGetResponse, JobGetAllOptions } from '../../../models/orchestrator/jobs.types';
2+
import { RawJobGetResponse, JobGetAllOptions, JobStopOptions, JobStopData } from '../../../models/orchestrator/jobs.types';
33
import { RawJobOutputFields } from '../../../models/orchestrator/jobs.internal-types';
44
import { JobServiceModel, JobGetResponse, createJobWithMethods } from '../../../models/orchestrator/jobs.models';
55
import { pascalToCamelCaseKeys, transformData } from '../../../utils/transform';
@@ -11,12 +11,17 @@ import { ValidationError, ServerError } from '../../../core/errors';
1111
import { ErrorFactory } from '../../../core/errors/error-factory';
1212
import { errorResponseParser } from '../../../core/errors/parser';
1313
import { createHeaders } from '../../../utils/http/headers';
14-
import { FOLDER_ID } from '../../../utils/constants/headers';
14+
import { FOLDER_ID, RESPONSE_TYPES } from '../../../utils/constants/headers';
1515
import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../../utils/pagination';
1616
import { PaginationHelpers } from '../../../utils/pagination/helpers';
1717
import { PaginationType } from '../../../utils/pagination/internal-types';
1818
import { track } from '../../../core/telemetry';
1919
import type { IUiPath } from '../../../core/types';
20+
import { OperationResponse, CollectionResponse } from '../../../models/common/types';
21+
import { StopStrategy } from '../../../models/orchestrator/processes.types';
22+
23+
/** Maximum number of job keys to resolve in a single OData filter query */
24+
const JOB_KEY_RESOLUTION_CHUNK_SIZE = 50;
2025

2126
/**
2227
* Service for interacting with UiPath Orchestrator Jobs API
@@ -160,6 +165,37 @@ export class JobService extends FolderScopedService implements JobServiceModel {
160165
return null;
161166
}
162167

168+
/**
169+
* Stops one or more jobs by their UUID keys.
170+
*
171+
* Resolves job UUID keys to integer IDs, then calls the StopJobs OData action.
172+
* Keys are resolved in chunks of 50 to avoid URL length limits.
173+
*
174+
* @param jobKeys - Array of job UUID keys to stop
175+
* @param folderId - The folder ID where the jobs reside (required by the API)
176+
* @param options - Optional stop options including strategy
177+
* @returns Promise resolving to an OperationResponse with the resolved job IDs
178+
*/
179+
@track('Jobs.Stop')
180+
async stop(
181+
jobKeys: string[],
182+
folderId: number,
183+
options?: JobStopOptions
184+
): Promise<OperationResponse<JobStopData>> {
185+
if (jobKeys.length === 0) {
186+
return { success: true, data: { jobIds: [] } };
187+
}
188+
189+
const headers = createHeaders({ [FOLDER_ID]: folderId });
190+
const strategy = options?.strategy ?? StopStrategy.SoftStop;
191+
192+
const jobIds = await this.resolveJobKeys(jobKeys, headers);
193+
194+
await this.stopJobsByIds(jobIds, strategy, headers);
195+
196+
return { success: true, data: { jobIds } };
197+
}
198+
163199
/**
164200
* Fetches a job by its Key (GUID) using the GetByKey endpoint.
165201
* Only selects fields needed for output extraction.
@@ -222,4 +258,56 @@ export class JobService extends FolderScopedService implements JobServiceModel {
222258
throw new ServerError({ message: 'Failed to parse job output file as JSON' });
223259
}
224260
}
261+
262+
/**
263+
* Resolves job UUID keys to integer IDs via OData filter queries.
264+
* Chunks keys into batches to avoid URL length limits.
265+
*/
266+
private async resolveJobKeys(
267+
jobKeys: string[],
268+
headers: Record<string, string>
269+
): Promise<number[]> {
270+
const uniqueKeys = [...new Set(jobKeys)];
271+
const keyToIdMap = new Map<string, number>();
272+
273+
for (let i = 0; i < uniqueKeys.length; i += JOB_KEY_RESOLUTION_CHUNK_SIZE) {
274+
const chunk = uniqueKeys.slice(i, i + JOB_KEY_RESOLUTION_CHUNK_SIZE);
275+
const filterValues = chunk.map((key) => `'${key}'`).join(',');
276+
const filter = `Key in (${filterValues})`;
277+
278+
const response = await this.get<CollectionResponse<{ Key: string; Id: number }>>(
279+
JOB_ENDPOINTS.GET_ALL,
280+
{
281+
params: { $filter: filter, $select: 'Id,Key' },
282+
headers,
283+
}
284+
);
285+
286+
for (const job of response.data.value) {
287+
keyToIdMap.set(job.Key, job.Id);
288+
}
289+
}
290+
291+
const missingKeys = uniqueKeys.filter((key) => !keyToIdMap.has(key));
292+
if (missingKeys.length > 0) {
293+
throw new Error(`Jobs not found for keys: ${missingKeys.join(', ')}`);
294+
}
295+
296+
return jobKeys.map((key) => keyToIdMap.get(key)!);
297+
}
298+
299+
/**
300+
* Calls the StopJobs OData action with resolved integer IDs.
301+
*/
302+
private async stopJobsByIds(
303+
jobIds: number[],
304+
strategy: StopStrategy,
305+
headers: Record<string, string>
306+
): Promise<void> {
307+
await this.post(
308+
JOB_ENDPOINTS.STOP,
309+
{ jobIds, strategy },
310+
{ headers, responseType: RESPONSE_TYPES.TEXT }
311+
);
312+
}
225313
}

src/utils/constants/endpoints/orchestrator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export const QUEUE_ENDPOINTS = {
6060
export const JOB_ENDPOINTS = {
6161
GET_ALL: `${ORCHESTRATOR_BASE}/odata/Jobs`,
6262
GET_BY_KEY: (identifier: string) => `${ORCHESTRATOR_BASE}/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier=${identifier})`,
63+
STOP: `${ORCHESTRATOR_BASE}/odata/Jobs/UiPath.Server.Configuration.OData.StopJobs`,
6364
} as const;
6465

6566
/**

tests/integration/shared/orchestrator/jobs.integration.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,56 @@ describe.each(modes)('Orchestrator Jobs - Integration Tests [%s]', (mode) => {
9191
});
9292
});
9393

94+
describe('stop', () => {
95+
it('should stop a running job with soft stop strategy', async () => {
96+
const { jobs, folderId } = getJobsService();
97+
98+
if (!folderId) {
99+
console.warn('INTEGRATION_TEST_FOLDER_ID not configured, skipping stop test.');
100+
return;
101+
}
102+
103+
// Find a running job to stop
104+
const runningJobs = await jobs.getAll({
105+
folderId,
106+
filter: "State eq 'Running'",
107+
pageSize: 1,
108+
});
109+
110+
if (runningJobs.items.length === 0) {
111+
console.warn('No running jobs available to test stop. Skipping.');
112+
return;
113+
}
114+
115+
const jobKey = runningJobs.items[0].key;
116+
117+
const result = await jobs.stop([jobKey], folderId);
118+
119+
expect(result).toBeDefined();
120+
expect(result.success).toBe(true);
121+
expect(result.data).toBeDefined();
122+
expect(result.data.jobIds).toBeDefined();
123+
expect(Array.isArray(result.data.jobIds)).toBe(true);
124+
expect(result.data.jobIds.length).toBe(1);
125+
expect(typeof result.data.jobIds[0]).toBe('number');
126+
});
127+
128+
it('should return empty result when called with empty array', async () => {
129+
const { jobs, folderId } = getJobsService();
130+
131+
if (!folderId) {
132+
console.warn('INTEGRATION_TEST_FOLDER_ID not configured, skipping stop test.');
133+
return;
134+
}
135+
136+
const result = await jobs.stop([], folderId);
137+
138+
expect(result).toBeDefined();
139+
expect(result.success).toBe(true);
140+
expect(result.data.jobIds).toEqual([]);
141+
});
142+
});
143+
94144
describe('Job structure validation', () => {
95145
it('should have expected fields in job objects', async () => {
96146
const { jobs, folderId } = getJobsService();

0 commit comments

Comments
 (0)