import type { JobRead, SegmentationRunRead, SegmentationRunRequest, SegmentationRunResponse } from '../types' import { jobsApi } from './api/jobs' const ACTIVE_JOB_STATUSES = new Set(['queued', 'running']) const TERMINAL_FAILURE_STATUSES = new Set(['failed', 'cancelled', 'partial']) export const SEGMENTATION_JOB_POLL_INTERVAL_MS = 1_500 export const SEGMENTATION_JOB_TIMEOUT_MS = 30 * 60 * 1_000 export class SegmentationJobError extends Error { readonly code: string readonly jobId: string constructor(message: string, code: string, jobId: string) { super(message) this.name = 'SegmentationJobError' this.code = code this.jobId = jobId } } interface WaitForSegmentationJobOptions { projectId: string initialJob: JobRead signal?: AbortSignal intervalMs?: number timeoutMs?: number maxConsecutiveReadErrors?: number readJob?: (projectId: string, jobId: string) => Promise onStatus?: (job: JobRead) => void } function abortedError(): Error { const error = new Error('Het volgen van de segmentatietaak is gestopt') error.name = 'AbortError' return error } function wait(milliseconds: number, signal?: AbortSignal): Promise { if (signal?.aborted) { return Promise.reject(abortedError()) } if (milliseconds <= 0) { return Promise.resolve() } return new Promise((resolve, reject) => { const timer = window.setTimeout(() => { signal?.removeEventListener('abort', onAbort) resolve() }, milliseconds) const onAbort = () => { window.clearTimeout(timer) signal?.removeEventListener('abort', onAbort) reject(abortedError()) } signal?.addEventListener('abort', onAbort, { once: true }) }) } function stringValue(record: Record | null | undefined, key: string): string | null { const value = record?.[key] return typeof value === 'string' && value.trim() ? value.trim() : null } function numberValue(record: Record | null | undefined, key: string): number | null { const value = record?.[key] return typeof value === 'number' && Number.isFinite(value) ? value : null } function assertSegmentationJobIdentity(projectId: string, job: JobRead): void { if (job.project_id !== projectId || job.job_type !== 'segmentation.run') { throw new SegmentationJobError( 'De server koppelde een onverwachte taak aan deze segmentatie', 'SEGMENTATION_JOB_IDENTITY_MISMATCH', job.id, ) } } /** Follow one queued GPU segmentation until the backend marks it terminal. */ export async function waitForSegmentationJob({ projectId, initialJob, signal, intervalMs = SEGMENTATION_JOB_POLL_INTERVAL_MS, timeoutMs = SEGMENTATION_JOB_TIMEOUT_MS, maxConsecutiveReadErrors = 3, readJob = jobsApi.get, onStatus, }: WaitForSegmentationJobOptions): Promise { const startedAt = Date.now() let job = initialJob let consecutiveReadErrors = 0 while (true) { if (signal?.aborted) { throw abortedError() } assertSegmentationJobIdentity(projectId, job) onStatus?.(job) if (job.status === 'success') { return job } if (TERMINAL_FAILURE_STATUSES.has(job.status)) { const code = stringValue(job.result_json, 'error_code') ?? `SEGMENTATION_JOB_${job.status.toUpperCase()}` const message = job.error_message ?? stringValue(job.result_json, 'message') ?? 'De GPU-taak is niet volledig uitgevoerd' throw new SegmentationJobError(message, code, job.id) } if (!ACTIVE_JOB_STATUSES.has(job.status)) { throw new SegmentationJobError( `De segmentatietaak heeft een onbekende status: ${job.status}`, 'SEGMENTATION_JOB_STATUS_INVALID', job.id, ) } if (Date.now() - startedAt >= timeoutMs) { throw new SegmentationJobError( 'De segmentatietaak loopt nog op de server, maar de wachttijd in dit scherm is verstreken. Herlaad de bewaarde segmentatieruns om het resultaat later te bekijken.', 'SEGMENTATION_JOB_POLL_TIMEOUT', job.id, ) } await wait(intervalMs, signal) try { job = await readJob(projectId, job.id) consecutiveReadErrors = 0 } catch (error) { if (signal?.aborted) { throw abortedError() } consecutiveReadErrors += 1 if (consecutiveReadErrors >= maxConsecutiveReadErrors) { throw error } } } } /** Convert persisted server evidence into the UI summary contract. */ export function completedSegmentationResponse( request: SegmentationRunRequest, job: JobRead, run: SegmentationRunRead, ): SegmentationRunResponse { assertSegmentationJobIdentity(request.project_id, job) if ( job.status !== 'success' || run.status !== 'success' || run.analysis_type !== 'segmentation' || run.project_id !== request.project_id || run.dataset_id !== request.dataset_id || run.job_id !== job.id || (run.model_name != null && run.model_name !== request.model_id) ) { throw new SegmentationJobError( 'De bewaarde segmentatierun komt niet overeen met de voltooide GPU-taak', 'SEGMENTATION_RUN_RESULT_MISMATCH', job.id, ) } const segmentationCount = numberValue(job.result_json, 'segmentation_count') ?? numberValue(run.result_json, 'segmentation_count') if (segmentationCount === null || !Number.isInteger(segmentationCount) || segmentationCount < 0) { throw new SegmentationJobError( 'De voltooide segmentatietaak bevat geen geldige, herleidbare vlakkentelling', 'SEGMENTATION_RUN_RESULT_INCOMPLETE', job.id, ) } return { analysis_run_id: run.id, job_id: job.id, project_id: request.project_id, dataset_id: request.dataset_id, model_id: run.model_name ?? request.model_id, status: 'success', segmentation_count: segmentationCount, error_code: null, message: segmentationCount === 0 ? 'Segmentatie voltooid zonder vlakken boven de gekozen zekerheidsdrempel. Dit bewijst niet dat het gebied geen relevante objecten bevat.' : 'GPU-segmentatie voltooid; de bewaarde vlakken zijn geladen.', } } export function analysisRunIdFromSegmentationJob(job: JobRead): string | null { return stringValue(job.result_json, 'analysis_run_id') }