From d5e35d48623e92126a807b572aa84be451ec6b27 Mon Sep 17 00:00:00 2001 From: Mikhail Kuryshev Date: Wed, 24 Jun 2026 10:51:41 +0200 Subject: [PATCH] fix(CON-366): fix run and run item status mapping Implement fixes similar to how it is done in https://github.com/aignostics/platform-console/pull/185 --- .../process-application-run.spec.ts | 6 +- .../sdk/src/entities/application-run/types.ts | 3 +- .../entities/application-run/utils.spec.ts | 72 ++++++++++++++----- .../sdk/src/entities/application-run/utils.ts | 43 +++++++---- .../run-item/process-run-item.spec.ts | 7 ++ packages/sdk/src/entities/run-item/types.ts | 2 +- .../sdk/src/entities/run-item/utils.spec.ts | 41 +++++++++++ packages/sdk/src/entities/run-item/utils.ts | 31 ++++++-- 8 files changed, 164 insertions(+), 41 deletions(-) diff --git a/packages/sdk/src/entities/application-run/process-application-run.spec.ts b/packages/sdk/src/entities/application-run/process-application-run.spec.ts index a8692ec..b007ae5 100644 --- a/packages/sdk/src/entities/application-run/process-application-run.spec.ts +++ b/packages/sdk/src/entities/application-run/process-application-run.spec.ts @@ -3,7 +3,7 @@ import { processApplicationRun } from './process-application-run.js'; import type { RunReadResponse } from '../../generated/index.js'; function buildRun( - overrides: Partial> & { + overrides: Partial> & { statistics?: Partial; } = {} ): RunReadResponse { @@ -12,7 +12,7 @@ function buildRun( application_id: 'app-1', version_number: '1.0.0', state: overrides.state ?? 'PENDING', - output: 'NONE', + output: overrides.output ?? 'NONE', termination_reason: overrides.termination_reason ?? null, error_code: null, error_message: null, @@ -62,6 +62,7 @@ describe('processApplicationRun', () => { const raw = buildRun({ state: 'TERMINATED', termination_reason: 'ALL_ITEMS_PROCESSED', + output: 'FULL', statistics: { item_count: 10, item_succeeded_count: 10 }, }); const result = processApplicationRun(raw); @@ -75,6 +76,7 @@ describe('processApplicationRun', () => { const raw = buildRun({ state: 'TERMINATED', termination_reason: 'ALL_ITEMS_PROCESSED', + output: 'PARTIAL', statistics: { item_count: 10, item_succeeded_count: 7, diff --git a/packages/sdk/src/entities/application-run/types.ts b/packages/sdk/src/entities/application-run/types.ts index 2489b88..52b0092 100644 --- a/packages/sdk/src/entities/application-run/types.ts +++ b/packages/sdk/src/entities/application-run/types.ts @@ -23,4 +23,5 @@ export type RunStatus = | 'COMPLETED' | 'COMPLETED_WITH_ERRORS' | 'CANCELED' - | 'FAILED'; + | 'FAILED' + | 'UNKNOWN'; diff --git a/packages/sdk/src/entities/application-run/utils.spec.ts b/packages/sdk/src/entities/application-run/utils.spec.ts index 3c9a5a6..daa1f83 100644 --- a/packages/sdk/src/entities/application-run/utils.spec.ts +++ b/packages/sdk/src/entities/application-run/utils.spec.ts @@ -7,7 +7,7 @@ import type { RunReadResponse } from '../../generated/index.js'; * Only the fields used by the utility functions need to be realistic. */ function buildRun( - overrides: Partial> & { + overrides: Partial> & { statistics?: Partial; } = {} ): RunReadResponse { @@ -16,7 +16,7 @@ function buildRun( application_id: 'app-1', version_number: '1.0.0', state: overrides.state ?? 'PENDING', - output: 'NONE', + output: overrides.output ?? 'NONE', termination_reason: overrides.termination_reason ?? null, error_code: null, error_message: null, @@ -118,44 +118,75 @@ describe('getRunStatus', () => { expect(getRunStatus(run)).toBe('CANCELED'); }); - it('should return FAILED when terminated by system', () => { + it('should return FAILED when all items processed but output is NONE', () => { + const run = buildRun({ + state: 'TERMINATED', + termination_reason: 'ALL_ITEMS_PROCESSED', + output: 'NONE', + }); + expect(getRunStatus(run)).toBe('FAILED'); + }); + + it('should return FAILED when canceled by system with no output', () => { const run = buildRun({ state: 'TERMINATED', termination_reason: 'CANCELED_BY_SYSTEM', + output: 'NONE', }); expect(getRunStatus(run)).toBe('FAILED'); }); - it('should return COMPLETED when all items succeeded', () => { + it('should return COMPLETED when all items processed and output is FULL', () => { const run = buildRun({ state: 'TERMINATED', termination_reason: 'ALL_ITEMS_PROCESSED', - statistics: { item_count: 10, item_succeeded_count: 10 }, + output: 'FULL', + }); + expect(getRunStatus(run)).toBe('COMPLETED'); + }); + + it('should return COMPLETED when canceled by system but output is FULL', () => { + const run = buildRun({ + state: 'TERMINATED', + termination_reason: 'CANCELED_BY_SYSTEM', + output: 'FULL', }); expect(getRunStatus(run)).toBe('COMPLETED'); }); - it('should return COMPLETED_WITH_ERRORS when not all items succeeded', () => { + it('should return COMPLETED_WITH_ERRORS when all items processed and output is PARTIAL', () => { const run = buildRun({ state: 'TERMINATED', termination_reason: 'ALL_ITEMS_PROCESSED', - statistics: { item_count: 10, item_succeeded_count: 7 }, + output: 'PARTIAL', }); expect(getRunStatus(run)).toBe('COMPLETED_WITH_ERRORS'); }); - it('should return COMPLETED_WITH_ERRORS when some items had errors', () => { + it('should return COMPLETED_WITH_ERRORS when canceled by system with output PARTIAL', () => { const run = buildRun({ state: 'TERMINATED', - termination_reason: 'ALL_ITEMS_PROCESSED', - statistics: { - item_count: 10, - item_succeeded_count: 8, - item_user_error_count: 2, - }, + termination_reason: 'CANCELED_BY_SYSTEM', + output: 'PARTIAL', }); expect(getRunStatus(run)).toBe('COMPLETED_WITH_ERRORS'); }); + + it('should return UNKNOWN when terminated with no termination reason', () => { + const run = buildRun({ + state: 'TERMINATED', + termination_reason: null, + }); + expect(getRunStatus(run)).toBe('UNKNOWN'); + }); + + it('should return UNKNOWN for an unrecognized termination reason', () => { + const run = buildRun({ + state: 'TERMINATED', + termination_reason: 'SOMETHING_ELSE' as RunReadResponse['termination_reason'], + }); + expect(getRunStatus(run)).toBe('UNKNOWN'); + }); }); describe('canDownloadRunItems', () => { @@ -181,6 +212,7 @@ describe('canDownloadRunItems', () => { const run = buildRun({ state: 'TERMINATED', termination_reason: 'CANCELED_BY_SYSTEM', + output: 'NONE', }); expect(canDownloadRunItems(run)).toBe(false); }); @@ -189,7 +221,7 @@ describe('canDownloadRunItems', () => { const run = buildRun({ state: 'TERMINATED', termination_reason: 'ALL_ITEMS_PROCESSED', - statistics: { item_count: 5, item_succeeded_count: 5 }, + output: 'FULL', }); expect(canDownloadRunItems(run)).toBe(true); }); @@ -198,7 +230,15 @@ describe('canDownloadRunItems', () => { const run = buildRun({ state: 'TERMINATED', termination_reason: 'ALL_ITEMS_PROCESSED', - statistics: { item_count: 5, item_succeeded_count: 3 }, + output: 'PARTIAL', + }); + expect(canDownloadRunItems(run)).toBe(true); + }); + + it('should return true for UNKNOWN runs', () => { + const run = buildRun({ + state: 'TERMINATED', + termination_reason: null, }); expect(canDownloadRunItems(run)).toBe(true); }); diff --git a/packages/sdk/src/entities/application-run/utils.ts b/packages/sdk/src/entities/application-run/utils.ts index 421e868..dfb56e8 100644 --- a/packages/sdk/src/entities/application-run/utils.ts +++ b/packages/sdk/src/entities/application-run/utils.ts @@ -28,36 +28,49 @@ export function getRunProgress(run: RunReadResponse): number { } /** - * Derive a human-readable {@link RunStatus} from the raw API `state` and `termination_reason`. + * Derive a human-readable {@link RunStatus} from the raw API `state`, + * `termination_reason`, and `output`. * * Mapping rules: * - PENDING / PROCESSING → passed through as-is * - TERMINATED + CANCELED_BY_USER → CANCELED - * - TERMINATED + CANCELED_BY_SYSTEM → FAILED - * - TERMINATED + not all items succeeded → COMPLETED_WITH_ERRORS - * - TERMINATED + all items succeeded → COMPLETED + * - TERMINATED + CANCELED_BY_SYSTEM or ALL_ITEMS_PROCESSED → derived from `output`: + * - NONE → FAILED (no items succeeded) + * - PARTIAL → COMPLETED_WITH_ERRORS (some items succeeded) + * - FULL → COMPLETED (all items succeeded) + * - otherwise → UNKNOWN + * - any other termination reason, or non-terminal state, → UNKNOWN * * @param run - Raw run response from the API */ export function getRunStatus(run: RunReadResponse): RunStatus { - const { state, statistics, termination_reason } = run; + const { state, termination_reason, output } = run; switch (state) { case 'PENDING': return 'PENDING'; case 'PROCESSING': return 'PROCESSING'; case 'TERMINATED': - if (termination_reason === 'CANCELED_BY_USER') { - return 'CANCELED'; + switch (termination_reason) { + case 'CANCELED_BY_USER': + return 'CANCELED'; + case 'CANCELED_BY_SYSTEM': + case 'ALL_ITEMS_PROCESSED': + switch (output) { + case 'NONE': + return 'FAILED'; + case 'PARTIAL': + return 'COMPLETED_WITH_ERRORS'; + case 'FULL': + return 'COMPLETED'; + default: + return 'UNKNOWN'; + } + default: + return 'UNKNOWN'; } - if (termination_reason === 'CANCELED_BY_SYSTEM') { - return 'FAILED'; - } - // If any items did not succeed, the run completed with errors - if (statistics.item_count !== statistics.item_succeeded_count) { - return 'COMPLETED_WITH_ERRORS'; - } - return 'COMPLETED'; + default: + return 'UNKNOWN'; } } diff --git a/packages/sdk/src/entities/run-item/process-run-item.spec.ts b/packages/sdk/src/entities/run-item/process-run-item.spec.ts index d17c2f0..6e0e09e 100644 --- a/packages/sdk/src/entities/run-item/process-run-item.spec.ts +++ b/packages/sdk/src/entities/run-item/process-run-item.spec.ts @@ -79,4 +79,11 @@ describe('processRunItem', () => { expect(result.status).toBe('SKIPPED'); expect(result.can_download).toBe(false); }); + + it('should mark a terminated item with no termination reason as UNKNOWN and not downloadable', () => { + const result = processRunItem(buildItem({ state: 'TERMINATED' })); + + expect(result.status).toBe('UNKNOWN'); + expect(result.can_download).toBe(false); + }); }); diff --git a/packages/sdk/src/entities/run-item/types.ts b/packages/sdk/src/entities/run-item/types.ts index bd3d1c1..fb60b3b 100644 --- a/packages/sdk/src/entities/run-item/types.ts +++ b/packages/sdk/src/entities/run-item/types.ts @@ -15,4 +15,4 @@ export interface ApplicationRunItem extends ItemResultReadResponse { } /** Derived item status that simplifies the raw `state` + `termination_reason` combination. */ -export type ItemStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'SKIPPED'; +export type ItemStatus = 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED' | 'SKIPPED' | 'UNKNOWN'; diff --git a/packages/sdk/src/entities/run-item/utils.spec.ts b/packages/sdk/src/entities/run-item/utils.spec.ts index 28713a0..c2fea71 100644 --- a/packages/sdk/src/entities/run-item/utils.spec.ts +++ b/packages/sdk/src/entities/run-item/utils.spec.ts @@ -49,11 +49,48 @@ describe('getItemStatus', () => { ).toBe('FAILED'); }); + it('should return FAILED when terminated with CANCELED_BY_SYSTEM', () => { + expect( + getItemStatus( + buildItem({ + state: 'TERMINATED', + termination_reason: 'CANCELED_BY_SYSTEM' as ItemResultReadResponse['termination_reason'], + }) + ) + ).toBe('FAILED'); + }); + + it('should return FAILED when terminated with CANCELED_BY_USER', () => { + expect( + getItemStatus( + buildItem({ + state: 'TERMINATED', + termination_reason: 'CANCELED_BY_USER' as ItemResultReadResponse['termination_reason'], + }) + ) + ).toBe('FAILED'); + }); + it('should return SKIPPED when terminated with SKIPPED', () => { expect(getItemStatus(buildItem({ state: 'TERMINATED', termination_reason: 'SKIPPED' }))).toBe( 'SKIPPED' ); }); + + it('should return UNKNOWN when terminated with no termination reason', () => { + expect(getItemStatus(buildItem({ state: 'TERMINATED' }))).toBe('UNKNOWN'); + }); + + it('should return UNKNOWN for an unrecognized termination reason', () => { + expect( + getItemStatus( + buildItem({ + state: 'TERMINATED', + termination_reason: 'SOMETHING_ELSE' as ItemResultReadResponse['termination_reason'], + }) + ) + ).toBe('UNKNOWN'); + }); }); describe('canDownloadItem', () => { @@ -88,4 +125,8 @@ describe('canDownloadItem', () => { false ); }); + + it('should return false for UNKNOWN items', () => { + expect(canDownloadItem(buildItem({ state: 'TERMINATED' }))).toBe(false); + }); }); diff --git a/packages/sdk/src/entities/run-item/utils.ts b/packages/sdk/src/entities/run-item/utils.ts index 01a5786..ba23d2b 100644 --- a/packages/sdk/src/entities/run-item/utils.ts +++ b/packages/sdk/src/entities/run-item/utils.ts @@ -12,14 +12,24 @@ export const canDownloadItem = (item: ItemResultReadResponse): boolean => { return getItemStatus(item) === 'COMPLETED'; }; +/** Termination reasons that mark a terminated item as FAILED. */ +const ERROR_TERMINATION_REASONS = new Set([ + 'CANCELED_BY_SYSTEM', + 'CANCELED_BY_USER', + 'SYSTEM_ERROR', + 'USER_ERROR', +]); + /** * Derive a human-readable {@link ItemStatus} from the raw API `state` and `termination_reason`. * * Mapping rules: * - PENDING / PROCESSING → passed through as-is - * - TERMINATED + SYSTEM_ERROR or USER_ERROR → FAILED + * - TERMINATED + no termination_reason → UNKNOWN + * - TERMINATED + an error reason (SYSTEM_ERROR, USER_ERROR, CANCELED_BY_SYSTEM, CANCELED_BY_USER) → FAILED * - TERMINATED + SKIPPED → SKIPPED - * - TERMINATED (otherwise) → COMPLETED + * - TERMINATED + SUCCEEDED → COMPLETED + * - TERMINATED + any unrecognized reason → UNKNOWN * * @param item - Raw item response from the API */ @@ -31,14 +41,23 @@ export function getItemStatus(item: ItemResultReadResponse): ItemStatus { case 'PROCESSING': return 'PROCESSING'; case 'TERMINATED': - // Items terminated due to errors are marked as FAILED - if (termination_reason === 'SYSTEM_ERROR' || termination_reason === 'USER_ERROR') { + if (!termination_reason) { + // tip: report to the sentry in the consumer + return 'UNKNOWN'; + } + if (ERROR_TERMINATION_REASONS.has(termination_reason)) { return 'FAILED'; } - // Explicitly skipped items get their own status + if (termination_reason === 'SKIPPED') { return 'SKIPPED'; } - return 'COMPLETED'; + + if (termination_reason === 'SUCCEEDED') { + return 'COMPLETED'; + } + + // tip: report to the sentry in the consumer + return 'UNKNOWN'; } }