Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions agent_docs/rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Every new method must also have an integration test in `tests/integration/shared
- Use `registerResource()` from `tests/integration/utils/cleanup.ts` for cleanup tracking
- Use `generateRandomString()` from `tests/integration/utils/helpers.ts` for unique test data
- Tests run in both `v0` and `v1` init modes via `describe.each(modes)` — **only if the service is registered in both modes in `unified-setup.ts`**. New services that only support `v1` init should use `['v1']` only.
- **Always `throw new Error()` when test preconditions are not met** — whether it's missing config (e.g., no `folderId`) or missing test data (e.g., no running jobs). Never use `console.warn()` + `return` to silently skip — silent skips hide unrunnable tests and make CI green when tests aren't actually exercised.
- **NEVER** write redundant integration tests — each test must cover a distinct code path, error scenario, or response shape aspect.
- **Include a transform validation test** for new methods with a transform pipeline. This test should verify: (a) transformed camelCase fields exist and have values (`job.createdTime`, `job.processName`), AND (b) original PascalCase API fields are absent (`(job as any).CreationTime` is `undefined`, `(job as any).ReleaseName` is `undefined`). This is a separate test from the basic "should retrieve by ID" test — it validates the SDK transform layer against the live API. Note: existing integration tests don't yet follow this pattern, but unit tests do (Assets, Queues, ChoiceSets). Extending it to integration tests catches mismatches between the Swagger spec assumptions and the live API response.

Expand Down
1 change: 1 addition & 0 deletions docs/oauth-scopes.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ This page lists the specific OAuth scopes required in external app for each SDK
| `getAll()` | `OR.Jobs` or `OR.Jobs.Read` |
| `getById()` | `OR.Jobs` or `OR.Jobs.Read` |
| `getOutput()` | `OR.Jobs` or `OR.Jobs.Read`, `OR.Folders` or `OR.Folders.Read` |
| `stop()` | `OR.Jobs` |
Comment thread
Raina451 marked this conversation as resolved.

## Attachments

Expand Down
6 changes: 5 additions & 1 deletion src/core/http/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
}


private async request<T>(method: string, path: string, options: RequestSpec = {}): Promise<T> {

Check failure on line 52 in src/core/http/api-client.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-typescript&issues=AZ2rfcCf8ypp24qwVSZg&open=AZ2rfcCf8ypp24qwVSZg&pullRequest=337
// Ensure path starts with a forward slash
const normalizedPath = path.startsWith('/') ? path.substring(1) : path;

Expand Down Expand Up @@ -114,7 +114,11 @@
return text as T;
}

return response.json();
const text = await response.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text);
} catch (error: any) {
// If it's already one of our errors, re-throw it
if (error.type && error.type.includes('Error')) {
Expand Down
3 changes: 3 additions & 0 deletions src/models/orchestrator/jobs.constants.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/** Maximum number of job keys to resolve in a single OData filter query */
export const JOB_KEY_RESOLUTION_CHUNK_SIZE = 50;

/**
* Maps fields for Job entities to ensure consistent naming
* Semantic renames only — case conversion handled by pascalToCamelCaseKeys()
Expand Down
61 changes: 60 additions & 1 deletion src/models/orchestrator/jobs.models.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { JobGetAllOptions, JobGetByIdOptions, RawJobGetResponse } from './jobs.types';
import { JobGetAllOptions, JobGetByIdOptions, RawJobGetResponse, JobStopOptions } from './jobs.types';
import { PaginatedResponse, NonPaginatedResponse, HasPaginationOptions } from '../../utils/pagination';

/** Combined response type for job data with bound methods. */
Expand Down Expand Up @@ -129,6 +129,40 @@ export interface JobServiceModel {
* ```
*/
getOutput(jobKey: string, folderId: number): Promise<Record<string, unknown> | null>;

/**
* Stops one or more jobs by their UUID keys.
*
* Sends a stop request for the specified jobs to the Orchestrator. Throws if any keys cannot be resolved.
*
* @param jobKeys - Array of job UUID keys to stop (e.g., from {@link JobGetResponse}.key)
* @param folderId - The folder ID where the jobs reside (required)
* @param options - Optional {@link JobStopOptions} including stop strategy
* @returns Promise that resolves when the jobs are stopped successfully, or rejects on failure
*
* @example
* ```typescript
* // Stop a single job with default soft stop
* await jobs.stop([<jobKey>], <folderId>);
* ```
*
* @example
* ```typescript
* import { StopStrategy } from '@uipath/uipath-typescript/jobs';
*
* // Force-kill multiple jobs
* await jobs.stop(
* [<jobKey1>, <jobKey2>],
* <folderId>,
* { strategy: StopStrategy.Kill }
Comment thread
ninja-shreyash marked this conversation as resolved.
* );
* ```
*/
stop(
Comment thread
ninja-shreyash marked this conversation as resolved.
jobKeys: string[],
Comment thread
Raina451 marked this conversation as resolved.
folderId: number,
options?: JobStopOptions
): Promise<void>;
}

/**
Expand Down Expand Up @@ -156,6 +190,26 @@ export interface JobMethods {
* ```
*/
getOutput(): Promise<Record<string, unknown> | null>;

/**
* Stops this job.
*
* Sends a stop request for this job to the Orchestrator.
*
* @param options - Optional {@link JobStopOptions} including stop strategy (defaults to SoftStop)
* @returns Promise that resolves when the jobs are stopped successfully, or rejects on failure
*
* @example
* ```typescript
* const allJobs = await jobs.getAll({ folderId: <folderId> });
* const runningJob = allJobs.items.find(j => j.state === JobState.Running);
*
* if (runningJob) {
* await runningJob.stop();
* }
* ```
*/
stop(options?: JobStopOptions): Promise<void>;
}

/**
Expand All @@ -172,6 +226,11 @@ function createJobMethods(jobData: RawJobGetResponse, service: JobServiceModel):
if (!jobData.folderId) throw new Error('Job folderId is undefined');
return service.getOutput(jobData.key, jobData.folderId);
},
async stop(options?: JobStopOptions): Promise<void> {
if (!jobData.key) throw new Error('Job key is undefined');
if (!jobData.folderId) throw new Error('Job folderId is undefined');
return service.stop([jobData.key], jobData.folderId, options);
},
};
}

Expand Down
15 changes: 15 additions & 0 deletions src/models/orchestrator/jobs.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
FolderProperties,
} from './processes.types';

export { StopStrategy };

Check warning on line 17 in src/models/orchestrator/jobs.types.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use `export…from` to re-export `StopStrategy`.

See more on https://sonarcloud.io/project/issues?id=UiPath_uipath-typescript&issues=AZ2rfcGm8ypp24qwVSZh&open=AZ2rfcGm8ypp24qwVSZh&pullRequest=337

/**
* Enum for job sub-state
*/
Expand Down Expand Up @@ -171,3 +173,16 @@
*/
export interface JobGetByIdOptions extends BaseOptions {}

/**
* Options for stopping jobs
*/
export interface JobStopOptions {
/**
* The stop strategy to use.
* - `SoftStop` — requests graceful cancellation; the job completes its current activity before stopping
* - `Kill` — requests immediate termination of the job
* @default StopStrategy.SoftStop
*/
strategy?: StopStrategy;
Comment thread
ninja-shreyash marked this conversation as resolved.
Comment thread
ninja-shreyash marked this conversation as resolved.
}

112 changes: 110 additions & 2 deletions src/services/orchestrator/jobs/jobs.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { FolderScopedService } from '../../folder-scoped';
import { RawJobGetResponse, JobGetAllOptions, JobGetByIdOptions } from '../../../models/orchestrator/jobs.types';
import { RawJobGetResponse, JobGetAllOptions, JobGetByIdOptions, JobStopOptions } from '../../../models/orchestrator/jobs.types';
import { JobServiceModel, JobGetResponse, createJobWithMethods } from '../../../models/orchestrator/jobs.models';
import { addPrefixToKeys, pascalToCamelCaseKeys, transformData } from '../../../utils/transform';
import { JOB_ENDPOINTS } from '../../../utils/constants/endpoints';
import { ODATA_PAGINATION, ODATA_OFFSET_PARAMS, ODATA_PREFIX } from '../../../utils/constants/common';
import { JobMap } from '../../../models/orchestrator/jobs.constants';
import { JobMap, JOB_KEY_RESOLUTION_CHUNK_SIZE } from '../../../models/orchestrator/jobs.constants';
import { AttachmentService } from '../attachments/attachments';
import { ValidationError, ServerError } from '../../../core/errors';
import { ErrorFactory } from '../../../core/errors/error-factory';
Expand All @@ -16,6 +16,7 @@ import { PaginationHelpers } from '../../../utils/pagination/helpers';
import { PaginationType } from '../../../utils/pagination/internal-types';
import { track } from '../../../core/telemetry';
import type { IUiPath } from '../../../core/types';
import { StopStrategy } from '../../../models/orchestrator/processes.types';

/**
* Service for interacting with UiPath Orchestrator Jobs API
Expand Down Expand Up @@ -211,6 +212,56 @@ export class JobService extends FolderScopedService implements JobServiceModel {
return null;
}

/**
* Stops one or more jobs by their UUID keys.
*
* Sends a stop request for the specified jobs to the Orchestrator. Throws if any keys cannot be resolved.
*
* @param jobKeys - Array of job UUID keys to stop (e.g., from {@link JobGetResponse}.key)
* @param folderId - The folder ID where the jobs reside (required)
* @param options - Optional {@link JobStopOptions} including stop strategy
* @returns Promise that resolves when the jobs are stopped successfully, or rejects on failure
*
* @example
* ```typescript
* // Stop a single job with default soft stop
* await jobs.stop([<jobKey>], <folderId>);
* ```
*
* @example
* ```typescript
* import { StopStrategy } from '@uipath/uipath-typescript/jobs';
*
* // Force-kill multiple jobs
* await jobs.stop(
* [<jobKey1>, <jobKey2>],
* <folderId>,
* { strategy: StopStrategy.Kill }
* );
* ```
*/
@track('Jobs.Stop')
async stop(
jobKeys: string[],
folderId: number,
options?: JobStopOptions
): Promise<void> {
if (jobKeys.length === 0) {
return;
}
Comment thread
ninja-shreyash marked this conversation as resolved.

if (!folderId) {
throw new ValidationError({ message: 'folderId is required for stop' });
}

const headers = createHeaders({ [FOLDER_ID]: folderId });
const strategy = options?.strategy ?? StopStrategy.SoftStop;

const jobIds = await this.resolveJobKeys(jobKeys, folderId);

await this.stopJobsByIds(jobIds, strategy, headers);
}

/**
* Downloads the output file content via the Attachments API.
* 1. Fetches blob access info from the attachment using AttachmentService
Expand Down Expand Up @@ -252,4 +303,61 @@ export class JobService extends FolderScopedService implements JobServiceModel {
throw new ServerError({ message: 'Failed to parse job output file as JSON' });
}
}

/**
* Resolves job UUID keys to integer IDs via the getAll method.
* Chunks keys into batches to avoid URL length limits.
*/
private async resolveJobKeys(
Comment thread
ninja-shreyash marked this conversation as resolved.
Comment thread
ninja-shreyash marked this conversation as resolved.
jobKeys: string[],
folderId: number
): Promise<number[]> {
const uniqueKeys = [...new Set(jobKeys)];
const keyToIdMap = new Map<string, number>();

const chunks: string[][] = [];
for (let i = 0; i < uniqueKeys.length; i += JOB_KEY_RESOLUTION_CHUNK_SIZE) {
chunks.push(uniqueKeys.slice(i, i + JOB_KEY_RESOLUTION_CHUNK_SIZE));
}

const results = await Promise.all(
chunks.map((chunk) => {
const filterValues = chunk.map((key) => `'${key}'`).join(',');
return this.getAll({
folderId,
filter: `key in (${filterValues})`,
select: 'id,key',
pageSize: chunk.length,
});
})
);

for (const response of results) {
for (const job of response.items) {
keyToIdMap.set(job.key, job.id);
}
}

const missingKeys = uniqueKeys.filter((key) => !keyToIdMap.has(key));
if (missingKeys.length > 0) {
throw new ValidationError({ message: `Jobs not found for keys: ${missingKeys.join(', ')}` });
}

return uniqueKeys.map((key) => keyToIdMap.get(key)!);
}

/**
* Calls the StopJobs OData action with resolved integer IDs.
*/
private async stopJobsByIds(
jobIds: number[],
strategy: StopStrategy,
headers: Record<string, string>
): Promise<void> {
await this.post(
JOB_ENDPOINTS.STOP,
{ jobIds, strategy },
{ headers }
);
}
}
1 change: 1 addition & 0 deletions src/utils/constants/endpoints/orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export const QUEUE_ENDPOINTS = {
export const JOB_ENDPOINTS = {
GET_ALL: `${ORCHESTRATOR_BASE}/odata/Jobs`,
GET_BY_KEY: (identifier: string) => `${ORCHESTRATOR_BASE}/odata/Jobs/UiPath.Server.Configuration.OData.GetByKey(identifier=${identifier})`,
STOP: `${ORCHESTRATOR_BASE}/odata/Jobs/UiPath.Server.Configuration.OData.StopJobs`,
} as const;

/**
Expand Down
33 changes: 33 additions & 0 deletions tests/integration/shared/orchestrator/jobs.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,39 @@ describe.each(modes)('Orchestrator Jobs - Integration Tests [%s]', (mode) => {
});
});

describe('stop', () => {
it('should start a process and then stop the resulting job', async () => {
const { jobs, folderId } = getJobsService();
const { processes } = getServices();
const config = getTestConfig();

if (!folderId) {
throw new Error('INTEGRATION_TEST_FOLDER_ID not configured — cannot run stop test.');
}

const processKey = config.orchestratorTestProcessKey;
if (!processKey) {
throw new Error('ORCHESTRATOR_TEST_PROCESS_KEY not configured — cannot run stop test.');
}

// Start a process to create a job
const startedJobs = await processes.start({ processKey }, folderId);
expect(startedJobs.length).toBeGreaterThan(0);

const jobKey = startedJobs[0].key;

// Stop the job we just started — resolves without error on success
await jobs.stop([jobKey], folderId);
});

it('should return empty result when called with empty array', async () => {
const { jobs } = getJobsService();

// folderId is unused for empty-array inputs — stop() returns early before reading it
await jobs.stop([], 0);
});
});

describe('Job structure validation', () => {
it('should have expected fields in job objects', async () => {
const { jobs, folderId } = getJobsService();
Expand Down
Loading
Loading