diff --git a/.env.example b/.env.example index 4d4dc59bf..ff34c3226 100644 --- a/.env.example +++ b/.env.example @@ -8,10 +8,6 @@ VITE_DICOM_WEB_NAME=ACME Hospital # Error reporting URL for sentry.io. VITE_SENTRY_DSN= -# If this env var exists and is true and there is a `save` URL parameter, -# clicking the save button POSTS the session.volview.zip file to the specifed URL. -VITE_ENABLE_REMOTE_SAVE=true - # VolView server remote URL VITE_REMOTE_SERVER_URL=http://localhost:4014 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b3fe446b9..892175fdf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -54,8 +54,8 @@ jobs: echo "Setting version to $VERSION for prerelease" fi - - name: Build with remote save enabled - run: VITE_REMOTE_SERVER_URL= VITE_ENABLE_REMOTE_SAVE=true npm run build + - name: Build + run: VITE_REMOTE_SERVER_URL= npm run build - name: Publish to npm run: npm publish --tag ${{ steps.version.outputs.tag }} --access public --provenance diff --git a/backend-contract/README.md b/backend-contract/README.md new file mode 100644 index 000000000..bd38ff58e --- /dev/null +++ b/backend-contract/README.md @@ -0,0 +1,123 @@ +# backend-contract + +> **Status: private draft `0.x`.** No stability promise; shapes may change any +> day. The artifact version is the OpenAPI `info.version` in +> `generated/openapi.json`, mirrored by `package.json` (a drift test keeps them +> in lockstep). Sole known consumer: girder_volview, which reads this tree from +> the installed `volview` npm package. + +The neutral VolView backend contract: task discovery, inputs, the job +lifecycle, result intents, and personal job history. No backend speaks these +shapes natively; each backend translates its own task format into this spec. + +To bring a new backend online, implement the [OpenAPI surface](#the-neutral-rest-surface-openapi) +and validate against the fixtures and generated schemas. Zero VolView client +change, and no need to read girder_volview source: girder_volview is a +consumer of this package, not its owner. + +## Layout + +``` +backend-contract/ + index.ts top-level barrel; re-exports ./processing + processing/ + task-spec.ts VolView's zod task-spec schema + wire.ts neutral wire shapes: input value, job status, job history, + result-intent vocabulary + openapi.ts the REST surface as an OpenAPI 3.1 document (wire schemas + injected from the zod codegen) + schema-json.ts zod -> JSON Schema codegen + index.ts re-exports schemas + types (not codegen/openapi) + __tests__/ every fixture validates; negatives fail; generated + artifacts stay in sync with the zod source + generated/ checked-in *.schema.json (one per wire schema) + openapi.json + fixtures/ + task-spec/ synthetic golden task specs (no backend-native formats; + translation is a backend concern) + negative/ payloads that MUST fail validation + wire/ input values, job statuses, result intents, job handle, + result-read payloads + scripts/ + generate-json-schema.ts regenerates generated/*.schema.json + generate-openapi.ts regenerates generated/openapi.json +``` + +## The single normative definition + +The zod sources are the one normative definition. The golden JSON fixtures are +the interchange format both sides pin, and the generated JSON Schemas are the +backend's validator, codegen'd from the zod source so the two can't drift. + +Task specs require two validation passes. First validate the generated +`task-spec.schema.json`, then enforce the cross-field rules implemented by +`validateTaskSpecSemantics` (or an equivalent implementation in the backend's +language). Standard JSON Schema cannot compare sibling values such as +`default <= max`. Backend conformance tests must also assert that every payload +under `fixtures/negative/` is rejected by the combined validation path. + +## The neutral REST surface (OpenAPI) + +`generated/openapi.json` (built from `processing/openapi.ts`) describes exactly +the endpoints the client calls, in neutral terms: no Girder routes, ids, or +enums. + +| operation | method + path | request → response | +| --------------------- | ---------------------------- | ------------------------------------------------------------------------------------- | +| `listTasks` | `GET /tasks` | → `TaskSummary[]` | +| `getTaskSpec` | `GET /tasks/{taskId}/spec` | → `TaskSpec` | +| `runTask` | `POST /tasks/{taskId}/run` | `RunTaskRequest` → `JobRef` | +| `listJobHistory` | `GET /jobs` | → paged `JobHistorySummary[]` (optional) | +| `getJobHistoryDetail` | `GET /jobs/{jobId}/detail` | → logs + submitted parameters on demand | +| `deleteJob` | `DELETE /jobs/{jobId}` | → cascading deletion: execution record, results, staged inputs (terminal jobs only; 409 otherwise) | +| `stageInput` | `POST /stage` | parent-bound labelmap multipart → `StageResponse` (optional) | +| `getJob` | `GET /jobs/{jobId}` | → `NeutralJobStatus` | +| `getJobResults` | `GET /jobs/{jobId}/results` | → result intents, or explicit error | +| `cancelJob` | `POST /jobs/{jobId}/cancel` | → `NeutralJobStatus` | + +Notes: + +- The lifecycle is poll-only (`getJob`); push (SSE) is an additive backend-only + enhancement, not described here. +- Job-addressed routes are keyed by the opaque job id alone; the job's own + access control is the gate, so no context leaks into the path. +- `JobHistorySummary.outputSummary.recorded` counts declared outputs that + recorded and resolve to a readable file; `missing` counts the rest (never + recorded, or no longer readable). + +## Job-state names + +The neutral job states are `pending | running | success | error | cancelled`, +the names the client store consumes at runtime. A backend-side conformance test +(girder_volview `tests/test_status_conformance.py`) validates the projected +status against the generated `neutral-job-status` schema. + +## Versioning + +Two versions on separate clocks: + +- **Artifact version**: `package.json` `version` and the OpenAPI + `info.version`, kept in lockstep by `processing/__tests__/openapi.spec.ts`. + Versions this package as a published thing. +- **Shape versions**: `INTENT_VOCABULARY_VERSION` (`processing/wire.ts`) and + the task-spec `specVersion`. These version the wire vocabulary for additive + compatibility negotiation. + +## Regenerating + +``` +npx tsx backend-contract/scripts/generate-json-schema.ts # rewrite generated/*.schema.json +npx tsx backend-contract/scripts/generate-openapi.ts # rewrite generated/openapi.json +``` + +Drift guards (`processing/__tests__/generated-schema.spec.ts`, +`processing/__tests__/openapi.spec.ts`) fail if the checked-in artifacts fall +out of sync with the zod source. + +## How the backend consumes this + +The `volview` npm package ships `backend-contract/` in its `files`; +girder_volview reads fixtures + generated schemas from the installed package +via its `tests/contract_loader.py`. No vendored copy, no sync step: the +exact-pinned `volview` version in girder_volview's `web_client/package.json` IS +the contract pin. For unreleased branches use `npm link` or the +`GIRDER_VOLVIEW_CONTRACT_DIR` escape hatch (see the loader's docstring). diff --git a/backend-contract/fixtures/negative/constraint-violation.json b/backend-contract/fixtures/negative/constraint-violation.json new file mode 100644 index 000000000..cee546aa5 --- /dev/null +++ b/backend-contract/fixtures/negative/constraint-violation.json @@ -0,0 +1,26 @@ +{ + "specVersion": 1, + "id": "ConstraintViolation", + "title": "Constraint Violation (must fail validation)", + "description": "An int parameter whose `default` (99) exceeds its `max` (10). The schema must reject a self-inconsistent constraint set.", + "parameters": [ + { + "kind": "sourceRef", + "id": "inputVolume", + "title": "Input Volume", + "required": true, + "accepts": [ + "image" + ] + }, + { + "kind": "int", + "id": "radius", + "title": "Radius", + "min": 1, + "max": 10, + "default": 99 + } + ], + "outputs": [] +} diff --git a/backend-contract/fixtures/negative/empty-uris.json b/backend-contract/fixtures/negative/empty-uris.json new file mode 100644 index 000000000..39ec151bf --- /dev/null +++ b/backend-contract/fixtures/negative/empty-uris.json @@ -0,0 +1,5 @@ +{ + "type": "image", + "format": "nrrd", + "uris": [] +} diff --git a/backend-contract/fixtures/negative/unknown-field-kind.json b/backend-contract/fixtures/negative/unknown-field-kind.json new file mode 100644 index 000000000..fbdc21909 --- /dev/null +++ b/backend-contract/fixtures/negative/unknown-field-kind.json @@ -0,0 +1,24 @@ +{ + "specVersion": 1, + "id": "UnknownFieldKind", + "title": "Unknown Field Kind (must fail validation)", + "description": "A parameter with an unknown `kind` must fail whole-spec validation. The client fails closed on an unknown field kind (Chunk 5 negative fixture; Chunk 7 hides the param and refuses submit if it was required).", + "parameters": [ + { + "kind": "sourceRef", + "id": "inputVolume", + "title": "Input Volume", + "required": true, + "accepts": [ + "image" + ] + }, + { + "kind": "color", + "id": "tint", + "title": "Tint", + "default": "#ff0000" + } + ], + "outputs": [] +} diff --git a/backend-contract/fixtures/negative/wrong-length-color.json b/backend-contract/fixtures/negative/wrong-length-color.json new file mode 100644 index 000000000..f5aa4fee1 --- /dev/null +++ b/backend-contract/fixtures/negative/wrong-length-color.json @@ -0,0 +1,22 @@ +{ + "id": "6600000000000000000000e1", + "intent": "add-segment-group", + "url": "/api/v1/file/6600000000000000000000e1/proxiable/otsu.nii.gz", + "name": "otsu.nii.gz", + "segments": [ + { + "value": 1, + "name": "Bin 1", + "color": [ + 255, + 0, + 0 + ] + } + ], + "source": { + "providerId": "analysis-provider", + "jobId": "job-abc123", + "outputId": "outputLabelmap" + } +} diff --git a/backend-contract/fixtures/task-spec/synthetic-all-kinds.json b/backend-contract/fixtures/task-spec/synthetic-all-kinds.json new file mode 100644 index 000000000..97ffec3e0 --- /dev/null +++ b/backend-contract/fixtures/task-spec/synthetic-all-kinds.json @@ -0,0 +1,86 @@ +{ + "specVersion": 1, + "id": "SyntheticAllKinds", + "title": "Synthetic All Field Kinds", + "description": "Synthetic spec (not a real tool) exercising every scalar parameter kind plus an image input, a labelmap input, and both an image and a labelmap output.", + "parameters": [ + { + "kind": "sourceRef", + "id": "inputVolume", + "title": "Input Volume", + "help": "Background volume to process.", + "section": "IO", + "order": 0, + "required": true, + "accepts": [ + "image" + ] + }, + { + "kind": "sourceRef", + "id": "inputLabelmap", + "title": "Mask Labelmap", + "help": "Optional labelmap selecting where to process.", + "section": "IO", + "order": 1, + "accepts": [ + "labelmap" + ] + }, + { + "kind": "int", + "id": "radius", + "title": "Radius", + "help": "An integer with numeric constraints and a default.", + "section": "Parameters", + "order": 2, + "min": 1, + "max": 10, + "step": 1, + "default": 1 + }, + { + "kind": "float", + "id": "threshold", + "title": "Threshold", + "help": "A floating-point value with a default.", + "section": "Parameters", + "order": 3, + "default": 50 + }, + { + "kind": "bool", + "id": "smooth", + "title": "Smooth", + "help": "A boolean toggle.", + "section": "Parameters", + "order": 4, + "default": true + }, + { + "kind": "string", + "id": "label", + "title": "Label", + "help": "A free-text string.", + "section": "Parameters", + "order": 5, + "default": "result" + } + ], + "outputs": [ + { + "id": "outputVolume", + "title": "Output Volume", + "help": "Processed image output.", + "type": "image", + "format": ".nii.gz" + }, + { + "id": "outputLabelmap", + "title": "Output Labelmap", + "help": "Segmentation output.", + "type": "labelmap", + "format": ".nii.gz" + } + ] +} diff --git a/backend-contract/fixtures/task-spec/synthetic-bounds-enum.json b/backend-contract/fixtures/task-spec/synthetic-bounds-enum.json new file mode 100644 index 000000000..567e77299 --- /dev/null +++ b/backend-contract/fixtures/task-spec/synthetic-bounds-enum.json @@ -0,0 +1,65 @@ +{ + "specVersion": 1, + "id": "SyntheticRegionEnum", + "title": "Synthetic Region and Enum", + "description": "Synthetic CLI (not a real radiology tool) exercising a region-derived bounds field, an enum, and UI hints for the translator conformance test.", + "parameters": [ + { + "kind": "sourceRef", + "id": "inputVolume", + "title": "Input Volume", + "help": "Input volume (DICOM, NIfTI, NRRD, MHA, etc.)", + "section": "IO", + "order": 0, + "required": true, + "accepts": [ + "image" + ] + }, + { + "kind": "bounds", + "id": "roi", + "title": "Region of Interest", + "help": "World-space box (LPS) to restrict processing.", + "section": "Region and options", + "order": 1 + }, + { + "kind": "enum", + "id": "method", + "title": "Method", + "help": "Which algorithm variant to run.", + "section": "Region and options", + "order": 2, + "options": [ + "otsu", + "kmeans", + "manual" + ], + "default": "otsu" + }, + { + "kind": "enum", + "id": "iterations", + "title": "Iterations", + "help": "Number of refinement iterations.", + "section": "Region and options", + "order": 3, + "options": [ + 1, + 2, + 4 + ], + "default": 2 + } + ], + "outputs": [ + { + "id": "outputLabelmap", + "title": "Output Labelmap", + "help": "Segmentation output.", + "type": "labelmap", + "format": ".nii.gz" + } + ] +} diff --git a/backend-contract/fixtures/wire/handle-roundtrip.json b/backend-contract/fixtures/wire/handle-roundtrip.json new file mode 100644 index 000000000..331e48889 --- /dev/null +++ b/backend-contract/fixtures/wire/handle-roundtrip.json @@ -0,0 +1,50 @@ +{ + "$comment": "Handle mint/parse round-trip corpus (WORKORDER-4 Chunk 54; F4/F18). A backend whose opaque handles embed a backend file name MUST percent-encode the name segment at mint (RFC 3986; `escaped` is the exact expected segment — JS encodeURIComponent(name), matching Python urllib.parse.quote(name, safe=\"\") on this corpus) and unescape at parse, so parse(mint(fileId, name)) round-trips to the identical parts for every case here and the emitted handle carries no raw fragment/query delimiters. The client never parses a handle: it carries the minted string byte-for-byte (exemplarHandles are Girder-shaped samples the client-side suite pins as opaque).", + "cases": [ + { + "name": "brain.nrrd", + "escaped": "brain.nrrd" + }, + { + "name": "Lesion #1.seg.nrrd", + "escaped": "Lesion%20%231.seg.nrrd" + }, + { + "name": "flow ?phase.nrrd", + "escaped": "flow%20%3Fphase.nrrd" + }, + { + "name": "coverage 50%.seg.nrrd", + "escaped": "coverage%2050%25.seg.nrrd" + }, + { + "name": "left lung mask.seg.nrrd", + "escaped": "left%20lung%20mask.seg.nrrd" + }, + { + "name": "肿瘤分割.seg.nrrd", + "escaped": "%E8%82%BF%E7%98%A4%E5%88%86%E5%89%B2.seg.nrrd" + }, + { + "name": "café-lésion.seg.nrrd", + "escaped": "caf%C3%A9-l%C3%A9sion.seg.nrrd" + }, + { + "name": "#leading.nrrd", + "escaped": "%23leading.nrrd" + }, + { + "name": "trailing?.nrrd", + "escaped": "trailing%3F.nrrd" + }, + { + "name": "a#b?c%d e.nrrd", + "escaped": "a%23b%3Fc%25d%20e.nrrd" + } + ], + "exemplarHandles": [ + "/api/v1/file/6600000000000000000000a1/proxiable/Lesion%20%231.seg.nrrd", + "/api/v1/file/6600000000000000000000a2/proxiable/%E8%82%BF%E7%98%A4%E5%88%86%E5%89%B2.seg.nrrd", + "/api/v1/file/6600000000000000000000a3/proxiable/a%23b%3Fc%25d%20e.nrrd" + ] +} diff --git a/backend-contract/fixtures/wire/input-value.dicom-series.json b/backend-contract/fixtures/wire/input-value.dicom-series.json new file mode 100644 index 000000000..7bde06760 --- /dev/null +++ b/backend-contract/fixtures/wire/input-value.dicom-series.json @@ -0,0 +1,9 @@ +{ + "type": "image", + "format": "dicom-series", + "uris": [ + "/api/v1/file/6600000000000000000000a1/proxiable/1-001.dcm", + "/api/v1/file/6600000000000000000000a2/proxiable/1-002.dcm", + "/api/v1/file/6600000000000000000000a3/proxiable/1-003.dcm" + ] +} diff --git a/backend-contract/fixtures/wire/input-value.labelmap.json b/backend-contract/fixtures/wire/input-value.labelmap.json new file mode 100644 index 000000000..8656f9403 --- /dev/null +++ b/backend-contract/fixtures/wire/input-value.labelmap.json @@ -0,0 +1,7 @@ +{ + "type": "labelmap", + "format": "nrrd", + "uris": [ + "/api/v1/file/6600000000000000000000c1/proxiable/segmentation.seg.nrrd" + ] +} diff --git a/backend-contract/fixtures/wire/input-value.single-file.json b/backend-contract/fixtures/wire/input-value.single-file.json new file mode 100644 index 000000000..3dfe5df8a --- /dev/null +++ b/backend-contract/fixtures/wire/input-value.single-file.json @@ -0,0 +1,7 @@ +{ + "type": "image", + "format": "nrrd", + "uris": [ + "/api/v1/file/6600000000000000000000b1/proxiable/scan.nrrd" + ] +} diff --git a/backend-contract/fixtures/wire/intent.add-base-image.json b/backend-contract/fixtures/wire/intent.add-base-image.json new file mode 100644 index 000000000..f7b5e8d86 --- /dev/null +++ b/backend-contract/fixtures/wire/intent.add-base-image.json @@ -0,0 +1,6 @@ +{ + "id": "6600000000000000000000d1", + "intent": "add-base-image", + "url": "/api/v1/file/6600000000000000000000d1/proxiable/filtered.nii.gz", + "name": "filtered.nii.gz" +} diff --git a/backend-contract/fixtures/wire/intent.add-layer.json b/backend-contract/fixtures/wire/intent.add-layer.json new file mode 100644 index 000000000..bd63f15e3 --- /dev/null +++ b/backend-contract/fixtures/wire/intent.add-layer.json @@ -0,0 +1,6 @@ +{ + "id": "6600000000000000000000d2", + "intent": "add-layer", + "url": "/api/v1/file/6600000000000000000000d2/proxiable/overlay.nii.gz", + "name": "overlay.nii.gz" +} diff --git a/backend-contract/fixtures/wire/intent.add-segment-group.embedded.json b/backend-contract/fixtures/wire/intent.add-segment-group.embedded.json new file mode 100644 index 000000000..5c6f74ba9 --- /dev/null +++ b/backend-contract/fixtures/wire/intent.add-segment-group.embedded.json @@ -0,0 +1,11 @@ +{ + "id": "6600000000000000000000e2", + "intent": "add-segment-group", + "url": "/api/v1/file/6600000000000000000000e2/proxiable/threshold.seg.nrrd", + "name": "threshold.seg.nrrd", + "source": { + "providerId": "analysis-provider", + "jobId": "job-def456", + "outputId": "outputLabelmap" + } +} diff --git a/backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json b/backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json new file mode 100644 index 000000000..fa0f30949 --- /dev/null +++ b/backend-contract/fixtures/wire/intent.add-segment-group.with-segments.json @@ -0,0 +1,44 @@ +{ + "id": "6600000000000000000000e1", + "intent": "add-segment-group", + "url": "/api/v1/file/6600000000000000000000e1/proxiable/otsu.nii.gz", + "name": "otsu.nii.gz", + "segments": [ + { + "value": 1, + "name": "Bin 1", + "color": [ + 255, + 0, + 0, + 255 + ] + }, + { + "value": 2, + "name": "Bin 2", + "color": [ + 0, + 255, + 0, + 255 + ], + "visible": true + }, + { + "value": 3, + "name": "Bin 3", + "color": [ + 0, + 0, + 255, + 255 + ] + } + ], + "source": { + "providerId": "analysis-provider", + "jobId": "job-abc123", + "outputId": "outputLabelmap" + } +} diff --git a/backend-contract/fixtures/wire/intent.unknown.json b/backend-contract/fixtures/wire/intent.unknown.json new file mode 100644 index 000000000..0c769082f --- /dev/null +++ b/backend-contract/fixtures/wire/intent.unknown.json @@ -0,0 +1,6 @@ +{ + "id": "6600000000000000000000e3", + "intent": "add-polygon", + "url": "/api/v1/file/6600000000000000000000e3/proxiable/contour.json", + "name": "contour.json" +} diff --git a/backend-contract/fixtures/wire/job-history-detail.json b/backend-contract/fixtures/wire/job-history-detail.json new file mode 100644 index 000000000..709313aaf --- /dev/null +++ b/backend-contract/fixtures/wire/job-history-detail.json @@ -0,0 +1,5 @@ +{ + "jobId": "job-abc123", + "log": ["completed\n"], + "parameters": { "threshold": 42 } +} diff --git a/backend-contract/fixtures/wire/job-history-page.json b/backend-contract/fixtures/wire/job-history-page.json new file mode 100644 index 000000000..0c2e402da --- /dev/null +++ b/backend-contract/fixtures/wire/job-history-page.json @@ -0,0 +1,14 @@ +{ + "jobs": [ + { + "jobId": "job-abc123", + "taskId": "OtsuSegmentation", + "taskTitle": "Otsu segmentation", + "createdBy": { "id": "user-1", "name": "Ada Lovelace" }, + "createdAt": "2026-07-03T18:24:00Z", + "state": "success", + "resultState": "ready" + } + ], + "nextCursor": "opaque-continuation" +} diff --git a/backend-contract/fixtures/wire/job-history-summary.json b/backend-contract/fixtures/wire/job-history-summary.json new file mode 100644 index 000000000..c233fadb7 --- /dev/null +++ b/backend-contract/fixtures/wire/job-history-summary.json @@ -0,0 +1,16 @@ +{ + "jobId": "job-abc123", + "taskId": "OtsuSegmentation", + "taskTitle": "Otsu segmentation", + "createdBy": { "id": "user-1", "name": "Ada Lovelace" }, + "createdAt": "2026-07-03T18:24:00Z", + "startedAt": "2026-07-03T18:24:01Z", + "finishedAt": "2026-07-03T18:24:05Z", + "state": "success", + "resultState": "ready", + "progress": 1, + "outputSummary": { + "recorded": 1, + "missing": 0 + } +} diff --git a/backend-contract/fixtures/wire/job-results.error.json b/backend-contract/fixtures/wire/job-results.error.json new file mode 100644 index 000000000..44c64af12 --- /dev/null +++ b/backend-contract/fixtures/wire/job-results.error.json @@ -0,0 +1,6 @@ +{ + "code": "results_unavailable", + "message": "Job did not succeed; results are unavailable.", + "state": "error", + "resultState": "unavailable" +} diff --git a/backend-contract/fixtures/wire/job-results.missing.json b/backend-contract/fixtures/wire/job-results.missing.json new file mode 100644 index 000000000..51bcc29fb --- /dev/null +++ b/backend-contract/fixtures/wire/job-results.missing.json @@ -0,0 +1,12 @@ +{ + "resultState": "incomplete", + "intents": [ + { + "id": "6600000000000000000000d1", + "intent": "add-base-image", + "url": "/api/v1/file/6600000000000000000000d1/proxiable/filtered.nii.gz", + "name": "filtered.nii.gz" + } + ], + "missing": 2 +} diff --git a/backend-contract/fixtures/wire/stage-input.labelmap.json b/backend-contract/fixtures/wire/stage-input.labelmap.json new file mode 100644 index 000000000..5b32a6580 --- /dev/null +++ b/backend-contract/fixtures/wire/stage-input.labelmap.json @@ -0,0 +1,12 @@ +{ + "type": "labelmap", + "name": "tumor.seg.nrrd", + "referenceImage": { + "type": "image", + "format": "dicom-series", + "uris": [ + "/api/v1/file/aaaaaaaaaaaaaaaaaaaaaaaa/proxiable/001.dcm", + "/api/v1/file/bbbbbbbbbbbbbbbbbbbbbbbb/proxiable/002.dcm" + ] + } +} diff --git a/backend-contract/fixtures/wire/status.cancelled.json b/backend-contract/fixtures/wire/status.cancelled.json new file mode 100644 index 000000000..0973e1c01 --- /dev/null +++ b/backend-contract/fixtures/wire/status.cancelled.json @@ -0,0 +1,5 @@ +{ + "jobId": "job-abc123", + "state": "cancelled", + "resultState": "unavailable" +} diff --git a/backend-contract/fixtures/wire/status.error-tail.json b/backend-contract/fixtures/wire/status.error-tail.json new file mode 100644 index 000000000..cc33eefdb --- /dev/null +++ b/backend-contract/fixtures/wire/status.error-tail.json @@ -0,0 +1,6 @@ +{ + "jobId": "job-abc123", + "state": "error", + "resultState": "unavailable", + "errorTail": "Traceback (most recent call last):\n File \"/opt/cli/MedianFilter.py\", line 41, in \n main()\n File \"/opt/cli/MedianFilter.py\", line 33, in main\n out = median(image, radius)\nRuntimeError: itk::ERROR: input image is empty" +} diff --git a/backend-contract/fixtures/wire/status.error.json b/backend-contract/fixtures/wire/status.error.json new file mode 100644 index 000000000..1f3cb2efa --- /dev/null +++ b/backend-contract/fixtures/wire/status.error.json @@ -0,0 +1,5 @@ +{ + "jobId": "job-abc123", + "state": "error", + "resultState": "unavailable" +} diff --git a/backend-contract/fixtures/wire/status.pending.json b/backend-contract/fixtures/wire/status.pending.json new file mode 100644 index 000000000..6906a6fee --- /dev/null +++ b/backend-contract/fixtures/wire/status.pending.json @@ -0,0 +1,5 @@ +{ + "jobId": "job-abc123", + "state": "pending", + "resultState": "waiting" +} diff --git a/backend-contract/fixtures/wire/status.running.json b/backend-contract/fixtures/wire/status.running.json new file mode 100644 index 000000000..faa185cb8 --- /dev/null +++ b/backend-contract/fixtures/wire/status.running.json @@ -0,0 +1,6 @@ +{ + "jobId": "job-abc123", + "state": "running", + "resultState": "waiting", + "progress": 0.42 +} diff --git a/backend-contract/fixtures/wire/status.success.json b/backend-contract/fixtures/wire/status.success.json new file mode 100644 index 000000000..89a73c8e9 --- /dev/null +++ b/backend-contract/fixtures/wire/status.success.json @@ -0,0 +1,6 @@ +{ + "jobId": "job-abc123", + "state": "success", + "resultState": "ready", + "progress": 1 +} diff --git a/backend-contract/generated/input-value.schema.json b/backend-contract/generated/input-value.schema.json new file mode 100644 index 000000000..f394b8bb7 --- /dev/null +++ b/backend-contract/generated/input-value.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "format": { + "type": "string" + }, + "uris": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "uris" + ], + "additionalProperties": false +} diff --git a/backend-contract/generated/job-history-detail.schema.json b/backend-contract/generated/job-history-detail.schema.json new file mode 100644 index 000000000..44c80c1c1 --- /dev/null +++ b/backend-contract/generated/job-history-detail.schema.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "log": { + "type": "array", + "items": { + "type": "string" + } + }, + "parameters": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "jobId", + "log", + "parameters" + ], + "additionalProperties": false +} diff --git a/backend-contract/generated/job-history-page.schema.json b/backend-contract/generated/job-history-page.schema.json new file mode 100644 index 000000000..d0e5be67d --- /dev/null +++ b/backend-contract/generated/job-history-page.schema.json @@ -0,0 +1,121 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "jobs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "taskId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "taskTitle": { + "type": "string" + }, + "createdBy": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "createdAt": { + "type": "string" + }, + "startedAt": { + "type": "string" + }, + "finishedAt": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "pending", + "running", + "success", + "error", + "cancelled" + ] + }, + "resultState": { + "type": "string", + "enum": [ + "waiting", + "ready", + "incomplete", + "unavailable" + ] + }, + "progress": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "outputSummary": { + "type": "object", + "properties": { + "recorded": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "missing": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "recorded", + "missing" + ], + "additionalProperties": false + } + }, + "required": [ + "jobId", + "taskId", + "taskTitle", + "createdBy", + "createdAt", + "state", + "resultState" + ], + "additionalProperties": false + } + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "jobs", + "nextCursor" + ], + "additionalProperties": false +} diff --git a/backend-contract/generated/job-history-summary.schema.json b/backend-contract/generated/job-history-summary.schema.json new file mode 100644 index 000000000..b3ea592ca --- /dev/null +++ b/backend-contract/generated/job-history-summary.schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "taskId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "taskTitle": { + "type": "string" + }, + "createdBy": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "createdAt": { + "type": "string" + }, + "startedAt": { + "type": "string" + }, + "finishedAt": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "pending", + "running", + "success", + "error", + "cancelled" + ] + }, + "resultState": { + "type": "string", + "enum": [ + "waiting", + "ready", + "incomplete", + "unavailable" + ] + }, + "progress": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "outputSummary": { + "type": "object", + "properties": { + "recorded": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "missing": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "recorded", + "missing" + ], + "additionalProperties": false + } + }, + "required": [ + "jobId", + "taskId", + "taskTitle", + "createdBy", + "createdAt", + "state", + "resultState" + ], + "additionalProperties": false +} diff --git a/backend-contract/generated/job-results-error.schema.json b/backend-contract/generated/job-results-error.schema.json new file mode 100644 index 000000000..e46d93383 --- /dev/null +++ b/backend-contract/generated/job-results-error.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "results_not_ready" + }, + "message": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "pending", + "running" + ] + }, + "resultState": { + "type": "string", + "const": "waiting" + } + }, + "required": [ + "code", + "message", + "state", + "resultState" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "results_unavailable" + }, + "message": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "error", + "cancelled" + ] + }, + "resultState": { + "type": "string", + "const": "unavailable" + } + }, + "required": [ + "code", + "message", + "state", + "resultState" + ], + "additionalProperties": false + } + ] +} diff --git a/backend-contract/generated/job-results.schema.json b/backend-contract/generated/job-results.schema.json new file mode 100644 index 000000000..ce9a41e5c --- /dev/null +++ b/backend-contract/generated/job-results.schema.json @@ -0,0 +1,291 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "resultState": { + "type": "string", + "enum": [ + "ready", + "incomplete" + ] + }, + "intents": { + "type": "array", + "items": { + "anyOf": [ + { + "oneOf": [ + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "const": "add-base-image" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "size": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "intent", + "id", + "name", + "url" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "const": "add-layer" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "size": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "intent", + "id", + "name", + "url" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "const": "add-segment-group" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "size": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "segments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "color": { + "minItems": 4, + "maxItems": 4, + "type": "array", + "prefixItems": [ + { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + ] + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "value", + "name", + "color" + ], + "additionalProperties": false + } + }, + "source": { + "type": "object", + "properties": { + "providerId": { + "type": "string" + }, + "jobId": { + "type": "string" + }, + "outputId": { + "type": "string" + } + }, + "required": [ + "providerId", + "jobId", + "outputId" + ], + "additionalProperties": false + } + }, + "required": [ + "intent", + "id", + "name", + "url" + ], + "additionalProperties": {} + } + ] + }, + { + "type": "object", + "properties": { + "intent": {}, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "size": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "url" + ], + "additionalProperties": {} + } + ] + } + }, + "missing": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "resultState", + "intents", + "missing" + ], + "additionalProperties": false +} diff --git a/backend-contract/generated/neutral-job-status.schema.json b/backend-contract/generated/neutral-job-status.schema.json new file mode 100644 index 000000000..1d0a59b26 --- /dev/null +++ b/backend-contract/generated/neutral-job-status.schema.json @@ -0,0 +1,158 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "oneOf": [ + { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "progress": { + "type": "number" + }, + "errorTail": { + "type": "string" + }, + "state": { + "type": "string", + "const": "pending" + }, + "resultState": { + "type": "string", + "const": "waiting" + } + }, + "required": [ + "jobId", + "state", + "resultState" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "progress": { + "type": "number" + }, + "errorTail": { + "type": "string" + }, + "state": { + "type": "string", + "const": "running" + }, + "resultState": { + "type": "string", + "const": "waiting" + } + }, + "required": [ + "jobId", + "state", + "resultState" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "progress": { + "type": "number" + }, + "errorTail": { + "type": "string" + }, + "state": { + "type": "string", + "const": "success" + }, + "resultState": { + "type": "string", + "enum": [ + "ready", + "incomplete" + ] + } + }, + "required": [ + "jobId", + "state", + "resultState" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "progress": { + "type": "number" + }, + "errorTail": { + "type": "string" + }, + "state": { + "type": "string", + "const": "error" + }, + "resultState": { + "type": "string", + "const": "unavailable" + } + }, + "required": [ + "jobId", + "state", + "resultState" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "progress": { + "type": "number" + }, + "errorTail": { + "type": "string" + }, + "state": { + "type": "string", + "const": "cancelled" + }, + "resultState": { + "type": "string", + "const": "unavailable" + } + }, + "required": [ + "jobId", + "state", + "resultState" + ], + "additionalProperties": false + } + ] +} diff --git a/backend-contract/generated/openapi.json b/backend-contract/generated/openapi.json new file mode 100644 index 000000000..ba37240c7 --- /dev/null +++ b/backend-contract/generated/openapi.json @@ -0,0 +1,1661 @@ +{ + "openapi": "3.1.0", + "jsonSchemaDialect": "https://json-schema.org/draft/2020-12/schema", + "info": { + "title": "VolView neutral backend contract", + "version": "0.1.0", + "description": "DRAFT 0.x — shapes may change until a second backend passes the conformance kit (the pinned 1.0 criterion). The neutral REST surface the VolView client calls to run processing tasks against a backend. A conforming server-side BACKEND implements these endpoints and the referenced wire schemas — no VolView client change is needed to bring a new backend online. Everything here is neutral: no backend routes, ids, status enums, or URL shapes leak. The artifact version is the draft artifact version, distinct from the shape versions: the result-intent vocabulary is at version 1 (INTENT_VOCABULARY_VERSION); the task-spec shape at version 1 (specVersion)." + }, + "servers": [ + { + "url": "{baseUrl}", + "description": "The provider processing base. Context-scoped endpoints are relative to a processing context; job-addressed endpoints are keyed by job id alone. The mapping of both onto concrete URLs is the backend choice.", + "variables": { + "baseUrl": { + "default": "/" + } + } + } + ], + "tags": [ + { + "name": "context", + "description": "Context-scoped: operate against a processing context (the collection a task runs in)." + }, + { + "name": "job", + "description": "Job-addressed: keyed by the opaque job id alone and gated by the job's own access control — no context in the path." + } + ], + "paths": { + "/tasks": { + "get": { + "operationId": "listTasks", + "tags": [ + "context" + ], + "summary": "List the processing tasks available in this context.", + "responses": { + "200": { + "description": "The available tasks as advisory summaries.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/TaskSummary" + } + } + } + } + } + } + } + }, + "/tasks/{taskId}/spec": { + "get": { + "operationId": "getTaskSpec", + "tags": [ + "context" + ], + "summary": "Get a task's VolView task spec (the backend translates its own native task format into this neutral spec).", + "parameters": [ + { + "name": "taskId", + "in": "path", + "required": true, + "description": "Opaque task identifier.", + "schema": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + } + } + ], + "responses": { + "200": { + "description": "The task's neutral task spec.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskSpec" + } + } + } + }, + "404": { + "description": "No such task in this context." + } + } + } + }, + "/tasks/{taskId}/run": { + "post": { + "operationId": "runTask", + "tags": [ + "context" + ], + "summary": "Submit a task; returns an opaque job handle to poll.", + "parameters": [ + { + "name": "taskId", + "in": "path", + "required": true, + "description": "Opaque task identifier.", + "schema": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunTaskRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The submitted job handle.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobRef" + } + } + } + }, + "404": { + "description": "No such task in this context." + } + } + } + }, + "/jobs": { + "get": { + "operationId": "listJobHistory", + "tags": [ + "context" + ], + "summary": "The current user's complete job history, newest first.", + "parameters": [ + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "description": "Opaque continuation cursor returned by the prior page.", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "One bounded lightweight page; logs and parameters are absent.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobHistoryPage" + } + } + } + } + } + } + }, + "/jobs/{jobId}/detail": { + "get": { + "operationId": "getJobHistoryDetail", + "tags": [ + "job" + ], + "summary": "Read logs and submitted parameters for one authorized job on demand.", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "description": "Opaque job identifier.", + "schema": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + } + } + ], + "responses": { + "200": { + "description": "The detail-only job fields.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobHistoryDetail" + } + } + } + } + } + } + }, + "/stage": { + "post": { + "operationId": "stageInput", + "tags": [ + "context" + ], + "summary": "Stage a parent-bound labelmap as a transient input; returns backend-minted URIs the client round-trips as an InputValue at submit.", + "requestBody": { + "required": true, + "description": "A typed staged resource: the labelmap bytes plus its durable reference-image relationship.", + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "file": { + "type": "string", + "format": "binary" + }, + "descriptor": { + "$ref": "#/components/schemas/StageInputDescriptor" + } + }, + "required": [ + "file", + "descriptor" + ], + "additionalProperties": false + }, + "encoding": { + "descriptor": { + "contentType": "text/plain", + "description": "JSON serialization of StageInputDescriptor." + } + } + } + } + }, + "responses": { + "200": { + "description": "The backend-minted URIs for the staged bytes.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StageResponse" + } + } + } + } + } + } + }, + "/jobs/{jobId}": { + "get": { + "operationId": "getJob", + "tags": [ + "job" + ], + "summary": "A job's neutral execution and result-readiness status. Poll until the job reaches a terminal state. Job-addressed: keyed by the opaque job id alone and gated by the job's own access control — no context in the path.", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "description": "Opaque job identifier.", + "schema": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + } + } + ], + "responses": { + "200": { + "description": "The neutral job status.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NeutralJobStatus" + } + } + } + } + } + }, + "delete": { + "operationId": "deleteJob", + "tags": [ + "job" + ], + "summary": "Delete one authorized terminal execution record AND everything it owns (cascading).", + "description": "Deletion is CASCADING, and the cascade is normative: removing the execution record also removes the results it produced and any staged inputs it still owns. A conforming backend MUST NOT retain results for a deleted record, so a client may truthfully tell the user that deleting the job deletes its results. Only a terminal (success / error / cancelled) job is deletable; a non-terminal job is refused with 409 — cancel it and delete once it settles.", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "description": "Opaque job identifier.", + "schema": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + } + } + ], + "responses": { + "204": { + "description": "The job and everything it owned (results, staged inputs) were deleted." + }, + "409": { + "description": "The job is not terminal; cancel it and re-delete once it settles." + } + } + } + }, + "/jobs/{jobId}/results": { + "get": { + "operationId": "getJobResults", + "tags": [ + "job" + ], + "summary": "A job's settled results as the { resultState, intents, missing } envelope. Partial and total output loss return an incomplete envelope.", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "description": "Opaque job identifier.", + "schema": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + } + } + ], + "responses": { + "200": { + "description": "The resolved results as the { resultState, intents, missing } envelope (JobResults). Each entry of `intents` is a ResultIntent — a result row carrying a required `id` display key and required `name`/`url`, plus optional/null `mimeType`/`size` file metadata. `missing` counts declared outputs that never arrived plus recorded outputs that cannot be read. Total loss is a valid incomplete response with an empty intents array.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobResults" + } + } + } + }, + "409": { + "description": "Results are still waiting, or execution made them unavailable. The typed body distinguishes retryable readiness from permanent unavailability.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/JobResultsError" + } + } + } + } + } + } + }, + "/jobs/{jobId}/cancel": { + "post": { + "operationId": "cancelJob", + "tags": [ + "job" + ], + "summary": "Best-effort cancel. Returns the job's real projected status after the attempt — never a fabricated `cancelled`; the client poller converges on whatever terminal state the backend ultimately reports. Job-addressed.", + "parameters": [ + { + "name": "jobId", + "in": "path", + "required": true, + "description": "Opaque job identifier.", + "schema": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + } + } + ], + "responses": { + "200": { + "description": "The projected job status after the cancel attempt.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NeutralJobStatus" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "TaskSpec": { + "$comment": "Structural validation only. Implement backend-contract validateTaskSpecSemantics after this schema and reject every fixtures/negative payload.", + "type": "object", + "properties": { + "specVersion": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "id": { + "type": "string", + "minLength": 1, + "allOf": [ + { + "pattern": "^(?!\\.{1,2}$)" + }, + { + "pattern": "\\S" + } + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "int" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "min": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "default": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "kind", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "float" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "step": { + "type": "number" + }, + "default": { + "type": "number" + } + }, + "required": [ + "kind", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "string" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "default": { + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "bool" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "default": { + "type": "boolean" + } + }, + "required": [ + "kind", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "enum" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "options": { + "minItems": 1, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "default": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "required": [ + "kind", + "id", + "options" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "sourceRef" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "accepts": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "kind", + "id", + "accepts" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "bounds" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "default": { + "minItems": 6, + "maxItems": 6, + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ] + } + }, + "required": [ + "kind", + "id" + ], + "additionalProperties": false + } + ] + } + }, + "outputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "type": { + "type": "string" + }, + "format": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + } + }, + "required": [ + "specVersion", + "id", + "title", + "parameters", + "outputs" + ], + "additionalProperties": false + }, + "InputValue": { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "format": { + "type": "string" + }, + "uris": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "uris" + ], + "additionalProperties": false + }, + "StageInputDescriptor": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "labelmap" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "referenceImage": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + }, + "format": { + "type": "string" + }, + "uris": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "uris" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "name", + "referenceImage" + ], + "additionalProperties": false + }, + "NeutralJobStatus": { + "oneOf": [ + { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "progress": { + "type": "number" + }, + "errorTail": { + "type": "string" + }, + "state": { + "type": "string", + "const": "pending" + }, + "resultState": { + "type": "string", + "const": "waiting" + } + }, + "required": [ + "jobId", + "state", + "resultState" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "progress": { + "type": "number" + }, + "errorTail": { + "type": "string" + }, + "state": { + "type": "string", + "const": "running" + }, + "resultState": { + "type": "string", + "const": "waiting" + } + }, + "required": [ + "jobId", + "state", + "resultState" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "progress": { + "type": "number" + }, + "errorTail": { + "type": "string" + }, + "state": { + "type": "string", + "const": "success" + }, + "resultState": { + "type": "string", + "enum": [ + "ready", + "incomplete" + ] + } + }, + "required": [ + "jobId", + "state", + "resultState" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "progress": { + "type": "number" + }, + "errorTail": { + "type": "string" + }, + "state": { + "type": "string", + "const": "error" + }, + "resultState": { + "type": "string", + "const": "unavailable" + } + }, + "required": [ + "jobId", + "state", + "resultState" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "progress": { + "type": "number" + }, + "errorTail": { + "type": "string" + }, + "state": { + "type": "string", + "const": "cancelled" + }, + "resultState": { + "type": "string", + "const": "unavailable" + } + }, + "required": [ + "jobId", + "state", + "resultState" + ], + "additionalProperties": false + } + ] + }, + "ResultIntent": { + "anyOf": [ + { + "oneOf": [ + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "const": "add-base-image" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "size": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "intent", + "id", + "name", + "url" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "const": "add-layer" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "size": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "intent", + "id", + "name", + "url" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "const": "add-segment-group" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "size": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "segments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "color": { + "minItems": 4, + "maxItems": 4, + "type": "array", + "prefixItems": [ + { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + ] + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "value", + "name", + "color" + ], + "additionalProperties": false + } + }, + "source": { + "type": "object", + "properties": { + "providerId": { + "type": "string" + }, + "jobId": { + "type": "string" + }, + "outputId": { + "type": "string" + } + }, + "required": [ + "providerId", + "jobId", + "outputId" + ], + "additionalProperties": false + } + }, + "required": [ + "intent", + "id", + "name", + "url" + ], + "additionalProperties": {} + } + ] + }, + { + "type": "object", + "properties": { + "intent": {}, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "size": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "url" + ], + "additionalProperties": {} + } + ] + }, + "JobHistorySummary": { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "taskId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "taskTitle": { + "type": "string" + }, + "createdBy": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "additionalProperties": false + }, + "createdAt": { + "type": "string" + }, + "startedAt": { + "type": "string" + }, + "finishedAt": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "pending", + "running", + "success", + "error", + "cancelled" + ] + }, + "resultState": { + "type": "string", + "enum": [ + "waiting", + "ready", + "incomplete", + "unavailable" + ] + }, + "progress": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "outputSummary": { + "type": "object", + "properties": { + "recorded": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "missing": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "recorded", + "missing" + ], + "additionalProperties": false + } + }, + "required": [ + "jobId", + "taskId", + "taskTitle", + "createdBy", + "createdAt", + "state", + "resultState" + ], + "additionalProperties": false + }, + "JobHistoryPage": { + "type": "object", + "properties": { + "jobs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JobHistorySummary" + } + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "jobs", + "nextCursor" + ], + "additionalProperties": false + }, + "JobHistoryDetail": { + "type": "object", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "log": { + "type": "array", + "items": { + "type": "string" + } + }, + "parameters": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "jobId", + "log", + "parameters" + ], + "additionalProperties": false + }, + "JobResults": { + "type": "object", + "properties": { + "resultState": { + "type": "string", + "enum": [ + "ready", + "incomplete" + ] + }, + "intents": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ResultIntent" + } + }, + "missing": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "resultState", + "intents", + "missing" + ], + "additionalProperties": false + }, + "JobResultsError": { + "oneOf": [ + { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "results_not_ready" + }, + "message": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "pending", + "running" + ] + }, + "resultState": { + "type": "string", + "const": "waiting" + } + }, + "required": [ + "code", + "message", + "state", + "resultState" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "code": { + "type": "string", + "const": "results_unavailable" + }, + "message": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "error", + "cancelled" + ] + }, + "resultState": { + "type": "string", + "const": "unavailable" + } + }, + "required": [ + "code", + "message", + "state", + "resultState" + ], + "additionalProperties": false + } + ] + }, + "TaskSummary": { + "type": "object", + "description": "Advisory display metadata for one task in the picker. Pass-through: the client renders it but validates only id/title; every other field is OPTIONAL advisory display metadata a backend MAY omit, and the client never dispatches on it.", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "category": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "id", + "title" + ], + "additionalProperties": true + }, + "RunTaskRequest": { + "type": "object", + "description": "Submission payload. `values` maps each task parameter id to its bound value: an InputValue for an input binding, or a scalar/list for a plain parameter.", + "properties": { + "values": { + "type": "object", + "additionalProperties": { + "oneOf": [ + { + "$ref": "#/components/schemas/InputValue" + }, + { + "type": "string" + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "array" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "values" + ], + "additionalProperties": false + }, + "JobRef": { + "type": "object", + "description": "Handle to the submitted job. `jobId` is opaque. An optional terminal `status` is the born-terminal fast-path for a synchronous backend.", + "properties": { + "jobId": { + "type": "string", + "minLength": 1, + "pattern": "^(?!\\.{1,2}$)" + }, + "status": { + "$ref": "#/components/schemas/NeutralJobStatus" + } + }, + "required": [ + "jobId" + ], + "additionalProperties": false + }, + "StageResponse": { + "type": "object", + "description": "Backend-minted opaque URIs for the staged bytes. The client mints no URI itself, so at least one is required (fail closed).", + "properties": { + "uris": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1 + } + }, + "required": [ + "uris" + ], + "additionalProperties": false + } + } + } +} diff --git a/backend-contract/generated/result-intent.schema.json b/backend-contract/generated/result-intent.schema.json new file mode 100644 index 000000000..9eeba14a4 --- /dev/null +++ b/backend-contract/generated/result-intent.schema.json @@ -0,0 +1,265 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "anyOf": [ + { + "oneOf": [ + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "const": "add-base-image" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "size": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "intent", + "id", + "name", + "url" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "const": "add-layer" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "size": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "intent", + "id", + "name", + "url" + ], + "additionalProperties": {} + }, + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "const": "add-segment-group" + }, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "size": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "segments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "value": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "color": { + "minItems": 4, + "maxItems": 4, + "type": "array", + "prefixItems": [ + { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 255 + } + ] + }, + "visible": { + "type": "boolean" + } + }, + "required": [ + "value", + "name", + "color" + ], + "additionalProperties": false + } + }, + "source": { + "type": "object", + "properties": { + "providerId": { + "type": "string" + }, + "jobId": { + "type": "string" + }, + "outputId": { + "type": "string" + } + }, + "required": [ + "providerId", + "jobId", + "outputId" + ], + "additionalProperties": false + } + }, + "required": [ + "intent", + "id", + "name", + "url" + ], + "additionalProperties": {} + } + ] + }, + { + "type": "object", + "properties": { + "intent": {}, + "id": { + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string" + }, + "url": { + "type": "string" + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "size": { + "anyOf": [ + { + "type": "number", + "minimum": 0 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "url" + ], + "additionalProperties": {} + } + ] +} diff --git a/backend-contract/generated/stage-input-descriptor.schema.json b/backend-contract/generated/stage-input-descriptor.schema.json new file mode 100644 index 000000000..59a751a27 --- /dev/null +++ b/backend-contract/generated/stage-input-descriptor.schema.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "labelmap" + }, + "name": { + "type": "string", + "minLength": 1 + }, + "referenceImage": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "image" + }, + "format": { + "type": "string" + }, + "uris": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "type", + "uris" + ], + "additionalProperties": false + } + }, + "required": [ + "type", + "name", + "referenceImage" + ], + "additionalProperties": false +} diff --git a/backend-contract/generated/task-spec.schema.json b/backend-contract/generated/task-spec.schema.json new file mode 100644 index 000000000..2ea9d51ed --- /dev/null +++ b/backend-contract/generated/task-spec.schema.json @@ -0,0 +1,421 @@ +{ + "$comment": "Structural validation only. Implement backend-contract validateTaskSpecSemantics after this schema and reject every fixtures/negative payload.", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "specVersion": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "id": { + "type": "string", + "minLength": 1, + "allOf": [ + { + "pattern": "^(?!\\.{1,2}$)" + }, + { + "pattern": "\\S" + } + ] + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "parameters": { + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "int" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "min": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "max": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "step": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "default": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "kind", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "float" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "min": { + "type": "number" + }, + "max": { + "type": "number" + }, + "step": { + "type": "number" + }, + "default": { + "type": "number" + } + }, + "required": [ + "kind", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "string" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "default": { + "type": "string" + } + }, + "required": [ + "kind", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "bool" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "default": { + "type": "boolean" + } + }, + "required": [ + "kind", + "id" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "enum" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "options": { + "minItems": 1, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "default": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + } + }, + "required": [ + "kind", + "id", + "options" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "sourceRef" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "accepts": { + "minItems": 1, + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "kind", + "id", + "accepts" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "const": "bounds" + }, + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "section": { + "type": "string" + }, + "order": { + "type": "number" + }, + "widget": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "default": { + "minItems": 6, + "maxItems": 6, + "type": "array", + "prefixItems": [ + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + }, + { + "type": "number" + } + ] + } + }, + "required": [ + "kind", + "id" + ], + "additionalProperties": false + } + ] + } + }, + "outputs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "pattern": "\\S" + }, + "title": { + "type": "string" + }, + "help": { + "type": "string" + }, + "type": { + "type": "string" + }, + "format": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": false + } + } + }, + "required": [ + "specVersion", + "id", + "title", + "parameters", + "outputs" + ], + "additionalProperties": false +} diff --git a/backend-contract/index.ts b/backend-contract/index.ts new file mode 100644 index 000000000..98d5089a8 --- /dev/null +++ b/backend-contract/index.ts @@ -0,0 +1,12 @@ +// --------------------------------------------------------------------------- +// backend-contract — the neutral VolView backend contract, published as an +// artifact. +// +// The zod sources in this package are the single normative definition of the +// wire shapes. The golden JSON fixtures under `fixtures/` are the interchange +// format BOTH suites pin: the VolView client validates them with the zod +// schemas here; the girder_volview backend validates them against JSON Schemas +// generated from these same zod sources. One definition, two validators. +// --------------------------------------------------------------------------- + +export * from './processing'; diff --git a/backend-contract/package.json b/backend-contract/package.json new file mode 100644 index 000000000..0b8f5e62c --- /dev/null +++ b/backend-contract/package.json @@ -0,0 +1,5 @@ +{ + "name": "@volview/backend-contract", + "version": "0.1.0", + "private": true +} diff --git a/backend-contract/processing/__tests__/generated-schema.spec.ts b/backend-contract/processing/__tests__/generated-schema.spec.ts new file mode 100644 index 000000000..0c5f9b824 --- /dev/null +++ b/backend-contract/processing/__tests__/generated-schema.spec.ts @@ -0,0 +1,43 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; + +import { generateJsonSchemas, GENERATED_SCHEMA_NAMES } from '../schema-json'; +import { validateTaskSpecSemantics } from '../task-spec'; +import { loadFixture } from './loadFixtures'; + +// The single-source guard: the checked-in JSON Schemas the backend consumes +// must equal what the zod source generates right now. If a schema changes and +// the generated artifact is not regenerated +// (`npx tsx backend-contract/scripts/generate-json-schema.ts`), this fails. + +const here = dirname(fileURLToPath(import.meta.url)); +const generatedDir = resolve(here, '..', '..', 'generated'); + +describe('generated JSON Schemas are in sync with the zod source', () => { + const fresh = generateJsonSchemas(); + + it.each(GENERATED_SCHEMA_NAMES)('%s.schema.json is up to date', (name) => { + const path = resolve(generatedDir, `${name}.schema.json`); + const onDisk = JSON.parse(readFileSync(path, 'utf-8')); + expect(onDisk).toEqual(fresh[name]); + }); + + it('rejects semantic negative fixtures in the required backend validation pass', () => { + const fixture = loadFixture('negative/constraint-violation.json'); + const structuralSchema = z.fromJSONSchema(fresh['task-spec']); + + // Standard JSON Schema cannot compare sibling instance properties. + expect(structuralSchema.safeParse(fixture).success).toBe(true); + expect(validateTaskSpecSemantics(fixture)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + message: 'default must be <= max', + path: ['parameters', 1, 'default'], + }), + ]) + ); + }); +}); diff --git a/backend-contract/processing/__tests__/loadFixtures.ts b/backend-contract/processing/__tests__/loadFixtures.ts new file mode 100644 index 000000000..8cb0b09a4 --- /dev/null +++ b/backend-contract/processing/__tests__/loadFixtures.ts @@ -0,0 +1,36 @@ +// Test helper: read the golden JSON fixtures off disk (relative to this file) +// so the vitest suites and the backend's Python loader consume the exact same +// files. Uses node fs rather than a bundler glob so the fixtures stay plain, +// tool-agnostic data both repos can load. + +import { readFileSync, readdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve, basename } from 'node:path'; + +const here = dirname(fileURLToPath(import.meta.url)); + +export const fixturesRoot = resolve(here, '..', '..', 'fixtures'); + +export type LoadedFixture = { + name: string; + path: string; + data: unknown; +}; + +const readJson = (path: string): unknown => + JSON.parse(readFileSync(path, 'utf-8')); + +export const loadFixture = (relPath: string): unknown => + readJson(resolve(fixturesRoot, relPath)); + +export const loadFixtureDir = (relDir: string): LoadedFixture[] => { + const dir = resolve(fixturesRoot, relDir); + return readdirSync(dir) + .filter((f) => f.endsWith('.json')) + .sort() + .map((f) => ({ + name: basename(f, '.json'), + path: resolve(dir, f), + data: readJson(resolve(dir, f)), + })); +}; diff --git a/backend-contract/processing/__tests__/openapi.spec.ts b/backend-contract/processing/__tests__/openapi.spec.ts new file mode 100644 index 000000000..86ba49ccb --- /dev/null +++ b/backend-contract/processing/__tests__/openapi.spec.ts @@ -0,0 +1,181 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { buildOpenApiDocument, NEUTRAL_OPERATION_IDS } from '../openapi'; +import { JOB_STATES, RESULT_INTENTS } from '../wire'; + +// The published OpenAPI is the backend's obligation surface, so it must leak +// NOTHING Girder-specific: a reader enumerates the backend obligation without +// reading girder_volview source. + +const here = dirname(fileURLToPath(import.meta.url)); +const openapiPath = resolve(here, '..', '..', 'generated', 'openapi.json'); + +const doc = buildOpenApiDocument(); + +const get = (obj: unknown, key: string): unknown => + (obj as Record)[key]; + +const schemaComponents = () => + get(get(doc, 'components'), 'schemas') as Record; + +type FoundOp = { method: string; path: string; operationId?: string }; + +const operations = (): FoundOp[] => { + const found: FoundOp[] = []; + const pathItems = get(doc, 'paths') as Record; + for (const [path, methods] of Object.entries(pathItems)) { + for (const [method, op] of Object.entries( + methods as Record + )) { + found.push({ + method, + path, + operationId: get(op, 'operationId') as string | undefined, + }); + } + } + return found; +}; + +const collectRefs = (node: unknown, acc: string[] = []): string[] => { + if (Array.isArray(node)) { + node.forEach((n) => collectRefs(n, acc)); + } else if (node && typeof node === 'object') { + for (const [key, value] of Object.entries(node)) { + if (key === '$ref' && typeof value === 'string') acc.push(value); + else collectRefs(value, acc); + } + } + return acc; +}; + +// --------------------------------------------------------------------------- +// Drift — the checked-in artifact equals what the source builds right now +// --------------------------------------------------------------------------- + +describe('published OpenAPI is in sync with the source', () => { + it('generated/openapi.json matches buildOpenApiDocument()', () => { + const onDisk = JSON.parse(readFileSync(openapiPath, 'utf-8')); + expect(onDisk).toEqual(doc); + }); + + it('package.json version equals the OpenAPI artifact version', () => { + // The two halves of the artifact version (README "Versioning and + // stability") — a bump that touches only one of them is drift. + const pkg = JSON.parse( + readFileSync(resolve(here, '..', '..', 'package.json'), 'utf-8') + ); + expect(pkg.version).toBe(get(get(doc, 'info'), 'version')); + }); +}); + +// --------------------------------------------------------------------------- +// Completeness — exactly the neutral client-invoked surface +// --------------------------------------------------------------------------- + +describe('covers exactly the neutral client-invoked surface', () => { + it('exposes every neutral operationId and no others', () => { + // The published surface is exactly the processing ops. + const ids = operations() + .map((o) => o.operationId) + .sort(); + expect(ids).toEqual([...NEUTRAL_OPERATION_IDS].sort()); + }); + + it('every operation carries an operationId', () => { + expect(operations().every((o) => Boolean(o.operationId))).toBe(true); + }); + + it('job-addressed routes are keyed by jobId alone (no context in path)', () => { + const jobOps = operations().filter((o) => + ['getJob', 'getJobResults', 'cancelJob'].includes(o.operationId ?? '') + ); + expect(jobOps).toHaveLength(3); + jobOps.forEach((o) => { + expect(o.path.startsWith('/jobs/{jobId}')).toBe(true); + }); + }); + + it('requires a run request body containing values', () => { + const run = get(get(get(doc, 'paths'), '/tasks/{taskId}/run'), 'post'); + const requestBody = get(run, 'requestBody'); + expect(get(requestBody, 'required')).toBe(true); + + const runTaskRequest = schemaComponents().RunTaskRequest; + expect(get(runTaskRequest, 'required')).toEqual(['values']); + }); +}); + +// --------------------------------------------------------------------------- +// $ref integrity — every component reference resolves +// --------------------------------------------------------------------------- + +describe('component $refs all resolve', () => { + it('every $ref points at a defined component schema', () => { + const schemas = schemaComponents(); + const refs = collectRefs(doc); + expect(refs.length).toBeGreaterThan(0); + refs.forEach((r) => { + const match = /^#\/components\/schemas\/(.+)$/.exec(r); + expect(match, `unexpected $ref shape: ${r}`).toBeTruthy(); + expect(schemas[match![1]], `unresolved $ref: ${r}`).toBeDefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Single source — the published shapes track the runtime vocabulary +// --------------------------------------------------------------------------- + +describe('single source — published shapes track the source of truth', () => { + it('NeutralJobStatus.state enum equals the runtime JOB_STATES', () => { + const status = schemaComponents().NeutralJobStatus; + const variants = get(status, 'oneOf') as unknown[]; + const states = variants.map((variant) => + get(get(get(variant, 'properties'), 'state'), 'const') + ); + expect(states).toEqual([...JOB_STATES]); + }); + + it('publishes the full v1 result-intent vocabulary', () => { + const serialized = JSON.stringify(schemaComponents().ResultIntent); + RESULT_INTENTS.forEach((intent) => { + expect(serialized).toContain(intent); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Neutrality invariant — no Girder route / id / enum / URL-shape leaks +// --------------------------------------------------------------------------- + +const FORBIDDEN = [ + { label: 'girder route param folderId', re: /folderId/ }, + { label: 'girder api mount /api/v1', re: /\/api\/v1/ }, + { label: 'proxiable url shape', re: /proxiable/i }, + { label: 'girder mention', re: /girder/i }, + { label: 'girder folder vocabulary', re: /folder/i }, + { label: 'slicer mention', re: /slicer/i }, + { label: 'backend task xml', re: /\bxml\b/i }, + { label: 'container tech mention', re: /docker/i }, + // The girder `JobStatus` enum name — but NOT our own neutral `NeutralJobStatus` + // component, which deliberately carries the `Neutral` prefix. + { label: 'JobStatus enum name', re: /(? { + const serialized = JSON.stringify(doc); + it.each(FORBIDDEN)('does not leak $label', ({ re }) => { + expect(serialized).not.toMatch(re); + }); +}); diff --git a/backend-contract/processing/__tests__/task-spec.spec.ts b/backend-contract/processing/__tests__/task-spec.spec.ts new file mode 100644 index 000000000..a53804f5f --- /dev/null +++ b/backend-contract/processing/__tests__/task-spec.spec.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; + +import { + SPEC_VERSION, + taskSpecSchema, + taskParameterSchema, + validateTaskSpecSemantics, + type VolViewTaskSpec, +} from '../task-spec'; +import { loadFixture, loadFixtureDir } from './loadFixtures'; + +// --------------------------------------------------------------------------- +// Golden task-spec fixtures (positive) — every one must validate. The fixtures +// are synthetic neutral specs; the contract carries no backend-specific (e.g. +// Slicer XML) source formats — translating a native task format into this +// neutral spec is a backend concern. +// --------------------------------------------------------------------------- + +describe('task-spec golden fixtures validate', () => { + const fixtures = loadFixtureDir('task-spec'); + + it('loads the expected fixtures', () => { + expect(fixtures.map((f) => f.name).sort()).toEqual([ + 'synthetic-all-kinds', + 'synthetic-bounds-enum', + ]); + }); + + it.each(fixtures.map((f) => [f.name, f.data] as const))( + 'validates %s', + (_name, data) => { + expect(() => taskSpecSchema.parse(data)).not.toThrow(); + } + ); + + it('pins specVersion as an integer on every fixture', () => { + fixtures.forEach(({ data }) => { + const spec = taskSpecSchema.parse(data); + expect(Number.isInteger(spec.specVersion)).toBe(true); + expect(spec.specVersion).toBe(SPEC_VERSION); + }); + }); + + it.each(['.', '..'])('rejects the dot-segment task id %j', (id) => { + const spec = taskSpecSchema.parse(fixtures[0].data); + expect(taskSpecSchema.safeParse({ ...spec, id }).success).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Field-kind coverage against the synthetic fixtures. +// --------------------------------------------------------------------------- + +describe('task-spec field kinds', () => { + const specByName = Object.fromEntries( + loadFixtureDir('task-spec').map((f) => [ + f.name, + taskSpecSchema.parse(f.data), + ]) + ) as Record; + + const paramById = (spec: string, id: string) => + specByName[spec].parameters.find((p) => p.id === id); + + it('models an image sourceRef input with an open `accepts` type-tag list', () => { + const input = paramById('synthetic-all-kinds', 'inputVolume'); + expect(input).toMatchObject({ kind: 'sourceRef', accepts: ['image'] }); + expect(input).toMatchObject({ required: true }); + }); + + it('models a labelmap sourceRef input alongside the image input', () => { + const spec = specByName['synthetic-all-kinds']; + const background = spec.parameters.find((p) => p.id === 'inputVolume'); + const mask = spec.parameters.find((p) => p.id === 'inputLabelmap'); + expect(background).toMatchObject({ kind: 'sourceRef', accepts: ['image'] }); + expect(mask).toMatchObject({ kind: 'sourceRef', accepts: ['labelmap'] }); + expect(spec.parameters.filter((p) => p.kind === 'sourceRef')).toHaveLength( + 2 + ); + }); + + it('carries numeric constraints + default on an int param', () => { + expect(paramById('synthetic-all-kinds', 'radius')).toMatchObject({ + kind: 'int', + min: 1, + max: 10, + step: 1, + default: 1, + }); + }); + + it('models a float param with a default', () => { + expect(paramById('synthetic-all-kinds', 'threshold')).toMatchObject({ + kind: 'float', + default: 50, + }); + }); + + it('models bool and string params with defaults', () => { + expect(paramById('synthetic-all-kinds', 'smooth')).toMatchObject({ + kind: 'bool', + default: true, + }); + expect(paramById('synthetic-all-kinds', 'label')).toMatchObject({ + kind: 'string', + default: 'result', + }); + }); + + it('models a bounds field and an enum field with UI hints', () => { + const roi = paramById('synthetic-bounds-enum', 'roi'); + const method = paramById('synthetic-bounds-enum', 'method'); + expect(roi).toMatchObject({ + kind: 'bounds', + section: 'Region and options', + }); + expect(method).toMatchObject({ + kind: 'enum', + options: ['otsu', 'kmeans', 'manual'], + default: 'otsu', + order: 2, + }); + }); + + it('declares outputs with a semantic type tag', () => { + // A single `.seg.nrrd` labelmap carries its per-label names/colors inside + // the file as embedded metadata, so a lone `labelmap` output suffices. + expect( + specByName['synthetic-bounds-enum'].outputs.map((o) => o.type) + ).toEqual(['labelmap']); + expect( + specByName['synthetic-all-kinds'].outputs.map((o) => o.type) + ).toEqual(['image', 'labelmap']); + }); + + it('accepts the optional `widget` renderer-override hint', () => { + // The renderer picks a default from `kind` when `widget` is absent; this + // pins that the schema still accepts an explicit override. + const parsed = taskParameterSchema.parse({ + kind: 'int', + id: 'radius', + title: 'Radius', + widget: 'slider', + min: 0, + max: 10, + default: 5, + }); + expect(parsed).toMatchObject({ widget: 'slider' }); + }); +}); + +// --------------------------------------------------------------------------- +// Negative fixtures — must FAIL validation (fail closed). +// --------------------------------------------------------------------------- + +describe('task-spec negative fixtures fail validation', () => { + it('rejects an unknown field kind', () => { + const data = loadFixture('negative/unknown-field-kind.json'); + const result = taskSpecSchema.safeParse(data); + expect(result.success).toBe(false); + }); + + it('rejects a constraint violation (default above max)', () => { + const data = loadFixture('negative/constraint-violation.json'); + const result = taskSpecSchema.safeParse(data); + expect(result.success).toBe(false); + expect(validateTaskSpecSemantics(data)).not.toHaveLength(0); + }); + + it('detects the unknown kind at the parameter level (fail-closed hide)', () => { + // The whole spec is rejected above, but an individual param parse also + // fails on the unknown kind — so the engine can hide that one param rather + // than reject the whole spec. + const badParam = { kind: 'color', id: 'tint', default: '#ff0000' }; + expect(taskParameterSchema.safeParse(badParam).success).toBe(false); + }); + + it('rejects empty and duplicate parameter ids', () => { + const base = loadFixture('task-spec/synthetic-all-kinds.json') as Record< + string, + unknown + >; + const parameters = [ + { kind: 'string', id: 'same' }, + { kind: 'bool', id: 'same' }, + { kind: 'int', id: '' }, + ]; + const result = taskSpecSchema.safeParse({ ...base, parameters }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues.map((issue) => issue.path)).toEqual( + expect.arrayContaining([ + ['parameters', 1, 'id'], + ['parameters', 2, 'id'], + ]) + ); + }); + + it('rejects duplicate output ids', () => { + const base = loadFixture('task-spec/synthetic-all-kinds.json') as Record< + string, + unknown + >; + const result = taskSpecSchema.safeParse({ + ...base, + outputs: [{ id: 'same' }, { id: 'same' }], + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ path: ['outputs', 1, 'id'] }), + ]) + ); + }); +}); diff --git a/backend-contract/processing/__tests__/wire.spec.ts b/backend-contract/processing/__tests__/wire.spec.ts new file mode 100644 index 000000000..2bb3d87d4 --- /dev/null +++ b/backend-contract/processing/__tests__/wire.spec.ts @@ -0,0 +1,364 @@ +import { describe, expect, it } from 'vitest'; + +import { + JOB_STATES, + RESULT_INTENTS, + INTENT_VOCABULARY_VERSION, + inputValueSchema, + stageInputDescriptorSchema, + neutralJobStatusSchema, + resultIntentSchema, + knownResultIntentSchema, + jobHistoryPageSchema, + jobHistorySummarySchema, + jobHistoryDetailSchema, + jobResultsSchema, + jobResultsErrorSchema, +} from '../wire'; +import { loadFixture, loadFixtureDir } from './loadFixtures'; + +const wire = Object.fromEntries( + loadFixtureDir('wire').map((f) => [f.name, f.data]) +); + +// --------------------------------------------------------------------------- +// Input values +// --------------------------------------------------------------------------- + +describe('input value fixtures', () => { + it.each([ + 'input-value.dicom-series', + 'input-value.single-file', + 'input-value.labelmap', + ])('validates %s', (name) => { + expect(() => inputValueSchema.parse(wire[name])).not.toThrow(); + }); + + it('carries multiple URIs for a dicom-series image', () => { + const value = inputValueSchema.parse(wire['input-value.dicom-series']); + expect(value.type).toBe('image'); + expect(value.uris.length).toBeGreaterThan(1); + }); + + it('accepts the open `labelmap` type tag (no closed server enum)', () => { + const value = inputValueSchema.parse(wire['input-value.labelmap']); + expect(value.type).toBe('labelmap'); + }); + + it('accepts an unknown/open type tag', () => { + expect(() => + inputValueSchema.parse({ type: 'pet', uris: ['/x'] }) + ).not.toThrow(); + }); + + it('rejects a bound input with no uris (negative fixture)', () => { + const empty = loadFixture('negative/empty-uris.json'); + expect(inputValueSchema.safeParse(empty).success).toBe(false); + }); +}); + +describe('staged resource descriptor fixtures', () => { + it('binds staged labelmap bytes to a durable reference image', () => { + const descriptor = stageInputDescriptorSchema.parse( + wire['stage-input.labelmap'] + ); + expect(descriptor.type).toBe('labelmap'); + expect(descriptor.referenceImage.type).toBe('image'); + expect(descriptor.referenceImage.uris).toHaveLength(2); + }); + + it('rejects a labelmap descriptor without reference provenance', () => { + expect( + stageInputDescriptorSchema.safeParse({ + type: 'labelmap', + name: 'mask.seg.nrrd', + referenceImage: { type: 'image', uris: [] }, + }).success + ).toBe(false); + }); + + it('rejects undeclared reference-image descriptor fields', () => { + expect( + stageInputDescriptorSchema.safeParse({ + type: 'labelmap', + name: 'mask.seg.nrrd', + referenceImage: { + type: 'image', + uris: ['/x'], + backendArtifactId: 'mixed-identity-channel', + }, + }).success + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Neutral status +// --------------------------------------------------------------------------- + +describe('neutral job status fixtures', () => { + it('has exactly the five v1 states, cancelled included', () => { + expect([...JOB_STATES]).toEqual([ + 'pending', + 'running', + 'success', + 'error', + 'cancelled', + ]); + }); + + it.each([ + 'status.pending', + 'status.running', + 'status.success', + 'status.error', + 'status.cancelled', + 'status.error-tail', + ])('validates %s', (name) => { + expect(() => neutralJobStatusSchema.parse(wire[name])).not.toThrow(); + }); + + it('accepts cancelled with no wire change', () => { + const s = neutralJobStatusSchema.parse(wire['status.cancelled']); + expect(s.state).toBe('cancelled'); + }); + + it('carries an errorTail on an errored job', () => { + const s = neutralJobStatusSchema.parse(wire['status.error-tail']); + expect(s.state).toBe('error'); + expect(s.errorTail).toBeTruthy(); + }); + + it('rejects a state outside the five (e.g. the retired `queued`)', () => { + // `queued`/`succeeded`/`failed` are rejected; the runtime names + // (`pending`/`success`/`error`) are the valid five. + expect( + neutralJobStatusSchema.safeParse({ jobId: 'j', state: 'queued' }).success + ).toBe(false); + }); + + it.each(['.', '..'])('rejects the dot-segment job id %j', (jobId) => { + expect( + neutralJobStatusSchema.safeParse({ + jobId, + state: 'running', + resultState: 'waiting', + }).success + ).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Result intents +// --------------------------------------------------------------------------- + +describe('result intent fixtures', () => { + it('exports vocabulary version 1 and the exactly-three state intents', () => { + expect(INTENT_VOCABULARY_VERSION).toBe(1); + expect([...RESULT_INTENTS]).toEqual([ + 'add-base-image', + 'add-layer', + 'add-segment-group', + ]); + expect(wire).not.toHaveProperty('intent.download'); + }); + + it.each([ + 'intent.add-base-image', + 'intent.add-layer', + 'intent.add-segment-group.with-segments', + 'intent.add-segment-group.embedded', + 'intent.unknown', + ])('validates %s', (name) => { + expect(() => resultIntentSchema.parse(wire[name])).not.toThrow(); + }); + + it('parses add-segment-group WITH segments and a source provenance tag', () => { + const parsed = resultIntentSchema.parse( + wire['intent.add-segment-group.with-segments'] + ) as Record; + expect(parsed.intent).toBe('add-segment-group'); + expect(Array.isArray(parsed.segments)).toBe(true); + expect(parsed.source).toEqual({ + providerId: 'analysis-provider', + jobId: 'job-abc123', + outputId: 'outputLabelmap', + }); + }); + + it('parses add-segment-group WITHOUT segments (embedded metadata) but with source', () => { + const parsed = resultIntentSchema.parse( + wire['intent.add-segment-group.embedded'] + ) as Record; + expect(parsed.intent).toBe('add-segment-group'); + expect(parsed.segments).toBeUndefined(); + expect(parsed.source).toMatchObject({ outputId: 'outputLabelmap' }); + }); + + it('rejects a segment-group source without provider identity', () => { + const value = structuredClone( + wire['intent.add-segment-group.with-segments'] + ) as { source: { providerId?: string } }; + delete value.source.providerId; + expect(knownResultIntentSchema.safeParse(value).success).toBe(false); + }); + + it('accepts an unknown intent as an ordinary result with no state action', () => { + const parsed = resultIntentSchema.parse(wire['intent.unknown']) as Record< + string, + unknown + >; + // It parses (fail-open), but is not one of the known state actions. + expect( + knownResultIntentSchema.safeParse(wire['intent.unknown']).success + ).toBe(false); + expect(parsed.url).toBeTruthy(); + expect(parsed.name).toBeTruthy(); + }); + + it.each([ + ['missing', { id: 'r1', url: '/report.csv', name: 'report.csv' }], + [ + 'malformed', + { id: 'r1', intent: 17, url: '/report.csv', name: 'report.csv' }, + ], + ])('accepts a %s intent as an ordinary result record', (_name, value) => { + expect(() => resultIntentSchema.parse(value)).not.toThrow(); + }); + + it('preserves null mimeType/size and extra producer fields on an ordinary result', () => { + const parsed = resultIntentSchema.parse({ + id: 'r1', + intent: 17, + url: '/report.csv', + name: 'report.csv', + mimeType: null, + size: null, + producerHint: 'keep-me', + }) as Record; + expect(parsed.mimeType).toBeNull(); + expect(parsed.size).toBeNull(); + // A catchall-preserved extra field survives without gaining behavior. + expect(parsed.producerHint).toBe('keep-me'); + }); + + it.each([ + ['a missing id', { url: '/x', name: 'x' }], + ['an empty id', { id: '', url: '/x', name: 'x' }], + [ + 'a missing id on a known intent', + { + intent: 'add-base-image', + url: '/x', + name: 'x', + }, + ], + [ + 'an empty id on a known intent', + { + id: '', + intent: 'add-base-image', + url: '/x', + name: 'x', + }, + ], + ])('rejects a result row with %s', (_name, value) => { + expect(resultIntentSchema.safeParse(value).success).toBe(false); + }); + + it('still rejects a result that is not even a file reference', () => { + expect( + resultIntentSchema.safeParse({ id: 'r1', intent: 'add-polygon' }).success + ).toBe(false); + }); + + it('rejects a wrong-length segment color (the tuple-length parity pin)', () => { + // The negative fixture carries a 3-element color. The STRICT union must + // reject it — and the generated JSON Schema must agree (backend side: + // test_contract_fixtures.py), so both validators close fixed-length + // tuples identically. The full union still accepts the row, demoted to an + // ordinary result with no state action (the designed fail-open). + const short = loadFixture('negative/wrong-length-color.json'); + expect(knownResultIntentSchema.safeParse(short).success).toBe(false); + expect(resultIntentSchema.safeParse(short).success).toBe(true); + + const good = wire['intent.add-segment-group.with-segments'] as { + segments: { color: number[] }[]; + }; + const long = structuredClone(good); + long.segments[0].color = [255, 0, 0, 255, 255]; + expect(knownResultIntentSchema.safeParse(long).success).toBe(false); + expect(knownResultIntentSchema.safeParse(good).success).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Durable job-history handle + result-read payloads +// --------------------------------------------------------------------------- + +describe('durable job-history handle + result-read payloads', () => { + it('validates job-history summary, page, and detail fixtures', () => { + expect( + jobHistorySummarySchema.parse(wire['job-history-summary']).state + ).toBe('success'); + expect( + jobHistoryPageSchema.parse(wire['job-history-page']).nextCursor + ).toBe('opaque-continuation'); + expect( + jobHistoryDetailSchema.parse(wire['job-history-detail']).log + ).toEqual(['completed\n']); + }); + + it('rejects an impossible job-history lifecycle pairing', () => { + // Parse the fixture first so the spread has a typed object source (the raw + // fixture is `unknown`, which cannot be spread — TS2698). + const summary = jobHistorySummarySchema.parse(wire['job-history-summary']); + const parsed = jobHistorySummarySchema.safeParse({ + ...summary, + state: 'success', + resultState: 'waiting', + }); + expect(parsed.success).toBe(false); + }); + + it('validates the pinned lightweight job-history page', () => { + const page = jobHistoryPageSchema.parse({ + jobs: [ + { + jobId: 'job-1', + taskId: 'task-1', + taskTitle: 'Threshold', + createdBy: { id: 'user-1', name: 'Ada Lovelace' }, + createdAt: '2026-06-01T12:00:00Z', + startedAt: '2026-06-01T12:00:01Z', + finishedAt: '2026-06-01T12:00:05Z', + state: 'success', + resultState: 'incomplete', + progress: 1, + outputSummary: { + recorded: 2, + missing: 1, + }, + }, + ], + nextCursor: 'opaque-value', + }); + expect(page.jobs[0].taskTitle).toBe('Threshold'); + expect(page.nextCursor).toBe('opaque-value'); + expect(page.jobs[0]).not.toHaveProperty('inputUris'); + expect(page.jobs[0]).not.toHaveProperty('log'); + expect(page.jobs[0]).not.toHaveProperty('params'); + }); + it('validates a getJobResults success payload with a missing count', () => { + const results = jobResultsSchema.parse(wire['job-results.missing']); + expect(results.missing).toBe(2); + expect(results.intents.length).toBe(1); + }); + + it('validates a getJobResults error payload (non-success)', () => { + const err = jobResultsErrorSchema.parse(wire['job-results.error']); + expect(err.message).toBeTruthy(); + expect(err.code).toBe('results_unavailable'); + expect(err.state).toBe('error'); + }); +}); diff --git a/backend-contract/processing/ids.ts b/backend-contract/processing/ids.ts new file mode 100644 index 000000000..86d821711 --- /dev/null +++ b/backend-contract/processing/ids.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +// Opaque IDs used as URL path segments must not be dot segments. URL parsers +// normalize both literal and percent-encoded dot segments before dispatch. +export const pathSegmentIdSchema = z + .string() + .min(1) + .regex(/^(?!\.{1,2}$)/, 'id must not be a dot segment'); diff --git a/backend-contract/processing/index.ts b/backend-contract/processing/index.ts new file mode 100644 index 000000000..07ce6afe6 --- /dev/null +++ b/backend-contract/processing/index.ts @@ -0,0 +1,15 @@ +// --------------------------------------------------------------------------- +// processing/ — the processing slice (task discovery, inputs, job lifecycle, +// results) of the neutral VolView backend contract. +// +// The zod sources in this slice are the single normative definition of the +// task spec and the neutral wire shapes. The golden JSON fixtures under +// `../fixtures/` are the interchange format BOTH suites pin: the VolView +// client validates them with the zod schemas here; the girder_volview backend +// validates them against a JSON Schema generated from these same zod sources +// (see `schema-json.ts`). One definition, two validators. +// --------------------------------------------------------------------------- + +export * from './task-spec'; +export * from './wire'; +export * from './ids'; diff --git a/backend-contract/processing/openapi.ts b/backend-contract/processing/openapi.ts new file mode 100644 index 000000000..4d9d0a92b --- /dev/null +++ b/backend-contract/processing/openapi.ts @@ -0,0 +1,530 @@ +// --------------------------------------------------------------------------- +// The neutral client<->backend REST surface, as an OpenAPI 3.1 document. +// This is the SERVER-side dual of the client transport (engine/transport.ts): +// one states the neutral surface for backend authors, the other implements the +// client half of it. +// +// NEUTRALITY INVARIANT (the whole point): this describes ONLY the neutral +// surface — no Girder routes, no `folderId`, no file ids, no `JobStatus` enum, +// no proxiable-URL shape, no Slicer XML. A reviewer can enumerate exactly what a +// non-Girder backend must implement WITHOUT reading girder_volview source. The +// `__tests__/openapi.spec.ts` neutrality gate greps this document for those +// leaks; keep it clean. +// +// SINGLE SOURCE: the wire component schemas are the SAME zod-generated JSON +// Schemas the backend validates against (`schema-json.ts` / `generateJsonSchemas`), +// injected here rather than re-typed — so the OpenAPI can never drift from the +// normative zod definition. The hand-authored pieces are only the request/ +// response ENVELOPES the client wraps the wire schemas in (they have no zod home +// — they are the engine's transport, not the contract vocabulary). +// --------------------------------------------------------------------------- + +import { z } from 'zod'; +import { generateJsonSchemas, type GeneratedSchemaName } from './schema-json'; +import { INTENT_VOCABULARY_VERSION } from './wire'; +import { SPEC_VERSION } from './task-spec'; +import { pathSegmentIdSchema } from './ids'; + +// --------------------------------------------------------------------------- +// Wire component schemas — injected from the single zod source +// --------------------------------------------------------------------------- + +// Generated-schema name -> OpenAPI component name. Every neutral wire schema the +// package defines is published as a component so a backend author sees the whole +// vocabulary in one document. +const WIRE_COMPONENTS: Record = { + 'task-spec': 'TaskSpec', + 'input-value': 'InputValue', + 'stage-input-descriptor': 'StageInputDescriptor', + 'neutral-job-status': 'NeutralJobStatus', + 'result-intent': 'ResultIntent', + 'job-history-summary': 'JobHistorySummary', + 'job-history-page': 'JobHistoryPage', + 'job-history-detail': 'JobHistoryDetail', + 'job-results': 'JobResults', + 'job-results-error': 'JobResultsError', +}; + +// The generated schemas carry a per-schema `$schema` dialect marker; OpenAPI 3.1 +// declares the dialect once at the document root (`jsonSchemaDialect`), so strip +// the per-schema marker as each is embedded as a component. +const stripDialect = (schema: unknown): Record => { + const { $schema, ...rest } = schema as Record; + void $schema; + return rest; +}; + +const pathSegmentIdJsonSchema = () => + stripDialect(z.toJSONSchema(pathSegmentIdSchema)); + +const wireComponentSchemas = (): Record => { + const generated = generateJsonSchemas(); + return dedupeNestedComponents( + Object.fromEntries( + (Object.keys(WIRE_COMPONENTS) as GeneratedSchemaName[]).map((name) => [ + WIRE_COMPONENTS[name], + stripDialect(generated[name]), + ]) + ) + ); +}; + +// The zod->JSON-Schema generation runs per schema, so a wire schema nested +// inside another is emitted as a full inline copy of a component that is ALSO +// published standalone. Replace those known copies with a $ref to the component +// — smaller and unambiguous for a reader ("intents is an array of +// ResultIntent"). Guarded: the inline copy must be byte-identical to the +// component it is replaced by, so the substitution can never change meaning. +const NESTED_COMPONENT_COPIES = [ + { host: 'JobResults', property: 'intents', component: 'ResultIntent' }, + { host: 'JobHistoryPage', property: 'jobs', component: 'JobHistorySummary' }, +] as const; + +const dedupeNestedComponents = ( + schemas: Record +): Record => { + NESTED_COMPONENT_COPIES.forEach(({ host, property, component }) => { + const hostSchema = schemas[host] as { + properties: Record; + }; + const inline = hostSchema.properties[property].items; + if (JSON.stringify(inline) !== JSON.stringify(schemas[component])) { + throw new Error( + `openapi dedupe: ${host}.${property}.items no longer matches the ` + + `${component} component; update NESTED_COMPONENT_COPIES` + ); + } + hostSchema.properties[property].items = ref(component); + }); + return schemas; +}; + +// --------------------------------------------------------------------------- +// Envelope component schemas — hand-authored transport shapes (no zod home) +// +// These are the request/response wrappers the client puts the wire schemas in. +// They are NOT the contract vocabulary (that is the zod-generated set above); +// they are the neutral transport envelope, kept small and free of any backend +// specific. +// --------------------------------------------------------------------------- + +const ref = (component: string) => ({ + $ref: `#/components/schemas/${component}`, +}); + +const envelopeComponentSchemas = (): Record => ({ + TaskSummary: { + type: 'object', + description: + 'Advisory display metadata for one task in the picker. Pass-through: the ' + + 'client renders it but validates only id/title; every other field is ' + + 'OPTIONAL advisory display metadata a backend MAY omit, and the client ' + + 'never dispatches on it.', + properties: { + id: pathSegmentIdJsonSchema(), + title: { type: 'string' }, + description: { type: 'string' }, + category: { type: 'array', items: { type: 'string' } }, + }, + required: ['id', 'title'], + additionalProperties: true, + }, + + RunTaskRequest: { + type: 'object', + description: + 'Submission payload. `values` maps each task parameter id to its bound ' + + 'value: an InputValue for an input binding, or a scalar/list for a ' + + 'plain parameter.', + properties: { + values: { + type: 'object', + additionalProperties: { + oneOf: [ + ref('InputValue'), + { type: 'string' }, + { type: 'number' }, + { type: 'boolean' }, + { type: 'array' }, + { type: 'null' }, + ], + }, + }, + }, + required: ['values'], + additionalProperties: false, + }, + + JobRef: { + type: 'object', + description: + 'Handle to the submitted job. `jobId` is opaque. An optional terminal ' + + '`status` is the born-terminal fast-path for a synchronous backend.', + properties: { + jobId: pathSegmentIdJsonSchema(), + status: ref('NeutralJobStatus'), + }, + required: ['jobId'], + additionalProperties: false, + }, + + StageResponse: { + type: 'object', + description: + 'Backend-minted opaque URIs for the staged bytes. The client mints no URI ' + + 'itself, so at least one is required (fail closed).', + properties: { + uris: { type: 'array', items: { type: 'string' }, minItems: 1 }, + }, + required: ['uris'], + additionalProperties: false, + }, +}); + +// --------------------------------------------------------------------------- +// Reusable response fragments +// --------------------------------------------------------------------------- + +const json = (schema: Record) => ({ + 'application/json': { schema }, +}); + +// A result read before readiness, or after an execution failure, is a typed +// conflict. Settled partial and total output loss are successful reads carrying +// `resultState: incomplete` and an accurate missing count. +const resultReadErrorResponse = { + description: + 'Results are still waiting, or execution made them unavailable. ' + + 'The typed body distinguishes retryable readiness from permanent unavailability.', + content: json(ref('JobResultsError')), +}; + +// --------------------------------------------------------------------------- +// Paths — every endpoint the client actually calls, in neutral terms +// +// Two addressing models, both neutral: +// * context-scoped (tag `context`): relative to a processing context — the +// collection a task runs against. tasks / spec / run / stage / recent-jobs. +// * job-addressed (tag `job`): keyed by the opaque job id ALONE; the job's +// own access control is the gate, so no context appears in the path. +// --------------------------------------------------------------------------- + +const taskIdParam = { + name: 'taskId', + in: 'path', + required: true, + description: 'Opaque task identifier.', + schema: pathSegmentIdJsonSchema(), +} as const; + +const jobIdParam = { + name: 'jobId', + in: 'path', + required: true, + description: 'Opaque job identifier.', + schema: pathSegmentIdJsonSchema(), +} as const; + +const paths = (): Record => ({ + '/tasks': { + get: { + operationId: 'listTasks', + tags: ['context'], + summary: 'List the processing tasks available in this context.', + responses: { + '200': { + description: 'The available tasks as advisory summaries.', + content: json({ type: 'array', items: ref('TaskSummary') }), + }, + }, + }, + }, + + '/tasks/{taskId}/spec': { + get: { + operationId: 'getTaskSpec', + tags: ['context'], + summary: + "Get a task's VolView task spec (the backend translates its own " + + 'native task format into this neutral spec).', + parameters: [taskIdParam], + responses: { + '200': { + description: "The task's neutral task spec.", + content: json(ref('TaskSpec')), + }, + '404': { description: 'No such task in this context.' }, + }, + }, + }, + + '/tasks/{taskId}/run': { + post: { + operationId: 'runTask', + tags: ['context'], + summary: 'Submit a task; returns an opaque job handle to poll.', + parameters: [taskIdParam], + requestBody: { + required: true, + content: json(ref('RunTaskRequest')), + }, + responses: { + '200': { + description: 'The submitted job handle.', + content: json(ref('JobRef')), + }, + '404': { description: 'No such task in this context.' }, + }, + }, + }, + + '/jobs': { + get: { + operationId: 'listJobHistory', + tags: ['context'], + summary: "The current user's complete job history, newest first.", + parameters: [ + { + name: 'limit', + in: 'query', + schema: { type: 'integer', minimum: 1, maximum: 100 }, + }, + { + name: 'cursor', + in: 'query', + description: 'Opaque continuation cursor returned by the prior page.', + schema: { type: 'string' }, + }, + ], + responses: { + '200': { + description: + 'One bounded lightweight page; logs and parameters are absent.', + content: json(ref('JobHistoryPage')), + }, + }, + }, + }, + + '/jobs/{jobId}/detail': { + get: { + operationId: 'getJobHistoryDetail', + tags: ['job'], + summary: + 'Read logs and submitted parameters for one authorized job on demand.', + parameters: [jobIdParam], + responses: { + '200': { + description: 'The detail-only job fields.', + content: json(ref('JobHistoryDetail')), + }, + }, + }, + }, + + '/stage': { + post: { + operationId: 'stageInput', + tags: ['context'], + summary: + 'Stage a parent-bound labelmap as a transient input; returns ' + + 'backend-minted URIs the client round-trips as an InputValue at submit.', + requestBody: { + required: true, + description: + 'A typed staged resource: the labelmap bytes plus its durable ' + + 'reference-image relationship.', + content: { + 'multipart/form-data': { + schema: { + type: 'object', + properties: { + file: { type: 'string', format: 'binary' }, + descriptor: ref('StageInputDescriptor'), + }, + required: ['file', 'descriptor'], + additionalProperties: false, + }, + encoding: { + descriptor: { + contentType: 'text/plain', + description: 'JSON serialization of StageInputDescriptor.', + }, + }, + }, + }, + }, + responses: { + '200': { + description: 'The backend-minted URIs for the staged bytes.', + content: json(ref('StageResponse')), + }, + }, + }, + }, + + '/jobs/{jobId}': { + get: { + operationId: 'getJob', + tags: ['job'], + summary: + "A job's neutral execution and result-readiness status. Poll until " + + 'the job reaches a terminal state. Job-addressed: keyed by the opaque ' + + "job id alone and gated by the job's own access control — no context " + + 'in the path.', + parameters: [jobIdParam], + responses: { + '200': { + description: 'The neutral job status.', + content: json(ref('NeutralJobStatus')), + }, + }, + }, + delete: { + operationId: 'deleteJob', + tags: ['job'], + summary: + 'Delete one authorized terminal execution record AND everything it ' + + 'owns (cascading).', + description: + 'Deletion is CASCADING, and the cascade is normative: removing the ' + + 'execution record also removes the results it produced and any staged ' + + 'inputs it still owns. A conforming backend MUST NOT retain results ' + + 'for a deleted record, so a client may truthfully tell the user that ' + + 'deleting the job deletes its results. Only a terminal (success / ' + + 'error / cancelled) job is deletable; a non-terminal job is refused ' + + 'with 409 — cancel it and delete once it settles.', + parameters: [jobIdParam], + responses: { + '204': { + description: + 'The job and everything it owned (results, staged inputs) were ' + + 'deleted.', + }, + '409': { + description: + 'The job is not terminal; cancel it and re-delete once it settles.', + }, + }, + }, + }, + + '/jobs/{jobId}/results': { + get: { + operationId: 'getJobResults', + tags: ['job'], + summary: + "A job's settled results as the { resultState, intents, missing } " + + 'envelope. Partial and total output loss return an incomplete envelope.', + parameters: [jobIdParam], + responses: { + '200': { + description: + 'The resolved results as the { resultState, intents, missing } envelope ' + + '(JobResults). Each entry of `intents` is a ResultIntent — a result ' + + 'row carrying a required `id` display key and required `name`/`url`, ' + + 'plus optional/null `mimeType`/`size` file metadata. `missing` counts ' + + 'declared outputs that never arrived plus recorded outputs that cannot ' + + 'be read. Total loss is a valid incomplete response with an empty ' + + 'intents array.', + content: json(ref('JobResults')), + }, + '409': resultReadErrorResponse, + }, + }, + }, + + '/jobs/{jobId}/cancel': { + post: { + operationId: 'cancelJob', + tags: ['job'], + summary: + "Best-effort cancel. Returns the job's real projected status after the " + + 'attempt — never a fabricated `cancelled`; the client poller converges ' + + 'on whatever terminal state the backend ultimately reports. Job-addressed.', + parameters: [jobIdParam], + responses: { + '200': { + description: 'The projected job status after the cancel attempt.', + content: json(ref('NeutralJobStatus')), + }, + }, + }, + }, +}); + +// --------------------------------------------------------------------------- +// The document +// --------------------------------------------------------------------------- + +export const buildOpenApiDocument = (): Record => ({ + openapi: '3.1.0', + jsonSchemaDialect: 'https://json-schema.org/draft/2020-12/schema', + info: { + title: 'VolView neutral backend contract', + // The ARTIFACT's own draft version: a private draft 0.x carrying no + // stability promise, DISTINCT from the shape versions (INTENT_VOCABULARY_ + // VERSION / specVersion) below. It is deliberately literal, not derived from + // the shape-version constants — the artifact and the shapes version on + // separate clocks. + version: '0.1.0', + description: + 'DRAFT 0.x — shapes may change until a second backend passes the ' + + 'conformance kit (the pinned 1.0 criterion). ' + + 'The neutral REST surface the VolView client calls to run processing ' + + 'tasks against a backend. A conforming server-side BACKEND implements ' + + 'these endpoints and the referenced wire schemas — no VolView client ' + + 'change is needed to bring a new backend online. Everything here is ' + + 'neutral: no backend routes, ids, status enums, or URL shapes leak. The ' + + 'artifact version is the draft artifact version, distinct from ' + + 'the shape versions: the result-intent vocabulary is at version ' + + `${INTENT_VOCABULARY_VERSION} (INTENT_VOCABULARY_VERSION); the task-spec ` + + `shape at version ${SPEC_VERSION} (specVersion).`, + }, + servers: [ + { + url: '{baseUrl}', + description: + 'The provider processing base. Context-scoped endpoints are relative ' + + 'to a processing context; job-addressed endpoints are keyed by job id ' + + 'alone. The mapping of both onto concrete URLs is the backend choice.', + variables: { baseUrl: { default: '/' } }, + }, + ], + tags: [ + { + name: 'context', + description: + 'Context-scoped: operate against a processing context (the collection ' + + 'a task runs in).', + }, + { + name: 'job', + description: + 'Job-addressed: keyed by the opaque job id alone and gated by the ' + + "job's own access control — no context in the path.", + }, + ], + paths: { ...paths() }, + components: { + schemas: { + ...wireComponentSchemas(), + ...envelopeComponentSchemas(), + }, + }, +}); + +// Every operationId the published surface must expose (enumerate the backend +// obligation without reading girder_volview source). +export const NEUTRAL_OPERATION_IDS = [ + 'listTasks', + 'getTaskSpec', + 'runTask', + 'listJobHistory', + 'getJobHistoryDetail', + 'deleteJob', + 'stageInput', + 'getJob', + 'getJobResults', + 'cancelJob', +] as const; diff --git a/backend-contract/processing/schema-json.ts b/backend-contract/processing/schema-json.ts new file mode 100644 index 000000000..73295dee2 --- /dev/null +++ b/backend-contract/processing/schema-json.ts @@ -0,0 +1,91 @@ +// --------------------------------------------------------------------------- +// Parity mechanism (single source): generate a JSON Schema from the zod +// source so the Python/backend side validates the SAME golden fixtures against +// the SAME normative definition — one schema, two validators — instead of a +// hand-maintained second copy. +// +// NOTE: this is the INTERNAL zod->JSON-Schema for fixture parity, NOT a +// third-party-facing JSON-Schema *view* of the task spec. The generated files +// here exist only so the backend tests can validate the shared fixtures. +// +// zod's cross-field refinements (min<=max, default-in-range, enum default) are +// NOT representable in standard JSON Schema; `unrepresentable: 'any'` drops +// them from the generated structural schema. A backend MUST follow task-spec +// JSON-Schema validation with `validateTaskSpecSemantics` (or an equivalent +// implementation) and run every negative fixture as a conformance suite. +// --------------------------------------------------------------------------- + +import { z } from 'zod'; +import { taskSpecSchema } from './task-spec'; +import { + inputValueSchema, + stageInputDescriptorSchema, + neutralJobStatusSchema, + resultIntentSchema, + jobHistorySummarySchema, + jobHistoryPageSchema, + jobHistoryDetailSchema, + jobResultsSchema, + jobResultsErrorSchema, +} from './wire'; + +const schemas = { + 'task-spec': taskSpecSchema, + 'input-value': inputValueSchema, + 'stage-input-descriptor': stageInputDescriptorSchema, + 'neutral-job-status': neutralJobStatusSchema, + 'result-intent': resultIntentSchema, + 'job-history-summary': jobHistorySummarySchema, + 'job-history-page': jobHistoryPageSchema, + 'job-history-detail': jobHistoryDetailSchema, + 'job-results': jobResultsSchema, + 'job-results-error': jobResultsErrorSchema, +} as const; + +export type GeneratedSchemaName = keyof typeof schemas; +type JsonSchema = z.core.JSONSchema.JSONSchema; + +// z.toJSONSchema renders a fixed-length z.tuple (color RGBA, bounds) as bare +// `prefixItems`, which JSON Schema treats as a prefix constraint only — a +// wrong-length array still validates, while the normative zod rejects it. +// Close every tuple to its exact length so both validators agree. A tuple +// with a rest element would carry `items`; leave its maxItems open. +const closeTupleLengths = (node: unknown): unknown => { + if (Array.isArray(node)) return node.map(closeTupleLengths); + if (node === null || typeof node !== 'object') return node; + const walked = Object.fromEntries( + Object.entries(node as Record).map(([key, value]) => [ + key, + closeTupleLengths(value), + ]) + ); + if (!Array.isArray(walked.prefixItems)) return walked; + return { + minItems: walked.prefixItems.length, + ...('items' in walked ? {} : { maxItems: walked.prefixItems.length }), + ...walked, + }; +}; + +export const generateJsonSchemas = (): Record< + GeneratedSchemaName, + JsonSchema +> => + Object.fromEntries( + Object.entries(schemas).map(([name, schema]) => [ + name, + name === 'task-spec' + ? { + $comment: + 'Structural validation only. Implement backend-contract validateTaskSpecSemantics after this schema and reject every fixtures/negative payload.', + ...(closeTupleLengths( + z.toJSONSchema(schema, { unrepresentable: 'any' }) + ) as JsonSchema), + } + : closeTupleLengths(z.toJSONSchema(schema, { unrepresentable: 'any' })), + ]) + ) as Record; + +export const GENERATED_SCHEMA_NAMES = Object.keys( + schemas +) as GeneratedSchemaName[]; diff --git a/backend-contract/processing/task-spec.ts b/backend-contract/processing/task-spec.ts new file mode 100644 index 000000000..b72d1aa88 --- /dev/null +++ b/backend-contract/processing/task-spec.ts @@ -0,0 +1,338 @@ +// --------------------------------------------------------------------------- +// VolView task-spec schema — the neutral task description. +// +// This is VolView's OWN, zod-validated task description. A backend never speaks +// this shape natively: a server-side backend TRANSLATES its native task format +// (Slicer Execution Model XML, MONAI `/info`, …) into this one neutral spec. +// The zod source here is the single normative definition; the golden JSON +// fixtures under `fixtures/` are the interchange format both the client (zod) +// and the backend (a JSON-Schema generated from this source) validate against. +// +// Additive-only: new fields must extend this schema AGAINST `specVersion`, +// never mutate it. +// --------------------------------------------------------------------------- + +import { z } from 'zod'; +import { pathSegmentIdSchema } from './ids'; + +// The integer spec version, present from day one so later additions are +// negotiable. Bump only on a shape change; new optional fields do not need it. +export const SPEC_VERSION = 1; + +// --------------------------------------------------------------------------- +// Shared vocabulary +// --------------------------------------------------------------------------- + +// Semantic type tag. OPEN vocabulary (no closed server enum): the +// tag says what an input/output IS to the task, not its byte format. Unknown +// tags are ACCEPTED, never rejected — a `z.string()`, not a `z.enum`. v1 seed +// vocabulary is `image | labelmap`; modality refinements (`ct`, `pet`) extend +// it when a task needs them. +export const TYPE_TAG_IMAGE = 'image'; +export const TYPE_TAG_LABELMAP = 'labelmap'; +export const typeTagSchema = z.string(); +const identifierSchema = z.string().regex(/\S/, 'id must not be empty'); + +// Axis-aligned world-space box in LPS: [xmin, xmax, ymin, ymax, zmin, zmax]. +// The value carried by a `bounds` parameter (bound from the crop tool). +export const boundsSchema = z.tuple([ + z.number(), + z.number(), + z.number(), + z.number(), + z.number(), + z.number(), +]); +export type Bounds = z.infer; + +// The known parameter kinds. An unknown kind is DETECTABLE: the whole-spec +// schema below is a discriminated union over `kind`, so a spec carrying an +// unknown kind fails validation. The engine reuses the per-kind pieces to hide +// an unknown param and refuse submit if it was required — a graceful per-param +// fail-closed, NOT a whole-spec reject. +export const PARAMETER_KINDS = [ + 'int', + 'float', + 'string', + 'bool', + 'enum', + 'sourceRef', + 'bounds', +] as const; +export type ParameterKind = (typeof PARAMETER_KINDS)[number]; + +// --------------------------------------------------------------------------- +// Parameter kinds +// --------------------------------------------------------------------------- + +// Fields common to every parameter kind: identity, the advisory UI hints +// (`section` / `order` / `help` / `widget`), and the `required` flag. `widget` +// is an optional renderer override — the renderer picks a default widget from +// `kind` when it is absent. +const paramCommon = { + id: identifierSchema, + title: z.string().optional(), + help: z.string().optional(), + section: z.string().optional(), + order: z.number().optional(), + widget: z.string().optional(), + required: z.boolean().optional(), +}; + +const intParam = z.object({ + kind: z.literal('int'), + ...paramCommon, + min: z.number().int().optional(), + max: z.number().int().optional(), + step: z.number().int().optional(), + default: z.number().int().optional(), +}); + +const floatParam = z.object({ + kind: z.literal('float'), + ...paramCommon, + min: z.number().optional(), + max: z.number().optional(), + step: z.number().optional(), + default: z.number().optional(), +}); + +const stringParam = z.object({ + kind: z.literal('string'), + ...paramCommon, + default: z.string().optional(), +}); + +const boolParam = z.object({ + kind: z.literal('bool'), + ...paramCommon, + default: z.boolean().optional(), +}); + +// Enum options are string OR number: some backend task formats declare numeric +// enumerations (e.g. Slicer integer/float enumerations), and coercing them to +// strings at the boundary would lose the type the task expects back at submit. +const enumOptionSchema = z.union([z.string(), z.number()]); + +const enumParam = z.object({ + kind: z.literal('enum'), + ...paramCommon, + options: z.array(enumOptionSchema).min(1), + default: enumOptionSchema.optional(), +}); + +// Imaging-native field kind: the input. `accepts` is a list of the open type +// tags this input binds (e.g. `["image"]`, `["labelmap"]`); the backend authors +// the mapping from its native format. +const sourceRefParam = z.object({ + kind: z.literal('sourceRef'), + ...paramCommon, + accepts: z.array(typeTagSchema).min(1), +}); + +const boundsParam = z.object({ + kind: z.literal('bounds'), + ...paramCommon, + default: boundsSchema.optional(), +}); + +const parameterUnion = z.discriminatedUnion('kind', [ + intParam, + floatParam, + stringParam, + boolParam, + enumParam, + sourceRefParam, + boundsParam, +]); + +const isNumericKind = (kind: ParameterKind) => + kind === 'int' || kind === 'float'; + +export type TaskSpecSemanticIssue = { + message: string; + path: (string | number)[]; +}; + +type StructuralTaskParameter = z.infer; + +const parameterSemanticIssues = ( + p: StructuralTaskParameter +): TaskSpecSemanticIssue[] => { + const issues: TaskSpecSemanticIssue[] = []; + if ( + isNumericKind(p.kind) && + 'min' in p && + 'max' in p && + p.min != null && + p.max != null && + p.min > p.max + ) { + issues.push({ message: 'min must be <= max', path: ['min'] }); + } + if ( + isNumericKind(p.kind) && + 'default' in p && + 'min' in p && + p.default != null && + p.min != null && + p.default < p.min + ) { + issues.push({ message: 'default must be >= min', path: ['default'] }); + } + if ( + isNumericKind(p.kind) && + 'default' in p && + 'max' in p && + p.default != null && + p.max != null && + p.default > p.max + ) { + issues.push({ message: 'default must be <= max', path: ['default'] }); + } + if (isNumericKind(p.kind) && 'step' in p && p.step != null && p.step <= 0) { + issues.push({ message: 'step must be > 0', path: ['step'] }); + } + if ( + p.kind === 'enum' && + p.default != null && + !p.options.includes(p.default) + ) { + issues.push({ + message: 'default must be one of the enum options', + path: ['default'], + }); + } + return issues; +}; + +// JSON Schema cannot compare sibling values such as `default` and `max`. +// Backends must run this semantic pass after structural JSON-Schema validation. +// The normative zod schema below calls the same implementation, preventing the +// two validation paths from drifting. +export const validateTaskSpecSemantics = ( + spec: unknown +): TaskSpecSemanticIssue[] => { + if (spec === null || typeof spec !== 'object') return []; + const record = spec as Record; + const issues: TaskSpecSemanticIssue[] = []; + + if (Array.isArray(record.parameters)) { + record.parameters.forEach((parameter, index) => { + const parsed = parameterUnion.safeParse(parameter); + if (!parsed.success) return; + parameterSemanticIssues(parsed.data).forEach((issue) => + issues.push({ ...issue, path: ['parameters', index, ...issue.path] }) + ); + }); + } + + (['parameters', 'outputs'] as const).forEach((field) => { + const entries = record[field]; + if (!Array.isArray(entries)) return; + const seen = new Set(); + entries.forEach((entry, index) => { + if ( + entry === null || + typeof entry !== 'object' || + !('id' in entry) || + typeof entry.id !== 'string' + ) { + return; + } + if (seen.has(entry.id)) { + issues.push({ + message: `duplicate ${field === 'parameters' ? 'parameter' : 'output'} id: ${entry.id}`, + path: [field, index, 'id'], + }); + } + seen.add(entry.id); + }); + }); + + return issues; +}; + +// Cross-field constraint checks layered on top of the discriminated union. +export const taskParameterSchema = parameterUnion.superRefine( + (parameter, ctx) => { + parameterSemanticIssues(parameter).forEach((issue) => + ctx.addIssue({ code: 'custom', ...issue }) + ); + } +); + +export type VolViewTaskParameter = z.infer; + +// --------------------------------------------------------------------------- +// Outputs +// --------------------------------------------------------------------------- + +// A declared task output. `type` is the same open semantic tag as inputs +// (`image | labelmap | file | …`); `format` is an advisory byte-format hint +// (e.g. `.nii.gz`). The backend authors these; the client maps them to result +// intents. +export const taskOutputSchema = z.object({ + id: identifierSchema, + title: z.string().optional(), + help: z.string().optional(), + type: typeTagSchema.optional(), + format: z.string().optional(), +}); + +export type VolViewTaskOutput = z.infer; + +// --------------------------------------------------------------------------- +// The task spec +// --------------------------------------------------------------------------- + +const reportDuplicateIds = ( + entries: unknown[], + path: 'parameters' | 'outputs', + ctx: z.RefinementCtx +) => { + const seen = new Set(); + entries.forEach((entry, index) => { + if ( + typeof entry !== 'object' || + entry === null || + !('id' in entry) || + typeof entry.id !== 'string' + ) { + return; + } + const { id } = entry; + if (!seen.has(id)) { + seen.add(id); + return; + } + ctx.addIssue({ + code: 'custom', + message: `duplicate ${path === 'parameters' ? 'parameter' : 'output'} id: ${id}`, + path: [path, index, 'id'], + }); + }); +}; + +export const taskSpecStructuralSchema = z.object({ + specVersion: z.number().int(), + id: pathSegmentIdSchema.regex(/\S/, 'id must not be empty'), + title: z.string(), + description: z.string().optional(), + parameters: z.array(taskParameterSchema), + outputs: z.array(taskOutputSchema), +}); + +export const reportDuplicateTaskSpecIds = ( + spec: { parameters: unknown[]; outputs: unknown[] }, + ctx: z.RefinementCtx +) => { + reportDuplicateIds(spec.parameters, 'parameters', ctx); + reportDuplicateIds(spec.outputs, 'outputs', ctx); +}; + +export const taskSpecSchema = taskSpecStructuralSchema.superRefine( + reportDuplicateTaskSpecIds +); + +export type VolViewTaskSpec = z.infer; diff --git a/backend-contract/processing/wire.ts b/backend-contract/processing/wire.ts new file mode 100644 index 000000000..7f6632e2d --- /dev/null +++ b/backend-contract/processing/wire.ts @@ -0,0 +1,307 @@ +// --------------------------------------------------------------------------- +// Neutral wire shapes shared between the client and any backend. Same neutral +// shapes everywhere: never a backend file id, a route, or a Girder `JobStatus` +// enum. +// +// Two DIFFERENT fail-closed behaviors live here, on purpose: +// * an unknown task-spec field kind is REJECTED (task-spec.ts, negative +// fixture) — the client must not silently render a param it can't type; +// * a missing, unknown, or malformed result INTENT is ACCEPTED as an +// ordinary result record but carries no VolView state directive. +// --------------------------------------------------------------------------- + +import { z } from 'zod'; +import { typeTagSchema } from './task-spec'; +import { pathSegmentIdSchema } from './ids'; + +// Bump when the intent vocabulary's shape changes so producers and the applier +// can negotiate compatibility. +export const INTENT_VOCABULARY_VERSION = 1; + +// --------------------------------------------------------------------------- +// Input value: what the client sends at submit +// --------------------------------------------------------------------------- + +// The bound input's value: verbatim provenance URIs plus a SEMANTIC type tag. +// `type`/`format` are an open vocabulary (no closed server enum). `uris` +// are the client's own opaque provenance URIs in sorted slice order (advisory), +// and at least one is required — a bound input with no URIs is not a value +// (the client never mints one, and the backend rejects it with a 400). +export const inputValueSchema = z.object({ + type: typeTagSchema, + format: z.string().optional(), + uris: z.array(z.string()).min(1), +}); + +export type InputValue = z.infer; + +// The typed descriptor that accompanies staged bytes. Staging creates a +// labelmap resource bound to the image it overlays; the image value is the +// same neutral, opaque-provenance shape used by ordinary task inputs. +export const stageInputDescriptorSchema = z.strictObject({ + type: z.literal('labelmap'), + name: z.string().min(1), + referenceImage: inputValueSchema + .extend({ + type: z.literal('image'), + uris: z.array(z.string()).min(1), + }) + .strict(), +}); + +export type StageInputDescriptor = z.infer; + +// --------------------------------------------------------------------------- +// Neutral job status +// --------------------------------------------------------------------------- + +// Exactly these five states, named to match what the backend projects and the +// client store consumes at runtime (`pending | running | success | error | +// cancelled`): typical backend job lifecycles map onto these with no translation +// layer, so the producer and the consumer already agree. `cancelled` is present +// so cancel needs no wire change; the terminal states (`success | error | +// cancelled`) also carry the born-terminal sync fast-path at zero cost. +export const JOB_STATES = [ + 'pending', + 'running', + 'success', + 'error', + 'cancelled', +] as const; +export type JobState = (typeof JOB_STATES)[number]; + +export const jobStateSchema = z.enum(JOB_STATES); + +export const RESULT_STATES = [ + 'waiting', + 'ready', + 'incomplete', + 'unavailable', +] as const; +export type ResultState = (typeof RESULT_STATES)[number]; + +export const resultStateSchema = z.enum(RESULT_STATES); + +const neutralJobStatusBaseSchema = z.object({ + jobId: pathSegmentIdSchema, + progress: z.number().optional(), + errorTail: z.string().optional(), +}); + +export const neutralJobStatusSchema = z.discriminatedUnion('state', [ + neutralJobStatusBaseSchema.extend({ + state: z.literal('pending'), + resultState: z.literal('waiting'), + }), + neutralJobStatusBaseSchema.extend({ + state: z.literal('running'), + resultState: z.literal('waiting'), + }), + neutralJobStatusBaseSchema.extend({ + state: z.literal('success'), + resultState: z.enum(['ready', 'incomplete']), + }), + neutralJobStatusBaseSchema.extend({ + state: z.literal('error'), + resultState: z.literal('unavailable'), + }), + neutralJobStatusBaseSchema.extend({ + state: z.literal('cancelled'), + resultState: z.literal('unavailable'), + }), +]); + +export type NeutralJobStatus = z.infer; + +// --------------------------------------------------------------------------- +// Result-intent vocabulary +// --------------------------------------------------------------------------- + +// The state directives the applier understands. +export const RESULT_INTENTS = [ + 'add-base-image', + 'add-layer', + 'add-segment-group', +] as const; +export type ResultIntentName = (typeof RESULT_INTENTS)[number]; + +// Provenance tag stamped on an applied segment group: an idempotency key. +// Structurally identical to the `source?` field on `SegmentGroupMetadata` so it +// round-trips the `.volview.zip`. +export const resultSourceSchema = z.object({ + providerId: z.string(), + jobId: z.string(), + outputId: z.string(), +}); +export type ResultSource = z.infer; + +const colorChannel = z.number().int().min(0).max(255); + +// A segment descriptor: `value` is a label index >= 1 (0 is reserved +// background), `color` is RGBA 0-255. +export const segmentDescriptorSchema = z.object({ + value: z.number().int().min(1), + name: z.string(), + color: z.tuple([colorChannel, colorChannel, colorChannel, colorChannel]), + visible: z.boolean().optional(), +}); +export type SegmentDescriptor = z.infer; + +// The ONE canonical result-list-item shape, shared by every producer, the +// client, the generated OpenAPI, the fixtures, and the backend copy. `id` is the +// display key (required, nonempty); `name`/`url` are required; `mimeType`/`size` +// are advisory file metadata that may be null. Every intent branch is built FROM +// this shape, so there is no payload the contract accepts but the client rejects. +export const resultListItemSchema = z.object({ + id: z.string().min(1), + name: z.string(), + url: z.string(), + mimeType: z.string().nullish(), + size: z.number().nonnegative().nullish(), +}); +export type ResultListItem = z.infer; + +// The known intents are built from the common result-item shape. +// `.passthrough()` keeps an unrecognized extra producer field on a KNOWN intent +// (it survives round-trip but gains no behavior). +const addBaseImage = z + .object({ + intent: z.literal('add-base-image'), + ...resultListItemSchema.shape, + }) + .passthrough(); + +const addLayer = z + .object({ intent: z.literal('add-layer'), ...resultListItemSchema.shape }) + .passthrough(); + +// `add-segment-group` carries OPTIONAL `segments` (the bare-labelmap + +// labels-sidecar case; a `seg.nrrd` with embedded metadata carries none — the +// client uses `segments` when present, else the file's own metadata) and an +// optional `source` provenance tag (the idempotency key). +const addSegmentGroup = z + .object({ + intent: z.literal('add-segment-group'), + ...resultListItemSchema.shape, + segments: z.array(segmentDescriptorSchema).optional(), + source: resultSourceSchema.optional(), + }) + .passthrough(); + +// The STRICT half of the vocabulary: exactly the v1 state directives, each with its +// declared shape. Exported so the single applier can gate on which union member +// strictly matched — a name-known-but-shape-invalid result (e.g. a broken +// `segments`) carries no state directive rather than being applied as valid. +export const knownResultIntentSchema = z.discriminatedUnion('intent', [ + addBaseImage, + addLayer, + addSegmentGroup, +]); + +export type KnownResultIntent = z.infer; + +// The ordinary-result branch: missing, unknown, and malformed intent values +// still preserve the full result row (id/name/url required), but the client +// performs no state action. `.catchall` keeps any extra producer fields without +// interpreting them. +const unknownIntent = z + .object({ + intent: z.unknown().optional(), + ...resultListItemSchema.shape, + }) + .catchall(z.unknown()); + +export const resultIntentSchema = z.union([ + knownResultIntentSchema, + unknownIntent, +]); + +export type ResultIntent = z.infer; + +// --------------------------------------------------------------------------- +// Complete personal job history +// --------------------------------------------------------------------------- + +export const jobHistorySummarySchema = z + .object({ + jobId: pathSegmentIdSchema, + taskId: pathSegmentIdSchema, + taskTitle: z.string(), + createdBy: z.object({ id: z.string(), name: z.string() }), + createdAt: z.string(), + startedAt: z.string().optional(), + finishedAt: z.string().optional(), + state: jobStateSchema, + resultState: resultStateSchema, + progress: z.number().min(0).max(1).optional(), + outputSummary: z + .object({ + recorded: z.number().int().nonnegative(), + missing: z.number().int().nonnegative(), + }) + .optional(), + }) + .superRefine((summary, context) => { + const lifecycle = neutralJobStatusSchema.safeParse({ + jobId: summary.jobId, + state: summary.state, + resultState: summary.resultState, + }); + if (!lifecycle.success) { + context.addIssue({ + code: z.ZodIssueCode.custom, + path: ['resultState'], + message: `resultState ${summary.resultState} is invalid for ${summary.state}`, + }); + } + }); + +export type JobHistorySummary = z.infer; + +export const jobHistoryPageSchema = z.object({ + jobs: z.array(jobHistorySummarySchema), + nextCursor: z.string().nullable(), +}); + +export type JobHistoryPage = z.infer; + +export const jobHistoryDetailSchema = z.object({ + jobId: pathSegmentIdSchema, + log: z.array(z.string()), + parameters: z.record(z.string(), z.unknown()), +}); + +export type JobHistoryDetail = z.infer; + +// --------------------------------------------------------------------------- +// Result-read payloads +// --------------------------------------------------------------------------- + +// A successful results read: the resolved intents plus a count of outputs the +// backend could not resolve (deleted files, etc.). `missing` is reported rather +// than silently dropped, so "succeeded with no outputs" stays distinguishable +// from "outputs deleted". +export const jobResultsSchema = z.object({ + resultState: z.enum(['ready', 'incomplete']), + intents: z.array(resultIntentSchema), + missing: z.number().int().nonnegative(), +}); +export type JobResults = z.infer; + +// The explicit error the backend returns for a non-succeeded job, so the client +// never mistakes a failed/running read for empty results. +export const jobResultsErrorSchema = z.discriminatedUnion('code', [ + z.object({ + code: z.literal('results_not_ready'), + message: z.string(), + state: z.enum(['pending', 'running']), + resultState: z.literal('waiting'), + }), + z.object({ + code: z.literal('results_unavailable'), + message: z.string(), + state: z.enum(['error', 'cancelled']), + resultState: z.literal('unavailable'), + }), +]); +export type JobResultsError = z.infer; diff --git a/backend-contract/scripts/generate-json-schema.ts b/backend-contract/scripts/generate-json-schema.ts new file mode 100644 index 000000000..b0d99b98b --- /dev/null +++ b/backend-contract/scripts/generate-json-schema.ts @@ -0,0 +1,26 @@ +// --------------------------------------------------------------------------- +// Writes the generated JSON Schemas (the single-source parity artifact) to +// `backend-contract/generated/`. Run with the repo's TypeScript runner: +// +// npx tsx backend-contract/scripts/generate-json-schema.ts +// +// The checked-in output is guarded against drift from the zod source by +// `processing/__tests__/generated-schema.spec.ts`; it ships in the `volview` +// package, where girder_volview's conformance tests read it. +// --------------------------------------------------------------------------- + +import { writeFileSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { generateJsonSchemas } from '../processing/schema-json'; + +const here = dirname(fileURLToPath(import.meta.url)); +const outDir = resolve(here, '..', 'generated'); +mkdirSync(outDir, { recursive: true }); + +const schemas = { ...generateJsonSchemas() }; +Object.entries(schemas).forEach(([name, schema]) => { + const path = resolve(outDir, `${name}.schema.json`); + writeFileSync(path, `${JSON.stringify(schema, null, 2)}\n`); + console.log(`wrote ${path}`); +}); diff --git a/backend-contract/scripts/generate-openapi.ts b/backend-contract/scripts/generate-openapi.ts new file mode 100644 index 000000000..cd99fa314 --- /dev/null +++ b/backend-contract/scripts/generate-openapi.ts @@ -0,0 +1,25 @@ +// --------------------------------------------------------------------------- +// Writes the published OpenAPI document to +// `backend-contract/generated/openapi.json`. Run with the repo's TS runner: +// +// npx tsx backend-contract/scripts/generate-openapi.ts +// +// Single source: the wire component schemas are injected from the SAME +// zod-generated JSON Schemas the backend validates against, so the OpenAPI can +// never drift from the normative zod definition. `processing/__tests__/openapi.spec.ts` +// guards the checked-in output against drift; it ships in the `volview` package +// (alongside the fixtures + generated schemas), where girder_volview reads it. +// --------------------------------------------------------------------------- + +import { writeFileSync, mkdirSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { buildOpenApiDocument } from '../processing/openapi'; + +const here = dirname(fileURLToPath(import.meta.url)); +const outDir = resolve(here, '..', 'generated'); +mkdirSync(outDir, { recursive: true }); + +const path = resolve(outDir, 'openapi.json'); +writeFileSync(path, `${JSON.stringify(buildOpenApiDocument(), null, 2)}\n`); +console.log(`wrote ${path}`); diff --git a/docs/authentication.md b/docs/authentication.md index e919b9f61..c6cc95171 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -17,13 +17,49 @@ When accessing OAuth-protected resources, VolView by default does not add the `A You can pass in the `token` URL parameter like so: `https://example.com/VolView/?token=XXXX`. When VolView sees this, it will extract the token from the URL and use it for all subsequent data requests. VolView will also strip the token from the URL to prevent saving the token into the browser history. +#### Common scenario: a hosted client reading protected data on another origin + +A data portal links a user to a shared/hosted VolView (e.g. the public +`volview.kitware.app`) and passes a token so VolView can pull that user's +protected images back from the portal's API: + +``` +https://volview.kitware.app/?names=[study.zip]&urls=[https://data.example.org/api/getImage?id=...]&token=XXXX +``` + +The bearer rides to `data.example.org` even though it is a different origin from +the client — this cross-origin read is the intended, supported use. For it to +work, the **data server** (not VolView) must set CORS, and two details trip +people up: + +- `Access-Control-Allow-Origin` must be a **single** value. A header echoing + several (`*, volview.kitware.app`) is invalid and the browser rejects it — send + either `*` or the one VolView origin. +- The preflight must permit the auth header: + `Access-Control-Allow-Headers: authorization`. Attaching `Authorization` + forces a preflight, so a server that omits this passes anonymous requests but + rejects tokened ones. + +Saving is **not** part of this scenario — a hosted client cannot save to the +portal's origin (see [Remote save is same-origin only](#remote-save-is-same-origin-only)). + ### `tokenUrl` URL parameter As an alternative to passing in the token via the URL, if you have an endpoint that returns the user's token then you can use the `tokenUrl` parameter like so: `https://example.com/VolView/?tokenUrl=https://example.com/userToken`. If VolView successfully receives a token from this endpoint, it will use the token in subsequent data requests. -The token URL is expected to return the access token as plaintext, i.e. `text/plain`. Please note that you cannot use an OAuth token endpoint here! OAuth token endpoints are used to exchange auth information, while `tokenUrl` must return just the access token under an already-authenticated session. +The token URL is expected to respond `200 OK` with the access token as plaintext, i.e. `text/plain` — just the token, nothing else. Please note that you cannot use an OAuth token endpoint here! OAuth token endpoints are used to exchange auth information and reply with a JSON object, while `tokenUrl` must return just the access token under an already-authenticated session. By default, VolView will make a `GET` request to the token URL. If another type of request is needed, you can configure it via the `tokenUrlMethod` parameter. For example, to make a `POST` request: `https:/example.com/VolView/?tokenUrl=https://example.com/userToken&tokenUrlMethod=POST`. > [!NOTE] > This requires CORS to be properly configured for the token URL endpoint. See the [CORS](/cors) documentation for more info. + +## Remote save is same-origin only + +Tokens authorize *reading* data from any origin. They do **not** enable saving to another origin. + +The remote save target (`save=`) is accepted only when its origin matches the origin serving VolView. A cross-origin `save=` URL is refused, and the save UI stays disabled — so **a `token=` (or `tokenUrl=`) plus a cross-origin `save=` does not work**, even though the same token will happily authorize cross-origin data requests. To save, host VolView on the same origin as the endpoint that receives the session. + +This is deliberate: it means a deployment that serves no save endpoint of its own — such as the public demo — has no save at all, with nothing to configure, and a crafted `?save=https://attacker.example/` link cannot POST the user's session to a third party. + +Loading is unaffected: `urls=` accepts any origin, so public cross-origin datasets (IDC S3, TCIA GCS buckets) load normally. diff --git a/eslint.config.js b/eslint.config.js index 360c2744c..e16734f65 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -89,5 +89,90 @@ export default tseslint.config( ], }, }, + // --------------------------------------------------------------------------- + // Processing feature layering boundaries. + // + // Enforced with the built-in `no-restricted-imports` — eslint-plugin-import is + // not a dependency of this repo, and the built-in rule expresses the same + // zones with no new dependency. Two rules: + // 1. Code OUTSIDE `src/processing/` may reach the feature ONLY through its + // public surface `@/src/processing` (the index), never a deep path. + // 2. The feature's pure layer (`engine/**`, `types.ts`, `config.ts`) may not + // import stores, components, or the upper feature modules, and stays + // framework-free (no pinia, no vue) — dependencies point downward only. + // --------------------------------------------------------------------------- + { + files: ['src/**/*.{js,ts,vue}'], + ignores: ['src/processing/**'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@/src/processing/*', + '@/src/processing/*/**', + '!@/src/processing/index', + ], + message: + 'Import the processing feature only from its public surface `@/src/processing` (src/processing/index.ts). A deep import bypasses the feature boundary.', + }, + ], + }, + ], + }, + }, + { + files: [ + 'src/processing/engine/**/*.{js,ts}', + 'src/processing/types.ts', + 'src/processing/config.ts', + ], + ignores: ['**/__tests__/**'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'pinia', + message: + 'The processing pure layer (engine/types/config) must stay framework-free — no pinia.', + }, + { + name: 'vue', + message: + 'The processing pure layer (engine/types/config) must stay framework-free — no vue.', + }, + ], + patterns: [ + { + group: [ + '@/src/processing/store', + '@/src/processing/applyResults', + '@/src/processing/jobResultReview', + '@/src/processing/index', + '@/src/processing/components/**', + '@/src/store/**', + '@/src/components/**', + './store', + './applyResults', + './jobResultReview', + './index', + '../store', + '../applyResults', + '../jobResultReview', + '../index', + '../components/**', + ], + message: + 'The processing pure layer (engine/types/config) must not import stores, components, or upper feature modules — dependencies point downward only.', + }, + ], + }, + ], + }, + }, eslintConfigPrettier ); diff --git a/package.json b/package.json index bd9fc4f66..a76661e56 100644 --- a/package.json +++ b/package.json @@ -6,13 +6,15 @@ "repository": "https://github.com/Kitware/VolView", "files": [ "dist", - "src" + "src", + "backend-contract" ], "scripts": { "dev": "cross-env VITE_SHOW_SAMPLE_DATA=true vite", "preview": "vite preview", "serve": "npm run dev", "prebuild": "patch-package", + "contract:generate": "tsx backend-contract/scripts/generate-json-schema.ts && tsx backend-contract/scripts/generate-openapi.ts", "build": "vue-tsc --noEmit && vite build", "build:analyze": "cross-env ANALYZE_BUNDLE=1 npm run build", "test": "vitest", diff --git a/src/actions/__tests__/loadDataSourcesNotices.spec.ts b/src/actions/__tests__/loadDataSourcesNotices.spec.ts new file mode 100644 index 000000000..764da6633 --- /dev/null +++ b/src/actions/__tests__/loadDataSourcesNotices.spec.ts @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; + +import { + loadDataSources, + loadUrlsWithOutcome, +} from '@/src/actions/loadUserFiles'; +import useLoadDataStore from '@/src/store/load-data'; +import type { DataSource } from '@/src/io/import/dataSource'; +import { asErrorResult, asOkayResult } from '@/src/io/import/common'; + +// --------------------------------------------------------------------------- +// ONE consolidated notice for degraded composed opens: importDataSources owns +// reporting for failures it has already surfaced (e.g. a failed state-file +// leaf, counted by completeStateFileRestore's consolidated "Some scene +// content could not be restored" warning) and returns them as 'ok' results — +// so the generic error-styled "Some files failed to load" fires exactly for +// the error results loadDataSources receives, no more and no less. +// --------------------------------------------------------------------------- + +const mocks = vi.hoisted(() => ({ + importDataSources: vi.fn(), +})); + +vi.mock('@/src/io/import/importDataSources', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, importDataSources: mocks.importDataSources }; +}); + +// What importDataSources returns for a failure it already surfaced itself. +const coveredFailure = (source: DataSource) => asOkayResult(source); + +const composedLeaf = (stateID: string): DataSource => ({ + type: 'uri', + uri: `https://girder.example/file/${stateID}`, + name: `${stateID}.nrrd`, + stateFileLeaf: { stateID }, +}); + +const standaloneSource = (): DataSource => ({ + type: 'uri', + uri: 'https://example.com/plain.nrrd', + name: 'plain.nrrd', +}); + +describe('loadDataSources — notice exclusivity for restore-covered failures', () => { + beforeEach(() => { + setActivePinia(createPinia()); + mocks.importDataSources.mockReset(); + }); + + it('a restore-covered failure does NOT raise the generic load error', async () => { + const leaf = composedLeaf('ds-a'); + mocks.importDataSources.mockResolvedValue([coveredFailure(leaf)]); + const spy = vi.spyOn(useLoadDataStore(), 'setError'); + + await loadDataSources([leaf]); + + expect(spy).not.toHaveBeenCalled(); + }); + + it('a returned error result still raises the generic load error', async () => { + const source = standaloneSource(); + mocks.importDataSources.mockResolvedValue([ + asErrorResult(new Error('boom'), source), + ]); + const spy = vi.spyOn(useLoadDataStore(), 'setError'); + + await loadDataSources([source]); + + expect(spy).toHaveBeenCalledTimes(1); + expect(String(spy.mock.calls[0][0])).toContain('plain.nrrd'); + }); + + it('a mixed result reports ONLY the error entries', async () => { + const leaf = composedLeaf('ds-a'); + const source = standaloneSource(); + mocks.importDataSources.mockResolvedValue([ + coveredFailure(leaf), + asErrorResult(new Error('boom'), source), + ]); + const spy = vi.spyOn(useLoadDataStore(), 'setError'); + + await loadDataSources([leaf, source]); + + expect(spy).toHaveBeenCalledTimes(1); + const message = String(spy.mock.calls[0][0]); + expect(message).toContain('plain.nrrd'); + expect(message).not.toContain('ds-a.nrrd'); + }); + + it('distinguishes a successful zero-dataset restore from an uncovered error', async () => { + const leaf = composedLeaf('ds-a'); + mocks.importDataSources.mockResolvedValueOnce([coveredFailure(leaf)]); + + await expect( + loadUrlsWithOutcome({ + urls: ['https://example.com/session.volview.json'], + }) + ).resolves.toEqual({ datasetIds: [], hadErrors: false }); + + mocks.importDataSources.mockResolvedValueOnce([ + asErrorResult(new Error('not found'), standaloneSource()), + ]); + await expect( + loadUrlsWithOutcome({ + urls: ['https://example.com/missing.volview.json'], + }) + ).resolves.toEqual({ datasetIds: [], hadErrors: true }); + }); +}); diff --git a/src/actions/loadUserFiles.ts b/src/actions/loadUserFiles.ts index 0c283475c..eef438ff4 100644 --- a/src/actions/loadUserFiles.ts +++ b/src/actions/loadUserFiles.ts @@ -15,6 +15,7 @@ import { parseUrl } from '@/src/utils/url'; import { logError } from '@/src/utils/loggers'; import { importDataSources, + importVolumeDataSources, toDataSelection, } from '@/src/io/import/importDataSources'; import { @@ -265,20 +266,33 @@ function loadSegmentations( }); } -function loadDataSources(sources: DataSource[]) { +type DataSourceImporter = ( + sources: DataSource[] +) => Promise; + +type LoadDataSourcesOutcome = { + datasetIds: string[]; + hadErrors: boolean; + completed: boolean; +}; + +function loadDataSourcesWithOutcome( + sources: DataSource[], + importer: DataSourceImporter +): Promise { const loadDataStore = useLoadDataStore(); const viewStore = useViewStore(); const load = async () => { let results: ImportDataSourcesResult[]; try { - results = (await importDataSources(sources)).filter((result) => + results = (await importer(sources)).filter((result) => // only look at data and error results ['data', 'error'].includes(result.type) ); } catch (error) { loadDataStore.setError(error as Error); - return; + return { datasetIds: [], hadErrors: true, completed: false }; } const [succeeded, errored] = partition( @@ -314,6 +328,10 @@ function loadDataSources(sources: DataSource[]) { } // else must be primaryDataSource.type === 'model', which are not dealt with here yet } + // Every error importDataSources returns is unreported by contract + // (failures it already surfaced itself — e.g. in the restore's + // consolidated notice — come back as 'ok' results), so all of them get + // the generic load error here. if (errored.length) { const errorMessages = (errored as ErrorResult[]).map((errResult) => { const { dataSource, error } = errResult; @@ -327,14 +345,23 @@ function loadDataSources(sources: DataSource[]) { loadDataStore.setError(failedError); } + return { + datasetIds: filterLoadableDataSources(succeeded).map( + (result) => result.dataID + ), + hadErrors: errored.length > 0, + completed: true, + }; }; - const wrapWithLoading = void>(fn: T) => { + const wrapWithLoading = ( + fn: (...args: Args) => Promise + ) => { const { startLoading, stopLoading } = useLoadDataStore(); - return async function wrapper(...args: any[]) { + return async function wrapper(...args: Args): Promise { try { startLoading(); - await fn(...args); + return await fn(...args); } finally { stopLoading(); } @@ -344,6 +371,18 @@ function loadDataSources(sources: DataSource[]) { return wrapWithLoading(load)(); } +export function loadDataSources(sources: DataSource[]) { + return loadDataSourcesWithOutcome(sources, importDataSources).then( + ({ datasetIds, completed }) => (completed ? datasetIds : undefined) + ); +} + +export function loadVolumeDataSources(sources: DataSource[]) { + return loadDataSourcesWithOutcome(sources, importVolumeDataSources).then( + ({ datasetIds, completed }) => (completed ? datasetIds : undefined) + ); +} + export function openFileDialog() { return new Promise((resolve) => { const fileEl = document.createElement('input'); @@ -383,16 +422,38 @@ type LoadUrlsParams = { }; export async function loadUrls(params: UrlParams | LoadUrlsParams) { + return (await loadUrlsWithOutcome(params)).datasetIds; +} + +export async function loadUrlsWithOutcome( + params: UrlParams | LoadUrlsParams +): Promise> { + const outcomes: LoadDataSourcesOutcome[] = []; if (params.config) { const configUrls = wrapInArray(params.config); - const configSources = urlsToDataSources(configUrls); - await loadDataSources(configSources); + const configSources = urlsToDataSources(configUrls, []); + outcomes.push( + await loadDataSourcesWithOutcome(configSources, importDataSources) + ); } if (params.urls) { const urls = wrapInArray(params.urls); const names = wrapInArray(params.names ?? []); const sources = urlsToDataSources(urls, names); - await loadDataSources(sources); + outcomes.push(await loadDataSourcesWithOutcome(sources, importDataSources)); } + return { + datasetIds: outcomes.flatMap(({ datasetIds }) => datasetIds), + hadErrors: outcomes.some(({ hadErrors }) => hadErrors), + }; +} + +export async function loadVolumeUrls( + params: Pick +) { + if (!params.urls) return []; + const urls = wrapInArray(params.urls); + const names = wrapInArray(params.names ?? []); + return (await loadVolumeDataSources(urlsToDataSources(urls, names))) ?? []; } diff --git a/src/components/App.vue b/src/components/App.vue index c3fb18303..e35673a5a 100644 --- a/src/components/App.vue +++ b/src/components/App.vue @@ -53,11 +53,13 @@ + + diff --git a/src/processing/components/JobsModule.vue b/src/processing/components/JobsModule.vue new file mode 100644 index 000000000..520bfd747 --- /dev/null +++ b/src/processing/components/JobsModule.vue @@ -0,0 +1,702 @@ + + + + + diff --git a/src/processing/components/TaskForm.vue b/src/processing/components/TaskForm.vue new file mode 100644 index 000000000..b8cb6cb04 --- /dev/null +++ b/src/processing/components/TaskForm.vue @@ -0,0 +1,184 @@ + + + + + diff --git a/src/processing/components/TaskPicker.vue b/src/processing/components/TaskPicker.vue new file mode 100644 index 000000000..5a6fd49bd --- /dev/null +++ b/src/processing/components/TaskPicker.vue @@ -0,0 +1,27 @@ + + + diff --git a/src/processing/components/__tests__/JobsModule.spec.ts b/src/processing/components/__tests__/JobsModule.spec.ts new file mode 100644 index 000000000..236a1d051 --- /dev/null +++ b/src/processing/components/__tests__/JobsModule.spec.ts @@ -0,0 +1,330 @@ +import { describe, it, beforeEach, expect, vi } from 'vitest'; +import { shallowMount, flushPromises } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; +import { createApp } from 'vue'; + +import { CorePiniaProviderPlugin } from '@/src/core/provider'; +import { defer, type Deferred } from '@/src/utils'; +import type { + ProcessingProvider, + ProcessingProviderConfig, + TaskSummary, +} from '@/src/processing/types'; +import type { TaskSpecEnvelope } from '@/src/processing/engine/taskSpec'; +import { + makeFakeProvider, + type FakeProvider, +} from '@/src/processing/__tests__/fakeProvider'; + +const registry = new Map(); +vi.mock('@/src/processing/engine/transport', () => ({ + createEngineTransport: (config: { id: string }) => registry.get(config.id), +})); + +import JobsModule from '@/src/processing/components/JobsModule.vue'; +import TaskPicker from '@/src/processing/components/TaskPicker.vue'; +import TaskForm from '@/src/processing/components/TaskForm.vue'; +import { useProcessingJobsStore } from '@/src/processing/store'; + +const cfg = (id: string): ProcessingProviderConfig => ({ + id, + label: id, + baseUrl: `http://${id}/`, + jobsBaseUrl: `http://${id}/jobs`, +}); + +const makeProvider = (id: string): FakeProvider => + makeFakeProvider(cfg(id), { + runTask: vi.fn().mockResolvedValue({ jobId: `${id}-1` }), + getResults: vi.fn().mockResolvedValue({ + resultState: 'ready', + results: [], + missing: 0, + }), + listJobHistory: vi.fn(), + }); + +// No parameters, so submit is never gated on a required input. +const envelope = (id: string, title: string): TaskSpecEnvelope => ({ + specVersion: 1, + id, + title, + parameters: [], + outputs: [], +}); + +const registerFake = ( + store: ReturnType, + provider: FakeProvider +) => { + registry.set(provider.config.id, provider as unknown as ProcessingProvider); + store.registerProviderConfig(provider.config); +}; + +type JobsVm = { + selectedProviderId: string | null; + tasks: TaskSummary[]; + taskModel: { id: string; title: string } | null; + providerError: string | null; + taskError: string | null; + loadingProvider: boolean; + loadingTask: boolean; +}; + +describe('JobsModule — race-free provider/task selection', () => { + let pinia: ReturnType; + + beforeEach(() => { + registry.clear(); + pinia = createPinia().use(CorePiniaProviderPlugin()); + // Core stores read injected tool singletons, which need an app to install onto. + createApp({}).use(pinia); + setActivePinia(pinia); + }); + + // Auto-stubs drop slot content, hiding the panel children. + const slotStub = { template: '
' }; + + const mount = () => + shallowMount(JobsModule, { + // v-select's auto-stub warns on getter-only props. + global: { + plugins: [pinia], + stubs: { + 'v-select': true, + 'v-expansion-panels': slotStub, + 'v-expansion-panel': slotStub, + 'v-expansion-panel-title': slotStub, + 'v-expansion-panel-text': slotStub, + }, + }, + }); + + it('commits only the winning provider’s tasks when the stale one resolves last', async () => { + const a = makeProvider('A'); + const b = makeProvider('B'); + const aTasks: TaskSummary[] = [{ id: 'a1', title: 'A task' }]; + const bTasks: TaskSummary[] = [{ id: 'b1', title: 'B task' }]; + const aTasksGate = defer(); + const bTasksGate = defer(); + a.listTasks = vi.fn().mockReturnValue(aTasksGate.promise); + b.listTasks = vi.fn().mockReturnValue(bTasksGate.promise); + b.getTaskSpec = vi.fn(() => Promise.resolve(envelope('b1', 'B task'))); + + const store = useProcessingJobsStore(); + registerFake(store, a); + registerFake(store, b); + + const wrapper = mount(); + await flushPromises(); + const vm = wrapper.vm as unknown as JobsVm; + expect(vm.selectedProviderId).toBe('A'); + + vm.selectedProviderId = 'B'; + await flushPromises(); + + bTasksGate.resolve(bTasks); + await flushPromises(); + aTasksGate.resolve(aTasks); + await flushPromises(); + + expect(vm.tasks).toEqual(bTasks); + expect(vm.tasks).not.toEqual(aTasks); + + const picker = wrapper.findComponent(TaskPicker); + expect(picker.exists()).toBe(true); + expect(picker.props('tasks')).toEqual(bTasks); + }); + + it('commits only the winning task spec and submits it when the stale spec resolves last', async () => { + const p = makeProvider('P'); + const tasks: TaskSummary[] = [ + { id: 'x', title: 'X' }, + { id: 'y', title: 'Y' }, + ]; + p.listTasks = vi.fn().mockResolvedValue(tasks); + const specGates: Record> = { + x: defer(), + y: defer(), + }; + p.getTaskSpec = vi.fn((id: string) => specGates[id].promise); + + const store = useProcessingJobsStore(); + registerFake(store, p); + + const wrapper = mount(); + await flushPromises(); + const vm = wrapper.vm as unknown as JobsVm; + + expect(vm.selectedProviderId).toBe('P'); + expect(p.getTaskSpec).toHaveBeenCalledWith('x'); + + wrapper.findComponent(TaskPicker).vm.$emit('update:modelValue', 'y'); + await flushPromises(); + expect(p.getTaskSpec).toHaveBeenCalledWith('y'); + + specGates.y.resolve(envelope('y', 'Task Y')); + await flushPromises(); + specGates.x.resolve(envelope('x', 'Task X')); + await flushPromises(); + + expect(vm.taskModel?.id).toBe('y'); + expect(vm.taskModel?.title).toBe('Task Y'); + + const submitSpy = vi.spyOn(store, 'submitJob').mockResolvedValue('job-1'); + wrapper.findComponent(TaskForm).vm.$emit('submit', { foo: 1 }); + await flushPromises(); + + expect(submitSpy).toHaveBeenCalledTimes(1); + const [providerId, taskId] = submitSpy.mock.calls[0]; + expect(providerId).toBe('P'); + expect(taskId).toBe('y'); + }); + + it('dispatches getTaskSpec exactly once per task pick (no double-dispatch)', async () => { + const p = makeProvider('P'); + const tasks: TaskSummary[] = [ + { id: 'y', title: 'Y' }, + { id: 'x', title: 'X' }, + ]; + p.listTasks = vi.fn().mockResolvedValue(tasks); + p.getTaskSpec = vi.fn((id: string) => + Promise.resolve(envelope(id, id.toUpperCase())) + ); + + const store = useProcessingJobsStore(); + registerFake(store, p); + + const wrapper = mount(); + await flushPromises(); + + expect(p.getTaskSpec).toHaveBeenCalledTimes(1); + expect(p.getTaskSpec).toHaveBeenCalledWith('y'); + p.getTaskSpec.mockClear(); + + wrapper.findComponent(TaskPicker).vm.$emit('update:modelValue', 'x'); + await flushPromises(); + + expect(p.getTaskSpec).toHaveBeenCalledTimes(1); + expect(p.getTaskSpec).toHaveBeenCalledWith('x'); + }); + + it('a stale provider generation that rejects cannot change current provider state', async () => { + const a = makeProvider('A'); + const b = makeProvider('B'); + const aTasksGate = defer(); + const bTasksGate = defer(); + a.listTasks = vi.fn().mockReturnValue(aTasksGate.promise); + b.listTasks = vi.fn().mockReturnValue(bTasksGate.promise); + b.getTaskSpec = vi.fn(() => Promise.resolve(envelope('b1', 'B task'))); + + const store = useProcessingJobsStore(); + registerFake(store, a); + registerFake(store, b); + + const wrapper = mount(); + await flushPromises(); + const vm = wrapper.vm as unknown as JobsVm; + + vm.selectedProviderId = 'B'; + await flushPromises(); + + aTasksGate.reject(new Error('A failed')); + await flushPromises(); + + expect(vm.providerError).toBeNull(); + expect(vm.loadingProvider).toBe(true); + + bTasksGate.resolve([{ id: 'b1', title: 'B task' }]); + await flushPromises(); + expect(vm.loadingProvider).toBe(false); + expect(vm.providerError).toBeNull(); + expect(vm.tasks).toEqual([{ id: 'b1', title: 'B task' }]); + }); + + it('a stale task generation that rejects cannot change current task state', async () => { + const p = makeProvider('P'); + const tasks: TaskSummary[] = [ + { id: 'x', title: 'X' }, + { id: 'y', title: 'Y' }, + ]; + p.listTasks = vi.fn().mockResolvedValue(tasks); + const specGates: Record> = { + x: defer(), + y: defer(), + }; + p.getTaskSpec = vi.fn((id: string) => specGates[id].promise); + + const store = useProcessingJobsStore(); + registerFake(store, p); + + const wrapper = mount(); + await flushPromises(); + const vm = wrapper.vm as unknown as JobsVm; + + wrapper.findComponent(TaskPicker).vm.$emit('update:modelValue', 'y'); + await flushPromises(); + expect(vm.loadingTask).toBe(true); + + specGates.x.reject(new Error('X spec failed')); + await flushPromises(); + + expect(vm.taskError).toBeNull(); + expect(vm.loadingTask).toBe(true); + + specGates.y.resolve(envelope('y', 'Task Y')); + await flushPromises(); + expect(vm.loadingTask).toBe(false); + expect(vm.taskError).toBeNull(); + expect(vm.taskModel?.id).toBe('y'); + }); + + it('retries provider task discovery after a load failure', async () => { + const p = makeProvider('P'); + p.listTasks = vi + .fn() + .mockRejectedValueOnce(new Error('provider unavailable')) + .mockResolvedValueOnce([{ id: 'x', title: 'X' }]); + p.getTaskSpec = vi.fn().mockResolvedValue(envelope('x', 'X')); + + const store = useProcessingJobsStore(); + registerFake(store, p); + const wrapper = mount(); + await flushPromises(); + + const vm = wrapper.vm as unknown as JobsVm; + expect(vm.providerError).toContain('provider unavailable'); + + await wrapper.get('[data-testid="retry-provider"]').trigger('click'); + await flushPromises(); + + expect(p.listTasks).toHaveBeenCalledTimes(2); + expect(vm.providerError).toBeNull(); + expect(vm.tasks).toEqual([{ id: 'x', title: 'X' }]); + }); + + it('retries a failed task spec without reloading the provider', async () => { + const p = makeProvider('P'); + p.listTasks = vi.fn().mockResolvedValue([{ id: 'x', title: 'X' }]); + p.getTaskSpec = vi + .fn() + .mockRejectedValueOnce(new Error('spec unavailable')) + .mockResolvedValueOnce(envelope('x', 'X')); + + const store = useProcessingJobsStore(); + registerFake(store, p); + const wrapper = mount(); + await flushPromises(); + + const vm = wrapper.vm as unknown as JobsVm; + expect(vm.taskError).toContain('spec unavailable'); + + await wrapper.get('[data-testid="retry-task"]').trigger('click'); + await flushPromises(); + + expect(p.listTasks).toHaveBeenCalledTimes(1); + expect(p.getTaskSpec).toHaveBeenCalledTimes(2); + expect(vm.taskError).toBeNull(); + expect(vm.taskModel?.id).toBe('x'); + }); +}); diff --git a/src/processing/components/widgets/BooleanWidget.vue b/src/processing/components/widgets/BooleanWidget.vue new file mode 100644 index 000000000..d54decc48 --- /dev/null +++ b/src/processing/components/widgets/BooleanWidget.vue @@ -0,0 +1,26 @@ + + + diff --git a/src/processing/components/widgets/EnumerationWidget.vue b/src/processing/components/widgets/EnumerationWidget.vue new file mode 100644 index 000000000..c57051e20 --- /dev/null +++ b/src/processing/components/widgets/EnumerationWidget.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/processing/components/widgets/FileWidget.vue b/src/processing/components/widgets/FileWidget.vue new file mode 100644 index 000000000..5f60ae918 --- /dev/null +++ b/src/processing/components/widgets/FileWidget.vue @@ -0,0 +1,74 @@ + + + + + diff --git a/src/processing/components/widgets/NumberWidget.vue b/src/processing/components/widgets/NumberWidget.vue new file mode 100644 index 000000000..86fda1fb5 --- /dev/null +++ b/src/processing/components/widgets/NumberWidget.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/src/processing/components/widgets/StringWidget.vue b/src/processing/components/widgets/StringWidget.vue new file mode 100644 index 000000000..98ca17975 --- /dev/null +++ b/src/processing/components/widgets/StringWidget.vue @@ -0,0 +1,25 @@ + + + diff --git a/src/processing/components/widgets/__tests__/NumberWidget.spec.ts b/src/processing/components/widgets/__tests__/NumberWidget.spec.ts new file mode 100644 index 000000000..86f076d79 --- /dev/null +++ b/src/processing/components/widgets/__tests__/NumberWidget.spec.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { defineComponent } from 'vue'; +import { shallowMount } from '@vue/test-utils'; + +import NumberWidget from '@/src/processing/components/widgets/NumberWidget.vue'; +import type { VolViewTaskParameter } from '@/backend-contract'; + +const TextFieldStub = defineComponent({ + name: 'VTextField', + props: ['modelValue', 'errorMessages'], + emits: ['update:modelValue'], + template: '', +}); + +const mountWidget = ( + param: Partial, + modelValue: number | null = null +) => + shallowMount(NumberWidget, { + props: { + param: { id: 'p', ...param } as VolViewTaskParameter, + modelValue, + }, + global: { stubs: { VTextField: TextFieldStub } }, + }); + +const type = async (wrapper: ReturnType, text: string) => { + await wrapper + .findComponent(TextFieldStub) + .vm.$emit('update:modelValue', text); +}; + +const lastEmitted = (wrapper: ReturnType) => + wrapper.emitted('update:modelValue')!.at(-1)![0]; + +const fieldError = (wrapper: ReturnType) => + wrapper.findComponent(TextFieldStub).props('errorMessages'); + +describe('NumberWidget input parsing', () => { + it('parses a float param with full precision', async () => { + const w = mountWidget({ kind: 'float' }); + await type(w, '1.9'); + expect(lastEmitted(w)).toBe(1.9); + expect(fieldError(w)).toEqual([]); + }); + + it('rejects fractional input on an int param instead of truncating', async () => { + const w = mountWidget({ kind: 'int' }); + await type(w, '1.9'); + expect(lastEmitted(w)).toBeNull(); + expect(fieldError(w)).toBe('Enter a whole number'); + }); + + it('accepts integral input on an int param', async () => { + const w = mountWidget({ kind: 'int' }); + await type(w, '2'); + expect(lastEmitted(w)).toBe(2); + expect(fieldError(w)).toEqual([]); + }); + + it('rejects off-step input on an int param with min+step', async () => { + const w = mountWidget({ kind: 'int', min: 1, step: 2 }); + await type(w, '4'); + expect(lastEmitted(w)).toBeNull(); + expect(fieldError(w)).toBe('Enter a value in steps of 2 from 1'); + await type(w, '3'); + expect(lastEmitted(w)).toBe(3); + expect(fieldError(w)).toEqual([]); + }); + + it('enforces step on a float param without rejecting precise decimals', async () => { + const w = mountWidget({ kind: 'float', min: 0.1, step: 0.2 }); + await type(w, '0.4'); + expect(lastEmitted(w)).toBeNull(); + expect(fieldError(w)).toBe('Enter a value in steps of 0.2 from 0.1'); + + await type(w, '0.3'); + expect(lastEmitted(w)).toBe(0.3); + expect(fieldError(w)).toEqual([]); + }); + + it('rejects non-numeric input', async () => { + const w = mountWidget({ kind: 'int' }); + await type(w, 'abc'); + expect(lastEmitted(w)).toBeNull(); + expect(fieldError(w)).toBe('Enter a number'); + }); + + it('clearing the field emits null with no error', async () => { + const w = mountWidget({ kind: 'int' }, 3); + await type(w, ''); + expect(lastEmitted(w)).toBeNull(); + expect(fieldError(w)).toEqual([]); + }); + + it('keeps the rejected text visible instead of clobbering it', async () => { + const w = mountWidget({ kind: 'int' }, 1); + await type(w, '1.9'); + await w.setProps({ modelValue: null }); + expect(w.findComponent(TextFieldStub).props('modelValue')).toBe('1.9'); + }); +}); diff --git a/src/processing/config.ts b/src/processing/config.ts new file mode 100644 index 000000000..a105c963a --- /dev/null +++ b/src/processing/config.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +import { isOriginAllowed, resolveOrigin } from '@/src/io/originGate'; +import type { ProcessingProviderConfig } from '@/src/processing/types'; + +// Non-strict so a version-skewed backend emitting unknown keys still registers. +// `satisfies` keeps the schema and the hand-written type from drifting. +const processingProviderConfig = z.object({ + id: z.string(), + label: z.string(), + baseUrl: z.string(), + // No baseUrl fallback in the transport, so a missing value mis-routes job calls. + jobsBaseUrl: z.string(), +}) satisfies z.ZodType; + +// The `processing` top-level config section, registered with the config-section +// registry from the feature entry point (see ./index.ts). +export const processingSection = z + .object({ + providers: z.array(processingProviderConfig).default([]), + }) + .optional(); + +export type ProcessingSection = z.output; + +const isProviderOriginAllowed = (config: ProcessingProviderConfig): boolean => { + // Both URLs carry the bearer token, so an ungated one would leak it off-origin. + const targets = [config.baseUrl, config.jobsBaseUrl]; + + const invalid = targets.find((url) => resolveOrigin(url) === null); + if (invalid !== undefined) { + console.warn( + `Skipping processing provider "${config.id}" because baseUrl is invalid: ${invalid}` + ); + return false; + } + + const rejected = targets.find((url) => !isOriginAllowed(url)); + if (rejected === undefined) return true; + + console.warn( + `Skipping processing provider "${config.id}" because origin "${resolveOrigin( + rejected + )}" is not allowed` + ); + return false; +}; + +export const selectAllowedProviders = ( + section: ProcessingSection +): ProcessingProviderConfig[] => { + const providersConfig = section?.providers; + if (!providersConfig?.length) return []; + return providersConfig.filter(isProviderOriginAllowed); +}; diff --git a/src/processing/engine/__tests__/bounds.spec.ts b/src/processing/engine/__tests__/bounds.spec.ts new file mode 100644 index 000000000..d442e654f --- /dev/null +++ b/src/processing/engine/__tests__/bounds.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { mat4 } from 'gl-matrix'; + +import { cropPlanesToWorldBounds } from '../bounds'; +import type { LPSCroppingPlanes } from '@/src/types/crop'; +import type { LPSDirections } from '@/src/types/lps'; + +// Only the LPS-axis→column mapping is read, so a partial cast suffices. +const dirs = ( + sagittal: 0 | 1 | 2, + coronal: 0 | 1 | 2, + axial: 0 | 1 | 2 +): LPSDirections => + ({ Sagittal: sagittal, Coronal: coronal, Axial: axial }) as LPSDirections; + +const planes: LPSCroppingPlanes = { + Sagittal: [1, 5], + Coronal: [2, 6], + Axial: [3, 7], +}; + +describe('cropPlanesToWorldBounds', () => { + it('maps index-space crop planes to a world LPS box under an identity transform', () => { + const bounds = cropPlanesToWorldBounds( + planes, + mat4.create(), + dirs(0, 1, 2) + ); + expect(bounds).toEqual([1, 5, 2, 6, 3, 7]); + }); + + it('applies the image indexToWorld (scale + translation)', () => { + // prettier-ignore + const indexToWorld = mat4.fromValues( + 2, 0, 0, 0, + 0, 3, 0, 0, + 0, 0, 4, 0, + 10, 20, 30, 1 + ); + const bounds = cropPlanesToWorldBounds(planes, indexToWorld, dirs(0, 1, 2)); + expect(bounds).toEqual([12, 20, 26, 38, 42, 58]); + }); + + it('honors a permuted LPS-axis→column mapping (oriented volume)', () => { + const bounds = cropPlanesToWorldBounds( + planes, + mat4.create(), + dirs(2, 0, 1) + ); + expect(bounds).toEqual([2, 6, 3, 7, 1, 5]); + }); +}); diff --git a/src/processing/engine/__tests__/formModel.spec.ts b/src/processing/engine/__tests__/formModel.spec.ts new file mode 100644 index 000000000..369b6ce31 --- /dev/null +++ b/src/processing/engine/__tests__/formModel.spec.ts @@ -0,0 +1,223 @@ +import { describe, it, expect } from 'vitest'; + +import { parseTaskSpecEnvelope } from '../taskSpec'; +import { + buildTaskFormModel, + initialFormValues, + validateFormValues, +} from '../formModel'; +import { + loadFixture, + loadFixtureDir, +} from '@/backend-contract/processing/__tests__/loadFixtures'; + +const KNOWN_KINDS = [ + 'int', + 'float', + 'string', + 'bool', + 'enum', + 'sourceRef', + 'bounds', +]; + +describe('form model renders every golden task-spec fixture', () => { + it('rejects task specs from unsupported contract versions', () => { + const raw = loadFixture('task-spec/synthetic-all-kinds.json') as Record< + string, + unknown + >; + + expect(() => parseTaskSpecEnvelope({ ...raw, specVersion: 2 })).toThrow(); + }); + + const fixtures = loadFixtureDir('task-spec'); + + it('loads the golden fixtures', () => { + expect(fixtures.map((f) => f.name).sort()).toEqual([ + 'synthetic-all-kinds', + 'synthetic-bounds-enum', + ]); + }); + + it.each(fixtures.map((f) => [f.name, f.data] as const))( + 'builds a fully renderable model for %s (nothing hidden)', + (_name, data) => { + const model = buildTaskFormModel(parseTaskSpecEnvelope(data)); + expect(model.hidden).toHaveLength(0); + expect(model.fields.length).toBeGreaterThan(0); + model.fields.forEach((f) => expect(KNOWN_KINDS).toContain(f.kind)); + } + ); + + it('renders the sourceRef + bounds + enum fields of the synthetic fixture, order-sorted', () => { + const model = buildTaskFormModel( + parseTaskSpecEnvelope(loadFixture('task-spec/synthetic-bounds-enum.json')) + ); + const kinds = model.fields.map((f) => f.kind); + expect(kinds).toContain('sourceRef'); + expect(kinds).toContain('bounds'); + expect(kinds).toContain('enum'); + expect(model.fields.map((f) => f.id)).toEqual([ + 'inputVolume', + 'roi', + 'method', + 'iterations', + ]); + }); + + it('seeds initial values from typed spec defaults; leaves sourceRef unbound', () => { + const model = buildTaskFormModel( + parseTaskSpecEnvelope(loadFixture('task-spec/synthetic-all-kinds.json')) + ); + const values = initialFormValues(model); + expect(values.radius).toBe(1); + expect(values.inputVolume).toBeNull(); + }); + + it('seeds an enum default (and falls back to the first option)', () => { + const model = buildTaskFormModel( + parseTaskSpecEnvelope(loadFixture('task-spec/synthetic-bounds-enum.json')) + ); + expect(initialFormValues(model).method).toBe('otsu'); + }); + + it('seeds a slider-rendered float (min+max, no default) at min so display matches submission', () => { + // The slider widget renders `modelValue ?? min`; seeding null would show + // `min` while submitting null — a value the user never saw. + const model = buildTaskFormModel( + parseTaskSpecEnvelope({ + specVersion: 1, + id: 'task', + title: 'Task', + parameters: [ + { id: 'sigma', kind: 'float', title: 'Sigma', min: 2, max: 10 }, + ], + outputs: [], + }) + ); + expect(initialFormValues(model).sigma).toBe(2); + }); + + it('seeds a NUMERIC enum default with its number type intact', () => { + const model = buildTaskFormModel( + parseTaskSpecEnvelope(loadFixture('task-spec/synthetic-bounds-enum.json')) + ); + // Stringifying the default would submit a type the CLI rejects. + expect(initialFormValues(model).iterations).toBe(2); + }); +}); + +describe('fail closed on an unknown or invalid parameter', () => { + it('hides an unknown field kind but keeps the valid params (negative fixture)', () => { + const model = buildTaskFormModel( + parseTaskSpecEnvelope(loadFixture('negative/unknown-field-kind.json')) + ); + expect(model.hidden.map((h) => h.id)).toEqual(['tint']); + expect(model.fields.map((f) => f.id)).toEqual(['inputVolume']); + }); + + it('still allows submit when the hidden param was NOT required', () => { + const model = buildTaskFormModel( + parseTaskSpecEnvelope(loadFixture('negative/unknown-field-kind.json')) + ); + const issues = validateFormValues(model, { inputVolume: 'uri:1' }); + expect(issues.some((i) => i.parameter === 'tint')).toBe(false); + expect(issues).toHaveLength(0); + }); + + it('refuses submit when a REQUIRED param had to be hidden', () => { + const model = buildTaskFormModel( + parseTaskSpecEnvelope({ + specVersion: 1, + id: 'x', + title: 'X', + parameters: [{ kind: 'color', id: 'tint', required: true }], + outputs: [], + }) + ); + expect(model.fields).toHaveLength(0); + expect(model.hidden).toEqual([ + { id: 'tint', required: true, reason: expect.any(String) }, + ]); + const issues = validateFormValues(model, {}); + expect(issues.map((i) => i.parameter)).toContain('tint'); + }); + + it('hides a param whose constraints are self-inconsistent (default above max)', () => { + const model = buildTaskFormModel( + parseTaskSpecEnvelope(loadFixture('negative/constraint-violation.json')) + ); + expect(model.hidden.map((h) => h.id)).toEqual(['radius']); + expect(model.fields.map((f) => f.id)).toEqual(['inputVolume']); + }); +}); + +describe('value validation gates submit', () => { + const model = () => + buildTaskFormModel( + parseTaskSpecEnvelope(loadFixture('task-spec/synthetic-all-kinds.json')) + ); + + it('flags an unbound required input', () => { + const issues = validateFormValues(model(), { + inputVolume: null, + radius: 1, + }); + expect(issues.some((i) => i.parameter === 'inputVolume')).toBe(true); + }); + + it('enforces numeric max', () => { + const issues = validateFormValues(model(), { + inputVolume: 'uri:1', + radius: 99, + }); + expect(issues.some((i) => i.parameter === 'radius')).toBe(true); + }); + + it('enforces float steps for values set outside the number widget', () => { + const floatModel = buildTaskFormModel( + parseTaskSpecEnvelope({ + specVersion: 1, + id: 'x', + title: 'X', + parameters: [{ kind: 'float', id: 'threshold', min: 0.1, step: 0.2 }], + outputs: [], + }) + ); + + expect( + validateFormValues(floatModel, { threshold: 0.4 }).map( + (issue) => issue.parameter + ) + ).toContain('threshold'); + expect(validateFormValues(floatModel, { threshold: 0.3 })).toHaveLength(0); + }); + + it('passes a fully valid value set', () => { + const issues = validateFormValues(model(), { + inputVolume: 'uri:1', + radius: 5, + }); + expect(issues).toHaveLength(0); + }); + + it('accepts a required enum whose selected member is the empty string', () => { + // Enum membership includes '', so treating it as unfilled would block submit. + const enumModel = buildTaskFormModel( + parseTaskSpecEnvelope({ + specVersion: 1, + id: 'x', + title: 'X', + parameters: [ + { kind: 'enum', id: 'mode', required: true, options: ['', 'fast'] }, + ], + outputs: [], + }) + ); + expect(validateFormValues(enumModel, { mode: '' })).toHaveLength(0); + expect( + validateFormValues(enumModel, { mode: null }).map((i) => i.parameter) + ).toContain('mode'); + }); +}); diff --git a/src/processing/engine/__tests__/jobHistory.spec.ts b/src/processing/engine/__tests__/jobHistory.spec.ts new file mode 100644 index 000000000..8674ba801 --- /dev/null +++ b/src/processing/engine/__tests__/jobHistory.spec.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; + +import { + filterJobHistory, + selectJobHistoryRows, + type JobHistoryFilters, +} from '@/src/processing/engine/jobHistory'; +import type { JobHistorySummary } from '@/backend-contract'; +import { jobKey } from '@/src/processing/types'; +import type { + ProcessingJobStatus, + SubmittedJobContext, +} from '@/src/processing/types'; + +const summary = ( + overrides: Partial = {} +): JobHistorySummary => ({ + jobId: 'job-1', + taskId: 'threshold', + taskTitle: 'Threshold segmentation', + createdBy: { id: 'user-1', name: 'Ada Lovelace' }, + createdAt: '2026-06-01T12:00:00Z', + state: 'success', + resultState: 'ready', + outputSummary: { recorded: 2, missing: 0 }, + ...overrides, +}); + +const filter = ( + jobs: JobHistorySummary[], + overrides: Partial +) => filterJobHistory(jobs, overrides); + +describe('job history filters', () => { + const jobs = [ + summary(), + summary({ + jobId: 'job-2', + taskId: 'registration', + taskTitle: 'Rigid registration', + createdAt: '2026-05-01T12:00:00Z', + state: 'error', + resultState: 'unavailable', + outputSummary: { recorded: 1, missing: 1 }, + }), + ]; + + it('filters by status and task', () => { + expect(filter(jobs, { statuses: ['error'] }).map((j) => j.jobId)).toEqual([ + 'job-2', + ]); + expect(filter(jobs, { task: 'threshold' }).map((j) => j.jobId)).toEqual([ + 'job-1', + ]); + }); + + it('includes a matching current-session job after durable history completed', () => { + const key = jobKey({ providerId: 'p1', jobId: 'live-job' }); + const rows = filterJobHistory( + selectJobHistoryRows( + [{ ...summary(), providerId: 'p1' }], + new Map([ + [ + key, + { jobId: 'live-job', state: 'running', resultState: 'waiting' }, + ], + ]), + new Map([ + [ + key, + { + jobId: 'live-job', + taskId: 'live-threshold', + providerId: 'p1', + submittedAt: '2026-07-12T12:00:00Z', + display: { taskTitle: 'Live threshold', parameters: [] }, + }, + ], + ]) + ), + { statuses: ['running'], task: 'threshold' } + ); + expect(rows.map((row) => row.jobId)).toEqual(['live-job']); + expect(rows[0].providerId).toBe('p1'); + }); + + it('retains and renders a current-session error tail after union filtering', () => { + const key = jobKey({ providerId: 'p1', jobId: 'live-error' }); + const rows = filterJobHistory( + selectJobHistoryRows( + [{ ...summary(), providerId: 'p1' }], + new Map([ + [ + key, + { + jobId: 'live-error', + state: 'error', + resultState: 'unavailable', + errorTail: 'Concrete worker failure: invalid mask', + }, + ], + ]), + new Map([ + [ + key, + { + jobId: 'live-error', + taskId: 'live-threshold', + providerId: 'p1', + submittedAt: '2026-07-12T12:00:00Z', + display: { taskTitle: 'Live threshold', parameters: [] }, + }, + ], + ]) + ), + { statuses: ['error'] } + ); + + expect(rows).toHaveLength(1); + expect(rows[0].providerId).toBe('p1'); + expect(rows[0].errorTail).toBe('Concrete worker failure: invalid mask'); + }); + + it('clear is client-only and restores all fully loaded summaries', () => { + expect(filterJobHistory(jobs, {})).toEqual(jobs); + }); +}); diff --git a/src/processing/engine/__tests__/mintInput.spec.ts b/src/processing/engine/__tests__/mintInput.spec.ts new file mode 100644 index 000000000..ad2d70829 --- /dev/null +++ b/src/processing/engine/__tests__/mintInput.spec.ts @@ -0,0 +1,265 @@ +import { describe, it, expect } from 'vitest'; + +import type { DataSource } from '@/src/io/import/dataSource'; +import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; +import { + collectProvenanceUris, + deriveFormat, + mintInputValue, + bindImageInputs, + imageInputFields, +} from '../mintInput'; +import { buildTaskFormModel, type TaskFormModel } from '../formModel'; +import { parseTaskSpecEnvelope } from '../taskSpec'; +import { loadFixture } from '@/backend-contract/processing/__tests__/loadFixtures'; +import type { FormField } from '../formModel'; + +const uriSource = (uri: string): DataSource => ({ + type: 'uri', + uri, + name: uri.split('/').pop() ?? uri, +}); + +// `chunk` is never read by the mint, so a placeholder is cast in. +const dicomChunk = (uri: string): DataSource => + ({ + type: 'chunk', + chunk: undefined, + mime: FILE_EXT_TO_MIME.dcm, + parent: uriSource(uri), + }) as unknown as DataSource; + +const dicomVolume = (uris: string[]): DataSource => ({ + type: 'collection', + sources: uris.map(dicomChunk), +}); + +const localChunk = (): DataSource => + ({ + type: 'chunk', + chunk: undefined, + mime: FILE_EXT_TO_MIME.dcm, + parent: { type: 'file', file: new File([], 'local.dcm'), fileType: '' }, + }) as unknown as DataSource; + +const mixedProvenanceVolume = (uris: string[]): DataSource => ({ + type: 'collection', + sources: [...uris.map(dicomChunk), localChunk()], +}); + +const remoteFile = (uri: string, filename: string): DataSource => ({ + type: 'file', + file: new File([], filename), + fileType: '', + parent: uriSource(uri), +}); + +const localFile = (filename: string): DataSource => ({ + type: 'file', + file: new File([], filename), + fileType: '', +}); + +const archiveMember = (): DataSource => ({ + type: 'archive', + path: 'series/1.dcm', + parent: { type: 'file', file: new File([], 'bundle.zip'), fileType: '' }, +}); + +const stateFileVolume = (): DataSource => ({ + type: 'file', + file: new File([], 'restored.nrrd'), + fileType: '', + stateFileLeaf: { stateID: 'restored-1' }, +}); + +const imageParamModel = ( + overrides: Partial> = {} +): TaskFormModel => ({ + id: 'task', + title: 'Task', + fields: [ + { + kind: 'sourceRef', + id: 'inputVolume', + accepts: ['image'], + required: true, + ...overrides, + }, + ], + hidden: [], +}); + +describe('mintInputValue matches the input-value golden fixtures', () => { + it('mints a dicom-series image from a remote DICOM collection', () => { + const fixture = loadFixture('wire/input-value.dicom-series.json'); + const uris = (fixture as { uris: string[] }).uris; + const value = mintInputValue(dicomVolume(uris)); + expect(value).toEqual(fixture); + }); + + it('mints a single-file image from a remote file', () => { + const fixture = loadFixture('wire/input-value.single-file.json') as { + uris: string[]; + }; + const value = mintInputValue(remoteFile(fixture.uris[0], 'scan.nrrd')); + expect(value).toEqual(fixture); + }); +}); + +describe('collectProvenanceUris', () => { + it('returns the collection URIs verbatim, in slice order', () => { + const uris = ['z/9.dcm', 'a/1.dcm', 'm/5.dcm']; + expect(collectProvenanceUris(dicomVolume(uris))).toEqual(uris); + }); + + it('walks a file up its parent chain to the URI', () => { + expect( + collectProvenanceUris(remoteFile('/api/x/scan.nrrd', 'scan.nrrd')) + ).toEqual(['/api/x/scan.nrrd']); + }); + + it('yields nothing for volumes with no URI provenance', () => { + expect(collectProvenanceUris(localFile('local.nrrd'))).toEqual([]); + expect(collectProvenanceUris(archiveMember())).toEqual([]); + expect(collectProvenanceUris(stateFileVolume())).toEqual([]); + expect(collectProvenanceUris(undefined)).toEqual([]); + }); + + it('yields nothing for a mixed-provenance collection (no partial volume)', () => { + // Minting only the remote subset would process an incomplete volume. + expect( + collectProvenanceUris(mixedProvenanceVolume(['a/1.dcm', 'a/2.dcm'])) + ).toEqual([]); + }); +}); + +describe('deriveFormat (advisory)', () => { + it('labels a DICOM collection dicom-series', () => { + expect(deriveFormat(dicomVolume(['a/1.dcm']))).toBe('dicom-series'); + }); + + it('uses the trailing extension for a single file', () => { + expect(deriveFormat(remoteFile('/api/x/scan.nrrd', 'scan.nrrd'))).toBe( + 'nrrd' + ); + }); +}); + +describe('mintInputValue fails closed for no-provenance volumes', () => { + it.each([ + ['local file drop', localFile('local.nrrd')], + ['archive member', archiveMember()], + ['restored state file', stateFileVolume()], + ['mixed remote/local collection', mixedProvenanceVolume(['a/1.dcm'])], + ])('returns null for a %s', (_label, ds) => { + expect(mintInputValue(ds)).toBeNull(); + }); +}); + +describe('bindImageInputs auto-binds the active dataset', () => { + it('binds the sole image param to the active volume', () => { + const result = bindImageInputs( + imageParamModel(), + remoteFile('/api/x/scan.nrrd', 'scan.nrrd') + ); + expect(result.states.inputVolume).toBe('bound'); + expect(result.issues).toHaveLength(0); + expect(result.values.inputVolume).toEqual({ + type: 'image', + format: 'nrrd', + uris: ['/api/x/scan.nrrd'], + }); + }); + + it('fails closed (no-provenance) + refuses submit for a local-drop volume', () => { + const result = bindImageInputs(imageParamModel(), localFile('local.nrrd')); + expect(result.states.inputVolume).toBe('no-provenance'); + expect(result.values.inputVolume).toBeNull(); + expect(result.issues).toHaveLength(1); + expect(result.issues[0].parameter).toBe('inputVolume'); + expect(result.issues[0].message).toMatch(/not loaded from the server/i); + }); + + it('fails closed (no-provenance) for a mixed remote/local collection', () => { + const result = bindImageInputs( + imageParamModel(), + mixedProvenanceVolume(['a/1.dcm', 'a/2.dcm']) + ); + expect(result.states.inputVolume).toBe('no-provenance'); + expect(result.values.inputVolume).toBeNull(); + expect(result.issues).toHaveLength(1); + }); + + it('fails closed (ambiguous) when more than one image param is present', () => { + const model: TaskFormModel = { + id: 'task', + title: 'Task', + fields: [ + { kind: 'sourceRef', id: 'ct', accepts: ['image'], required: true }, + { kind: 'sourceRef', id: 'pet', accepts: ['image'], required: true }, + ], + hidden: [], + }; + const result = bindImageInputs( + model, + remoteFile('/api/x/scan.nrrd', 'scan.nrrd') + ); + expect(result.states.ct).toBe('ambiguous'); + expect(result.states.pet).toBe('ambiguous'); + expect(result.values).toEqual({ ct: null, pet: null }); + expect(result.issues).toHaveLength(1); + }); + + it('is a no-op when the task has no image input', () => { + const model: TaskFormModel = { + id: 'task', + title: 'Task', + fields: [{ kind: 'int', id: 'radius', default: 1 }], + hidden: [], + }; + expect( + bindImageInputs(model, remoteFile('/api/x/scan.nrrd', 'scan.nrrd')) + ).toEqual({ + values: {}, + states: {}, + issues: [], + }); + }); + + it('refuses submit for a required image input with no active dataset', () => { + const result = bindImageInputs(imageParamModel(), undefined); + expect(result.states.inputVolume).toBe('unbound'); + expect(result.issues).toHaveLength(1); + expect(result.issues[0].message).toMatch(/required/i); + }); + + it('does not block an OPTIONAL image input with no active dataset', () => { + const result = bindImageInputs( + imageParamModel({ required: false }), + undefined + ); + expect(result.states.inputVolume).toBe('unbound'); + expect(result.issues).toHaveLength(0); + }); +}); + +describe('bindImageInputs over a real task-spec fixture', () => { + it('finds and binds the image inputVolume from provenance', () => { + const model = buildTaskFormModel( + parseTaskSpecEnvelope(loadFixture('task-spec/synthetic-all-kinds.json')) + ); + expect(imageInputFields(model).map((f) => f.id)).toEqual(['inputVolume']); + + const uris = ( + loadFixture('wire/input-value.dicom-series.json') as { uris: string[] } + ).uris; + const result = bindImageInputs(model, dicomVolume(uris)); + expect(result.states.inputVolume).toBe('bound'); + expect(result.values.inputVolume).toEqual({ + type: 'image', + format: 'dicom-series', + uris, + }); + }); +}); diff --git a/src/processing/engine/__tests__/mintLabelmap.spec.ts b/src/processing/engine/__tests__/mintLabelmap.spec.ts new file mode 100644 index 000000000..1066395e5 --- /dev/null +++ b/src/processing/engine/__tests__/mintLabelmap.spec.ts @@ -0,0 +1,281 @@ +import { describe, it, expect } from 'vitest'; + +import type { DataSource } from '@/src/io/import/dataSource'; +import { + labelmapInputFields, + resolveLabelmapGroup, + bindLabelmapInputs, + mintLabelmapValue, + mintLabelmapReferenceImage, + type SegmentGroupView, +} from '../mintLabelmap'; +import { bindImageInputs } from '../mintInput'; +import type { TaskFormModel, FormField } from '../formModel'; + +const labelmapModel = ( + overrides: Partial> = {} +): TaskFormModel => ({ + id: 'task', + title: 'Task', + fields: [ + { + kind: 'sourceRef', + id: 'inputSeg', + accepts: ['labelmap'], + required: true, + ...overrides, + }, + ], + hidden: [], +}); + +const viewOf = (parentByGroup: Record): SegmentGroupView => { + const orderByParent: Record = {}; + Object.entries(parentByGroup).forEach(([groupId, parentImage]) => { + (orderByParent[parentImage] ??= []).push(groupId); + }); + return { + orderByParent, + metadataByID: Object.fromEntries( + Object.entries(parentByGroup).map(([groupId, parentImage]) => [ + groupId, + { parentImage }, + ]) + ), + }; +}; + +const localFile = (filename: string): DataSource => ({ + type: 'file', + file: new File([], filename), + fileType: '', +}); + +const remoteFile = (uri: string): DataSource => ({ + type: 'uri', + uri, + name: 'scan.nrrd', +}); + +describe('labelmapInputFields', () => { + it('selects sourceRef params that accept a labelmap', () => { + const model: TaskFormModel = { + id: 'task', + title: 'Task', + fields: [ + { kind: 'sourceRef', id: 'bg', accepts: ['image'], required: true }, + { kind: 'sourceRef', id: 'seg', accepts: ['labelmap'], required: true }, + { kind: 'int', id: 'radius', default: 1 }, + ], + hidden: [], + }; + expect(labelmapInputFields(model).map((f) => f.id)).toEqual(['seg']); + }); +}); + +describe('resolveLabelmapGroup — fallback chain', () => { + it('branch 1: the paint-active group (guard passes)', () => { + const view = viewOf({ g1: 'bg' }); + expect(resolveLabelmapGroup('bg', 'g1', view)).toEqual({ + kind: 'resolved', + groupId: 'g1', + }); + }); + + it('branch 1 wins over multiple groups: paint-active disambiguates', () => { + const view = viewOf({ g1: 'bg', g2: 'bg' }); + expect(resolveLabelmapGroup('bg', 'g2', view)).toEqual({ + kind: 'resolved', + groupId: 'g2', + }); + }); + + it("branch 2: the background's ONLY segment group when none is paint-active", () => { + const view = viewOf({ g1: 'bg' }); + expect(resolveLabelmapGroup('bg', null, view)).toEqual({ + kind: 'resolved', + groupId: 'g1', + }); + }); + + it('branch 3: fail closed when the background has no segment group', () => { + const view = viewOf({ gOther: 'other' }); + expect(resolveLabelmapGroup('bg', null, view)).toEqual({ + kind: 'unresolved', + }); + }); + + it('branch 3: multiple groups, none paint-active → fail closed (no v1 picker)', () => { + const view = viewOf({ g1: 'bg', g2: 'bg' }); + expect(resolveLabelmapGroup('bg', null, view)).toEqual({ + kind: 'unresolved', + }); + }); + + it('fails closed when there is no bound background', () => { + const view = viewOf({ g1: 'bg' }); + expect(resolveLabelmapGroup(undefined, 'g1', view)).toEqual({ + kind: 'unresolved', + }); + }); +}); + +describe('resolveLabelmapGroup — parentImage guard', () => { + it('rejects a paint-active group whose parentImage is not the background', () => { + const view = viewOf({ g1: 'other', g2: 'bg' }); + expect(resolveLabelmapGroup('bg', 'g1', view)).toEqual({ + kind: 'resolved', + groupId: 'g2', + }); + }); + + it('fails closed when the only paint-active group belongs to another image', () => { + const view = viewOf({ g1: 'other' }); + expect(resolveLabelmapGroup('bg', 'g1', view)).toEqual({ + kind: 'unresolved', + }); + }); + + it('branch 2 never crosses images: an only-group on another image is not used', () => { + const view = viewOf({ g1: 'other' }); + expect(resolveLabelmapGroup('bg', null, view)).toEqual({ + kind: 'unresolved', + }); + }); +}); + +describe('bindLabelmapInputs', () => { + it('is a no-op when the task has no labelmap input', () => { + const model: TaskFormModel = { + id: 'task', + title: 'Task', + fields: [{ kind: 'int', id: 'radius', default: 1 }], + hidden: [], + }; + expect(bindLabelmapInputs(model, 'bg', 'g1', viewOf({ g1: 'bg' }))).toEqual( + { + groups: {}, + states: {}, + issues: [], + } + ); + }); + + it('binds the sole labelmap param to the resolved group', () => { + const result = bindLabelmapInputs( + labelmapModel(), + 'bg', + 'g1', + viewOf({ g1: 'bg' }) + ); + expect(result.states.inputSeg).toBe('bound'); + expect(result.groups.inputSeg).toBe('g1'); + expect(result.issues).toHaveLength(0); + }); + + it('fails closed (no-segment-group) + refuses submit when unresolved', () => { + const result = bindLabelmapInputs(labelmapModel(), 'bg', null, viewOf({})); + expect(result.states.inputSeg).toBe('no-segment-group'); + expect(result.groups).toEqual({}); + expect(result.issues).toHaveLength(1); + expect(result.issues[0].parameter).toBe('inputSeg'); + expect(result.issues[0].message).toMatch( + /paint or select a segment group/i + ); + }); + + it('does not block an OPTIONAL labelmap input with no segment group', () => { + const result = bindLabelmapInputs( + labelmapModel({ required: false }), + 'bg', + null, + viewOf({}) + ); + expect(result.states.inputSeg).toBe('no-segment-group'); + expect(result.groups).toEqual({}); + expect(result.issues).toHaveLength(0); + }); + + it('fails closed (ambiguous) when more than one labelmap param is present', () => { + const model: TaskFormModel = { + id: 'task', + title: 'Task', + fields: [ + { + kind: 'sourceRef', + id: 'segA', + accepts: ['labelmap'], + required: true, + }, + { + kind: 'sourceRef', + id: 'segB', + accepts: ['labelmap'], + required: true, + }, + ], + hidden: [], + }; + const result = bindLabelmapInputs(model, 'bg', 'g1', viewOf({ g1: 'bg' })); + expect(result.states.segA).toBe('ambiguous'); + expect(result.states.segB).toBe('ambiguous'); + expect(result.groups).toEqual({}); + expect(result.issues).toHaveLength(1); + }); +}); + +describe('no-provenance background blocks the labelmap flow for free', () => { + it('keeps the image no-provenance issue even when the labelmap resolves', () => { + const model: TaskFormModel = { + id: 'task', + title: 'Task', + fields: [ + { kind: 'sourceRef', id: 'bg', accepts: ['image'], required: true }, + { kind: 'sourceRef', id: 'seg', accepts: ['labelmap'], required: true }, + ], + hidden: [], + }; + + const image = bindImageInputs(model, localFile('local.nrrd')); + const labelmap = bindLabelmapInputs( + model, + 'bg', + 'seg', + viewOf({ seg: 'bg' }) + ); + + expect(labelmap.states.seg).toBe('bound'); + expect(labelmap.issues).toHaveLength(0); + + const combined = [...image.issues, ...labelmap.issues]; + expect(combined).toHaveLength(1); + expect(combined[0].parameter).toBe('bg'); + expect(combined[0].message).toMatch(/not loaded from the server/i); + }); +}); + +describe('mintLabelmapValue', () => { + it('mints { type: "labelmap", uris } from the staging response (no format)', () => { + const uris = ['/api/v1/file/deadbeef/proxiable/seg.seg.nrrd']; + expect(mintLabelmapValue(uris)).toEqual({ type: 'labelmap', uris }); + }); +}); + +describe('mintLabelmapReferenceImage', () => { + it('copies the parent image opaque provenance for a labelmap-only task', () => { + const uri = '/api/v1/file/parent/proxiable/scan.nrrd'; + expect( + mintLabelmapReferenceImage('g1', viewOf({ g1: 'image-a' }), (imageId) => + imageId === 'image-a' ? remoteFile(uri) : undefined + ) + ).toEqual({ type: 'image', format: 'nrrd', uris: [uri] }); + }); + + it('fails closed when the parent image has no server provenance', () => { + expect( + mintLabelmapReferenceImage('g1', viewOf({ g1: 'image-a' }), () => + localFile('local.nrrd') + ) + ).toBeNull(); + }); +}); diff --git a/src/processing/engine/__tests__/resultDownload.spec.ts b/src/processing/engine/__tests__/resultDownload.spec.ts new file mode 100644 index 000000000..b21070bd1 --- /dev/null +++ b/src/processing/engine/__tests__/resultDownload.spec.ts @@ -0,0 +1,58 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deleteGlobalHeader, setGlobalHeader } from '@/src/utils/fetch'; +import { fetchProcessingResult } from '../resultDownload'; + +describe('fetchProcessingResult', () => { + const fetchSpy = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchSpy); + setGlobalHeader('Authorization', 'Bearer result-token'); + }); + + afterEach(() => { + deleteGlobalHeader('Authorization'); + vi.unstubAllGlobals(); + fetchSpy.mockReset(); + }); + + it('downloads through the authenticated fetch primitive', async () => { + fetchSpy.mockResolvedValue( + new Response(new Blob(['result bytes'], { type: 'application/test' }), { + status: 200, + }) + ); + + const file = await fetchProcessingResult({ + id: 'output', + name: 'output.bin', + url: 'https://results.example/output', + }); + + const [, init] = fetchSpy.mock.calls[0]; + expect(new Headers(init.headers).get('Authorization')).toBe( + 'Bearer result-token' + ); + expect(init).toMatchObject({ + credentials: 'same-origin', + }); + expect(init.redirect).toBeUndefined(); + expect(file.name).toBe('output.bin'); + expect(await file.text()).toBe('result bytes'); + }); + + it('rejects a failed result response instead of saving its error body', async () => { + fetchSpy.mockResolvedValue( + new Response('unauthorized', { status: 401, statusText: 'Unauthorized' }) + ); + + await expect( + fetchProcessingResult({ + id: 'output', + name: 'output.bin', + url: 'https://results.example/output', + }) + ).rejects.toThrow('401 Unauthorized'); + }); +}); diff --git a/src/processing/engine/__tests__/resultFiles.spec.ts b/src/processing/engine/__tests__/resultFiles.spec.ts new file mode 100644 index 000000000..838f992aa --- /dev/null +++ b/src/processing/engine/__tests__/resultFiles.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { + offersSceneLoad, + sceneApplicableResults, +} from '@/src/processing/engine/resultFiles'; +import type { ProcessingResult } from '@/src/processing/types'; + +const result = ( + overrides: Partial = {} +): ProcessingResult => ({ + id: 'result-1', + name: 'report.csv', + url: '/api/v1/file/deadbeefdeadbeefdeadbeef/proxiable/report.csv', + ...overrides, +}); + +const image = result({ id: 'result-2', intent: 'add-base-image' }); + +describe('sceneApplicableResults', () => { + it('keeps only the results carrying a known intent', () => { + expect(sceneApplicableResults([result(), image])).toEqual([image]); + }); + + it('drops a result whose intent name is known but shape is invalid', () => { + const malformed = result({ + intent: 'add-segment-group', + segments: [{ value: 0, name: 'bad', color: [0, 0, 0, 255] }], + }); + expect(sceneApplicableResults([malformed])).toEqual([]); + }); +}); + +describe('offersSceneLoad', () => { + it('offers Load while the result list is still unfetched', () => { + expect(offersSceneLoad(undefined)).toBe(true); + }); + + it('offers Load when at least one result applies to the scene', () => { + expect(offersSceneLoad([result(), image])).toBe(true); + }); + + it('withholds Load for a report-only job', () => { + expect(offersSceneLoad([result()])).toBe(false); + }); + + it('withholds Load for a job that produced nothing', () => { + expect(offersSceneLoad([])).toBe(false); + }); +}); diff --git a/src/processing/engine/__tests__/resultToIntent.spec.ts b/src/processing/engine/__tests__/resultToIntent.spec.ts new file mode 100644 index 000000000..8e7d21729 --- /dev/null +++ b/src/processing/engine/__tests__/resultToIntent.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; + +import { resultToIntent } from '@/src/processing/engine/resultToIntent'; +import type { ProcessingResult } from '@/src/processing/types'; + +const result = ( + overrides: Partial = {} +): ProcessingResult => ({ + id: 'result-1', + name: 'report.csv', + url: '/results/report.csv', + ...overrides, +}); + +describe('resultToIntent', () => { + it.each([ + ['missing', result()], + ['unknown', result({ intent: 'add-polygon' })], + [ + 'malformed', + result({ + intent: 'add-segment-group', + segments: [{ value: 0, name: 'bad', color: [0, 0, 0, 255] }], + }), + ], + ])('returns no state directive for a %s intent', (_name, value) => { + expect(resultToIntent(value)).toBeUndefined(); + }); + + it('returns a declared, shape-valid state directive (carrying the row id)', () => { + expect(resultToIntent(result({ intent: 'add-base-image' }))).toEqual({ + id: 'result-1', + intent: 'add-base-image', + name: 'report.csv', + url: '/results/report.csv', + }); + }); +}); diff --git a/src/processing/engine/__tests__/sourceRefs.spec.ts b/src/processing/engine/__tests__/sourceRefs.spec.ts new file mode 100644 index 000000000..999b77111 --- /dev/null +++ b/src/processing/engine/__tests__/sourceRefs.spec.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from 'vitest'; +import type { DataSource } from '@/src/io/import/dataSource'; +import type { TaskFormModel } from '../formModel'; +import { bindSourceRefs, type SourceRefBindingContext } from '../sourceRefs'; + +const remoteImage: DataSource = { + type: 'uri', + uri: '/data/image.nrrd', + name: 'image.nrrd', +}; + +const model = (fields: TaskFormModel['fields']): TaskFormModel => ({ + id: 'task', + title: 'Task', + fields, + hidden: [], +}); + +const context = ( + overrides: Partial = {} +): SourceRefBindingContext => ({ + activeDataSource: remoteImage, + backgroundImageId: 'image-1', + activeSegmentGroupId: null, + segmentGroups: { orderByParent: {}, metadataByID: {} }, + getDataSource: () => remoteImage, + ...overrides, +}); + +describe('bindSourceRefs', () => { + it('uses an image alternative when no labelmap is available', () => { + const bindings = bindSourceRefs( + model([ + { + kind: 'sourceRef', + id: 'input', + accepts: ['labelmap', 'image'], + required: true, + }, + ]), + context() + ); + + expect(bindings.types.input).toBe('image'); + expect(bindings.image.values.input).toMatchObject({ type: 'image' }); + expect(bindings.issues).toEqual([]); + }); + + it('honors accepted-type order when both alternatives are available', () => { + const bindings = bindSourceRefs( + model([ + { + kind: 'sourceRef', + id: 'input', + accepts: ['labelmap', 'image'], + required: true, + }, + ]), + context({ + activeSegmentGroupId: 'group-1', + segmentGroups: { + orderByParent: { 'image-1': ['group-1'] }, + metadataByID: { 'group-1': { parentImage: 'image-1' } }, + }, + }) + ); + + expect(bindings.types.input).toBe('labelmap'); + expect(bindings.labelmap.groups.input).toBe('group-1'); + expect(bindings.issues).toEqual([]); + }); + + it('uses the other type for a union alongside a dedicated input', () => { + const bindings = bindSourceRefs( + model([ + { + kind: 'sourceRef', + id: 'image', + accepts: ['image'], + required: true, + }, + { + kind: 'sourceRef', + id: 'either', + accepts: ['image', 'labelmap'], + required: true, + }, + ]), + context({ + activeSegmentGroupId: 'group-1', + segmentGroups: { + orderByParent: { 'image-1': ['group-1'] }, + metadataByID: { 'group-1': { parentImage: 'image-1' } }, + }, + }) + ); + + expect(bindings.types).toEqual({ image: 'image', either: 'labelmap' }); + expect(bindings.issues).toEqual([]); + }); + + it('falls back to image when a labelmap parent lacks provenance', () => { + const bindings = bindSourceRefs( + model([ + { + kind: 'sourceRef', + id: 'input', + accepts: ['labelmap', 'image'], + required: true, + }, + ]), + context({ + activeSegmentGroupId: 'group-1', + segmentGroups: { + orderByParent: { 'image-1': ['group-1'] }, + metadataByID: { 'group-1': { parentImage: 'image-1' } }, + }, + getDataSource: () => undefined, + }) + ); + + expect(bindings.types.input).toBe('image'); + expect(bindings.issues).toEqual([]); + }); + + it('walks image provenance only once', () => { + let sourceReads = 0; + const source = { + type: 'collection', + get sources() { + sourceReads += 1; + return [remoteImage]; + }, + } as DataSource; + + bindSourceRefs( + model([ + { + kind: 'sourceRef', + id: 'input', + accepts: ['image'], + required: true, + }, + ]), + context({ activeDataSource: source }) + ); + + // One mint reads the collection once for provenance and once for format. + expect(sourceReads).toBe(2); + }); + + it('mints the selected labelmap reference image only once', () => { + let dataSourceReads = 0; + + bindSourceRefs( + model([ + { + kind: 'sourceRef', + id: 'input', + accepts: ['labelmap'], + required: true, + }, + ]), + context({ + activeSegmentGroupId: 'group-1', + segmentGroups: { + orderByParent: { 'image-1': ['group-1'] }, + metadataByID: { 'group-1': { parentImage: 'image-1' } }, + }, + getDataSource: () => { + dataSourceReads += 1; + return remoteImage; + }, + }) + ); + + expect(dataSourceReads).toBe(1); + }); +}); diff --git a/src/processing/engine/__tests__/transport.spec.ts b/src/processing/engine/__tests__/transport.spec.ts new file mode 100644 index 000000000..03cc6be81 --- /dev/null +++ b/src/processing/engine/__tests__/transport.spec.ts @@ -0,0 +1,314 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +import { createEngineTransport } from '../transport'; +import type { HttpError } from '../transport'; +import type { ProcessingProviderConfig } from '@/src/processing/types'; +import { setGlobalHeader, deleteGlobalHeader } from '@/src/utils/fetch'; + +type Call = { url: string; init: RequestInit | undefined }; + +// Same-origin base URLs: the global bearer is only attached same-origin. +const BASE = `${window.location.origin}/api/v1/folder/f1/volview_processing`; +const JOBS_BASE = `${window.location.origin}/api/v1/volview_processing`; + +const config: ProcessingProviderConfig = { + id: 'p1', + label: 'Analysis — Folder', + baseUrl: BASE, + jobsBaseUrl: JOBS_BASE, +}; + +// Fixtures reach the real parsers, so every canned body must actually parse. +const FIXTURES = { + tasks: [{ id: 'task-1', title: 'Threshold', description: 'd' }], + spec: { specVersion: 1, id: 't', title: 'T', parameters: [], outputs: [] }, + run: { jobId: 'job-1' }, + status: { jobId: 'server-id', state: 'running', resultState: 'waiting' }, + results: { + resultState: 'ready', + intents: [ + { + id: 'r1', + name: 'out.nrrd', + url: `${window.location.origin}/out.nrrd`, + intent: 'add-base-image', + }, + ], + missing: 0, + }, + cancel: { + jobId: 'server-id', + state: 'cancelled', + resultState: 'unavailable', + }, + detail: { jobId: 'job-1', log: ['done\n'], parameters: { threshold: '3' } }, + stage: { uris: ['/api/v1/file/abc/proxiable/seg.seg.nrrd'] }, + jobs: { jobs: [], nextCursor: null }, +} as const; + +const fixtureFor = (url: string): unknown => { + const path = url.split('?')[0]; + if (path.endsWith('/spec')) return FIXTURES.spec; + if (path.endsWith('/run')) return FIXTURES.run; + if (path.endsWith('/results')) return FIXTURES.results; + if (path.endsWith('/cancel')) return FIXTURES.cancel; + if (path.endsWith('/detail')) return FIXTURES.detail; + if (path.endsWith('/stage')) return FIXTURES.stage; + if (path.endsWith('/tasks')) return FIXTURES.tasks; + if (path.endsWith('/jobs')) return FIXTURES.jobs; + if (/\/jobs\/[^/]+$/.test(path)) return FIXTURES.status; + return {}; +}; + +const stubFetch = (override?: (url: string) => unknown) => { + const calls: Call[] = []; + const stub = vi.fn(async (url: unknown, init?: RequestInit) => { + const u = String(url); + calls.push({ url: u, init }); + const json = override ? override(u) : fixtureFor(u); + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => json, + text: async () => JSON.stringify(json), + } as unknown as Response; + }); + vi.stubGlobal('fetch', stub); + return calls; +}; + +const authOf = (call: Call): string | null => + new Headers(call.init?.headers).get('Authorization'); + +const makeTransport = () => createEngineTransport(config); + +describe('one required transport — fixed routes + $fetch', () => { + beforeEach(() => { + setGlobalHeader('Authorization', 'Bearer test-token'); + }); + afterEach(() => { + deleteGlobalHeader('Authorization'); + vi.unstubAllGlobals(); + }); + + it('constructs every fixed route from the correct base URL', async () => { + const calls = stubFetch(); + const t = makeTransport(); + + await t.listTasks(); + await t.getTaskSpec('threshold'); + await t.runTask('threshold', { radius: 3 }); + await t.getJob('job-1'); + await t.getResults('job-1'); + await t.cancelJob('job-1'); + await t.deleteJob('job-1'); + await t.getJobHistoryDetail('job-1'); + await t.listJobHistory(); + + const urls = calls.map((c) => c.url); + expect(urls[0]).toBe(`${BASE}/tasks`); + expect(urls[1]).toBe(`${BASE}/tasks/threshold/spec`); + expect(urls[2]).toBe(`${BASE}/tasks/threshold/run`); + expect(urls[3]).toBe(`${JOBS_BASE}/jobs/job-1`); + expect(urls[4]).toBe(`${JOBS_BASE}/jobs/job-1/results`); + expect(urls[5]).toBe(`${JOBS_BASE}/jobs/job-1/cancel`); + expect(urls[6]).toBe(`${JOBS_BASE}/jobs/job-1`); + expect(urls[7]).toBe(`${JOBS_BASE}/jobs/job-1/detail`); + expect(urls[8]).toBe(`${BASE}/jobs`); + }); + + it('percent-encodes a job id containing a slash', async () => { + const calls = stubFetch(); + await makeTransport().getJob('a/b'); + expect(calls[0].url).toBe(`${JOBS_BASE}/jobs/a%2Fb`); + }); + + it.each(['.', '..'])( + 'rejects the dot-segment id %j before fetch', + async (id) => { + const calls = stubFetch(); + const transport = makeTransport(); + + await expect(transport.getTaskSpec(id)).rejects.toThrow( + 'id must not be a dot segment' + ); + await expect(transport.deleteJob(id)).rejects.toThrow( + 'id must not be a dot segment' + ); + expect(calls).toHaveLength(0); + } + ); + + it('carries the global bearer on every call (never raw fetch)', async () => { + const calls = stubFetch(); + const t = makeTransport(); + await t.getTaskSpec('t1'); + await t.runTask('t1', { radius: 3 }); + await t.getJob('j1'); + await t.getResults('j1'); + expect(calls.length).toBe(4); + calls.forEach((c) => { + expect(authOf(c)).toBe('Bearer test-token'); + expect(c.init?.redirect).toBeUndefined(); + }); + }); + + it('POSTs the run body as { values } JSON', async () => { + const calls = stubFetch(); + await makeTransport().runTask('t1', { radius: 3 }); + expect(calls[0].init?.method).toBe('POST'); + expect(calls[0].init?.body).toBe(JSON.stringify({ values: { radius: 3 } })); + }); + + it('DELETEs a job through the job-addressed route', async () => { + const calls = stubFetch(); + await makeTransport().deleteJob('job-1'); + expect(calls[0].url).toBe(`${JOBS_BASE}/jobs/job-1`); + expect(calls[0].init?.method).toBe('DELETE'); + }); + + it('parses tasks fail-soft: drops a malformed summary, keeps valid ones', async () => { + stubFetch(() => [{ id: 'ok', title: 'OK' }, { id: 'bad-no-title' }]); + const tasks = await makeTransport().listTasks(); + expect(tasks).toEqual([{ id: 'ok', title: 'OK' }]); + }); + + it('drops task summaries whose ids are dot segments', async () => { + stubFetch(() => [ + { id: '.', title: 'Current' }, + { id: '..', title: 'Parent' }, + { id: 'safe', title: 'Safe' }, + ]); + expect(await makeTransport().listTasks()).toEqual([ + { id: 'safe', title: 'Safe' }, + ]); + }); + + it('parses a non-array task payload as an empty list', async () => { + stubFetch(() => ({ not: 'an array' })); + expect(await makeTransport().listTasks()).toEqual([]); + }); + + it('parses the task spec envelope', async () => { + stubFetch(); + const spec = await makeTransport().getTaskSpec('t'); + expect(spec.title).toBe('T'); + }); + + it('parses the run response into a job ref', async () => { + stubFetch(); + expect(await makeTransport().runTask('t', {})).toEqual({ jobId: 'job-1' }); + }); + + it('parses job status, pinning the requested job id', async () => { + stubFetch(); + const status = await makeTransport().getJob('job-9'); + expect(status).toMatchObject({ + jobId: 'job-9', + state: 'running', + resultState: 'waiting', + }); + }); + + it('parses the results envelope into the neutral bundle', async () => { + stubFetch(); + const bundle = await makeTransport().getResults('job-1'); + expect(bundle.missing).toBe(0); + expect(bundle.results[0].id).toBe('r1'); + }); + + it('parses the projected status from a cancel', async () => { + stubFetch(); + const status = await makeTransport().cancelJob('job-1'); + expect(status).toMatchObject({ jobId: 'job-1', state: 'cancelled' }); + }); + + it('parses a job-history page and detail', async () => { + stubFetch(); + const t = makeTransport(); + expect(await t.listJobHistory()).toEqual({ jobs: [], nextCursor: null }); + const detail = await t.getJobHistoryDetail('job-1'); + expect(detail.log).toEqual(['done\n']); + }); + + it('appends an opaque cursor to the history query', async () => { + const calls = stubFetch(); + await makeTransport().listJobHistory('opaque cursor'); + expect(calls[0].url).toBe(`${BASE}/jobs?cursor=opaque%20cursor`); + }); + + const stubFetchNotOk = (status: number, body?: unknown) => { + const text = body === undefined ? '' : JSON.stringify(body); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + ({ + ok: false, + status, + statusText: 'ERR', + json: async () => body, + text: async () => text, + }) as unknown as Response + ) + ); + }; + + const rejectionOf = async (promise: Promise): Promise => { + try { + await promise; + } catch (err) { + return err as HttpError; + } + throw new Error('expected the operation to reject'); + }; + + it('throws an HttpError carrying .status on a non-2xx JSON route', async () => { + stubFetchNotOk(401); + const err = await rejectionOf(makeTransport().getJob('job-1')); + expect(err.status).toBe(401); + expect(err.message).toContain('401'); + }); + + it('carries .code and .body from a typed non-2xx error payload', async () => { + const payload = { code: 'results_unavailable', message: 'job errored' }; + stubFetchNotOk(404, payload); + const err = await rejectionOf(makeTransport().getResults('job-1')); + expect(err.status).toBe(404); + expect(err.code).toBe('results_unavailable'); + expect(err.body).toEqual(payload); + }); + + it('throws an HttpError carrying .status on a non-2xx empty route', async () => { + stubFetchNotOk(401); + const err = await rejectionOf(makeTransport().deleteJob('job-1')); + expect(err.status).toBe(401); + }); + + it('stages a labelmap multipart resource and returns the minted URIs', async () => { + const calls = stubFetch(); + const uris = await makeTransport().stageInput({ + file: new Blob(['bytes']), + descriptor: { + type: 'labelmap', + name: 'seg.seg.nrrd', + referenceImage: { + type: 'image', + uris: ['/api/v1/file/parent/proxiable/scan.nrrd'], + }, + }, + }); + expect(uris).toEqual(['/api/v1/file/abc/proxiable/seg.seg.nrrd']); + expect(calls[0].url).toBe(`${BASE}/stage`); + expect(calls[0].init?.method).toBe('POST'); + expect(authOf(calls[0])).toBe('Bearer test-token'); + const form = calls[0].init?.body as FormData; + expect(form).toBeInstanceOf(FormData); + expect(await (form.get('file') as Blob).text()).toBe('bytes'); + expect(JSON.parse(form.get('descriptor') as string)).toMatchObject({ + type: 'labelmap', + name: 'seg.seg.nrrd', + }); + }); +}); diff --git a/src/processing/engine/__tests__/wire.spec.ts b/src/processing/engine/__tests__/wire.spec.ts new file mode 100644 index 000000000..3d6f27c02 --- /dev/null +++ b/src/processing/engine/__tests__/wire.spec.ts @@ -0,0 +1,298 @@ +import { describe, expect, it } from 'vitest'; + +import { + parseJobHistoryPage, + parseJobRef, + parseJobStatus, + parseResults, +} from '@/src/processing/engine/wire'; +import type { + ProcessingJobStatus, + ProcessingResult, +} from '@/src/processing/types'; +import { loadFixture } from '@/backend-contract/processing/__tests__/loadFixtures'; + +describe('parseJobStatus', () => { + it('passes a valid status through byte-identically', () => { + const status: ProcessingJobStatus = { + jobId: 'job-1', + state: 'running', + resultState: 'waiting', + progress: 0.4, + }; + expect(parseJobStatus('job-1', status)).toEqual(status); + }); + + it('accepts every declared terminal/non-terminal state', () => { + ( + [ + ['pending', 'waiting'], + ['running', 'waiting'], + ['success', 'ready'], + ['error', 'unavailable'], + ['cancelled', 'unavailable'], + ] as const + ).forEach(([state, resultState]) => { + expect( + parseJobStatus('job-1', { jobId: 'job-1', state, resultState }).state + ).toBe(state); + }); + }); + + it('rejects an execution/result-state pairing the contract cannot produce', () => { + const result = parseJobStatus('job-1', { + jobId: 'job-1', + state: 'success', + resultState: 'waiting', + }); + expect(result.state).toBe('error'); + expect(result.resultState).toBe('unavailable'); + expect(result.errorTail).toContain('Malformed job status'); + }); + + it('preserves unknown wire keys on the happy path', () => { + const raw = { + jobId: 'job-1', + state: 'success', + resultState: 'ready', + extra: 'keep-me', + }; + expect(parseJobStatus('job-1', raw)).toMatchObject({ extra: 'keep-me' }); + }); + + it('converts an unknown state into a terminal error keyed to the requested job', () => { + const result = parseJobStatus('job-1', { + jobId: 'job-1', + state: 'who-knows', + }); + expect(result.jobId).toBe('job-1'); + expect(result.state).toBe('error'); + expect(result.errorTail).toContain('Malformed job status'); + }); + + it('converts a missing state into a terminal error', () => { + expect(parseJobStatus('job-1', { jobId: 'job-1' }).state).toBe('error'); + }); + + it('pins a valid status to the requested jobId, not the returned one', () => { + const status = parseJobStatus('job-1', { + jobId: 'something-else', + state: 'success', + resultState: 'ready', + }); + expect(status.jobId).toBe('job-1'); + expect(status.state).toBe('success'); + }); + + it('converts a non-object payload into a terminal error', () => { + expect(parseJobStatus('job-1', 'nonsense').state).toBe('error'); + expect(parseJobStatus('job-1', null).state).toBe('error'); + }); +}); + +describe('parseJobRef', () => { + it('parses a ref with no initial status (async/poll path)', () => { + expect(parseJobRef({ jobId: 'job-1' })).toEqual({ jobId: 'job-1' }); + }); + + it('treats an explicit status:null as absent (poll path), not a terminal error', () => { + expect(parseJobRef({ jobId: 'job-1', status: null })).toEqual({ + jobId: 'job-1', + }); + }); + + it('parses a born-terminal ref with a valid status', () => { + const status: ProcessingJobStatus = { + jobId: 'job-1', + state: 'success', + resultState: 'ready', + }; + expect(parseJobRef({ jobId: 'job-1', status })).toEqual({ + jobId: 'job-1', + status, + }); + }); + + it('turns a malformed born-terminal status into a terminal error, not an infinite poll', () => { + const ref = parseJobRef({ + jobId: 'job-1', + status: { jobId: 'job-1', state: 'bogus' }, + }); + expect(ref.jobId).toBe('job-1'); + expect(ref.status?.state).toBe('error'); + }); + + it.each([ + ['a missing job id', { status: { state: 'success' } }], + ['an empty job id', { jobId: '' }], + ['a current-directory job id', { jobId: '.' }], + ['a parent-directory job id', { jobId: '..' }], + ['a non-string job id', { jobId: 42 }], + ])('throws on a ref with %s', (_label, input) => { + expect(() => parseJobRef(input)).toThrow(/Malformed job ref/); + }); +}); + +describe('parseResults', () => { + const validItems: ProcessingResult[] = [ + { id: 'r1', name: 'out.nrrd', url: 'https://example/out.nrrd' }, + ]; + + it('parses the {intents, missing} envelope into {results, missing}', () => { + const { results, missing } = parseResults({ + resultState: 'incomplete', + intents: validItems, + missing: 2, + }); + expect(results).toEqual(validItems); + expect(missing).toBe(2); + }); + + it('rejects an envelope without required readiness and missing fields', () => { + expect(() => parseResults({ intents: validItems })).toThrow( + /Malformed job results/ + ); + }); + + it('preserves a segment-group result with descriptors and unknown keys', () => { + const intents = [ + { + id: 'r1', + name: 'seg.nrrd', + url: 'https://example/seg.nrrd', + intent: 'add-segment-group', + segments: [{ value: 1, name: 'liver', color: [255, 0, 0, 255] }], + extra: 'keep-me', + }, + ]; + expect( + parseResults({ resultState: 'ready', intents, missing: 0 }).results + ).toEqual(intents); + }); + + it('keeps an out-of-range segment descriptor as an ordinary result (no whole-list rejection)', () => { + const intents = [ + { + id: 'r1', + name: 'seg.nrrd', + url: 'https://example/seg.nrrd', + intent: 'add-segment-group', + segments: [{ value: 0, name: 'bg', color: [300, -5, 0, 255] }], + }, + ]; + expect( + parseResults({ resultState: 'ready', intents, missing: 0 }).results + ).toEqual(intents); + }); + + it('keeps a numeric/malformed intent as an ordinary result (never throws the list away)', () => { + const intents = [ + { + id: 'r1', + name: 'report.csv', + url: 'https://example/report.csv', + intent: 17, + }, + ]; + const { results } = parseResults({ + resultState: 'ready', + intents, + missing: 0, + }); + expect(results).toHaveLength(1); + expect(results[0]).toMatchObject({ id: 'r1', name: 'report.csv' }); + }); + + it.each([ + ['a missing id', { name: 'x', url: 'https://example/x' }], + ['an empty id', { id: '', name: 'x', url: 'https://example/x' }], + ])('throws when a result row has %s', (_name, item) => { + expect(() => + parseResults({ resultState: 'ready', intents: [item], missing: 0 }) + ).toThrow(/Malformed job results/); + }); + + it('tolerates null mimeType/size (the backend emits absent file fields as null)', () => { + const intents = [ + { + id: 'r1', + name: 'out.nrrd', + url: 'https://example/out.nrrd', + mimeType: null, + size: null, + }, + ]; + const { results } = parseResults({ + resultState: 'ready', + intents, + missing: 0, + }); + expect(results[0]).toMatchObject({ id: 'r1', name: 'out.nrrd' }); + expect(results[0].mimeType).toBeUndefined(); + expect(results[0].size).toBeUndefined(); + }); + + it('exercises the job-results.missing.json wire fixture', () => { + const fixture = loadFixture('wire/job-results.missing.json') as { + intents: Array>; + missing: number; + }; + expect(fixture.missing).toBe(2); + const enriched = { + ...fixture, + intents: fixture.intents.map((intent, n) => ({ + id: `out-${n}`, + ...intent, + })), + }; + const { results, missing } = parseResults(enriched); + expect(missing).toBe(2); + expect(results).toHaveLength(1); + expect(results[0].intent).toBe('add-base-image'); + }); + + it('throws on a bare list — the pre-envelope shape is no longer accepted', () => { + expect(() => parseResults(validItems)).toThrow(/Malformed job results/); + }); + + it('throws on a non-object payload', () => { + expect(() => parseResults({ id: 'r1' })).toThrow(/Malformed job results/); + }); + + it('throws when a result item is missing a required field', () => { + expect(() => + parseResults({ intents: [{ id: 'r1', name: 'out.nrrd' }] }) + ).toThrow(/Malformed job results/); + }); +}); + +describe('parseJobHistoryPage (durable job-history)', () => { + const summary = { + jobId: 'job-abc123', + taskId: 'OtsuSegmentation', + taskTitle: 'Otsu segmentation', + createdBy: { id: 'u1', name: 'User One' }, + createdAt: '2026-07-03T18:24:00Z', + finishedAt: '2026-07-03T18:24:05.123000+00:00', + state: 'success', + resultState: 'ready', + }; + + it('passes a valid JobHistoryPage through', () => { + const page = { jobs: [summary], nextCursor: 'opaque' }; + expect(parseJobHistoryPage(page)).toEqual(page); + expect(parseJobHistoryPage({ jobs: [], nextCursor: null })).toEqual({ + jobs: [], + nextCursor: null, + }); + }); + + it('throws on a non-page or malformed summary', () => { + expect(() => parseJobHistoryPage({ jobId: 'x' })).toThrow( + /Malformed job history page/ + ); + expect(() => + parseJobHistoryPage({ jobs: [{ jobId: 'j' }], nextCursor: null }) + ).toThrow(/Malformed job history page/); + }); +}); diff --git a/src/processing/engine/bounds.ts b/src/processing/engine/bounds.ts new file mode 100644 index 000000000..d371550d6 --- /dev/null +++ b/src/processing/engine/bounds.ts @@ -0,0 +1,28 @@ +import vtkBoundingBox from '@kitware/vtk.js/Common/DataModel/BoundingBox'; +import type { mat4 } from 'gl-matrix'; +import type { Bounds } from '@/backend-contract'; +import type { LPSDirections } from '@/src/types/lps'; + +export type ReadonlyCropPlanes = { + Sagittal: readonly [number, number]; + Coronal: readonly [number, number]; + Axial: readonly [number, number]; +}; + +// All eight corners are transformed so the result stays correct for oriented volumes. +export const cropPlanesToWorldBounds = ( + planes: ReadonlyCropPlanes, + indexToWorld: mat4, + lpsOrientation: LPSDirections +): Bounds => { + const indexBounds: Bounds = [0, 0, 0, 0, 0, 0]; + (['Sagittal', 'Coronal', 'Axial'] as const).forEach((axis) => { + const k = lpsOrientation[axis]; + [indexBounds[2 * k], indexBounds[2 * k + 1]] = planes[axis]; + }); + return vtkBoundingBox.transformBounds( + indexBounds, + indexToWorld, + [0, 0, 0, 0, 0, 0] + ) as Bounds; +}; diff --git a/src/processing/engine/formModel.ts b/src/processing/engine/formModel.ts new file mode 100644 index 000000000..cf74c0884 --- /dev/null +++ b/src/processing/engine/formModel.ts @@ -0,0 +1,178 @@ +import { taskParameterSchema } from '@/backend-contract'; +import type { VolViewTaskParameter } from '@/backend-contract'; +import { isRecord } from '@/src/utils'; +import type { ProcessingValue } from '@/src/processing/types'; +import type { TaskSpecEnvelope } from './taskSpec'; + +export type FormField = VolViewTaskParameter; + +export type HiddenField = { + id: string; + required: boolean; + reason: string; +}; + +export type TaskFormModel = { + id: string; + title: string; + description?: string; + fields: FormField[]; + hidden: HiddenField[]; +}; + +export type FormValidationIssue = { + parameter: string; + message: string; +}; + +const firstIssue = (error: { issues: { message: string }[] }): string => + error.issues[0]?.message ?? 'unsupported parameter'; + +export const fieldLabel = (f: FormField): string => f.title ?? f.id; + +// A min+max float renders as a slider showing `value ?? min`. Single source +// of the predicate so seeded values stay in sync with what the widget paints. +export const sliderConfig = (f: FormField) => + f.kind === 'float' && f.min != null && f.max != null + ? { min: f.min, max: f.max, step: f.step } + : null; + +export const isStepAligned = ( + value: number, + step: number, + base = 0 +): boolean => { + const offset = (value - base) / step; + const tolerance = Number.EPSILON * 16 * Math.max(1, Math.abs(offset)); + return Math.abs(offset - Math.round(offset)) <= tolerance; +}; + +const isEmpty = (v: ProcessingValue | undefined): boolean => + v === null || + v === undefined || + (typeof v === 'string' && v.length === 0) || + (Array.isArray(v) && v.length === 0); + +export const buildTaskFormModel = (env: TaskSpecEnvelope): TaskFormModel => { + const fields: FormField[] = []; + const hidden: HiddenField[] = []; + + env.parameters.forEach((raw) => { + const parsed = taskParameterSchema.safeParse(raw); + if (parsed.success) { + fields.push(parsed.data); + return; + } + const rec = isRecord(raw) ? raw : {}; + hidden.push({ + id: typeof rec.id === 'string' ? rec.id : '(unnamed)', + required: rec.required === true, + reason: firstIssue(parsed.error), + }); + }); + + // Array.prototype.sort is stable, so equal/missing orders keep spec order. + const orderOf = (f: FormField) => f.order ?? Number.POSITIVE_INFINITY; + const sorted = [...fields].sort((a, b) => { + const oa = orderOf(a); + const ob = orderOf(b); + return oa === ob ? 0 : oa - ob; + }); + + return { + id: env.id, + title: env.title, + description: env.description, + fields: sorted, + hidden, + }; +}; + +export const blockingHiddenFields = (model: TaskFormModel): HiddenField[] => + model.hidden.filter((h) => h.required); + +export const initialFormValues = ( + model: TaskFormModel +): Record => { + const values: Record = {}; + model.fields.forEach((f) => { + switch (f.kind) { + case 'float': + values[f.id] = f.default ?? sliderConfig(f)?.min ?? null; + break; + case 'int': + case 'string': + case 'bounds': + values[f.id] = f.default ?? null; + break; + case 'bool': + values[f.id] = f.default ?? false; + break; + case 'enum': + values[f.id] = f.default ?? f.options[0] ?? null; + break; + case 'sourceRef': + // Value is minted from provenance by the caller. + values[f.id] = null; + break; + } + }); + return values; +}; + +export const validateFormValues = ( + model: TaskFormModel, + values: Record +): FormValidationIssue[] => { + const issues: FormValidationIssue[] = []; + + blockingHiddenFields(model).forEach((h) => { + issues.push({ + parameter: h.id, + message: `"${h.id}" is required but uses an unsupported parameter type (${h.reason}) — cannot submit`, + }); + }); + + model.fields.forEach((f) => { + const v = values[f.id]; + // An enum member of '' counts as filled; the contract accepts it by membership. + const isDeclaredOption = + f.kind === 'enum' && f.options.some((o) => o === v); + if (f.required && isEmpty(v) && !isDeclaredOption) { + issues.push({ + parameter: f.id, + message: `${fieldLabel(f)} is required`, + }); + return; + } + if ((f.kind === 'int' || f.kind === 'float') && typeof v === 'number') { + if (f.kind === 'int' && !Number.isInteger(v)) { + issues.push({ + parameter: f.id, + message: `${fieldLabel(f)} must be a whole number`, + }); + } + if (f.min != null && v < f.min) { + issues.push({ + parameter: f.id, + message: `${fieldLabel(f)} must be ≥ ${f.min}`, + }); + } + if (f.max != null && v > f.max) { + issues.push({ + parameter: f.id, + message: `${fieldLabel(f)} must be ≤ ${f.max}`, + }); + } + if (f.step != null && !isStepAligned(v, f.step, f.min ?? 0)) { + const from = f.min != null ? ` from ${f.min}` : ''; + issues.push({ + parameter: f.id, + message: `${fieldLabel(f)} must be in steps of ${f.step}${from}`, + }); + } + } + }); + + return issues; +}; diff --git a/src/processing/engine/jobHistory.ts b/src/processing/engine/jobHistory.ts new file mode 100644 index 000000000..b7a8d05cb --- /dev/null +++ b/src/processing/engine/jobHistory.ts @@ -0,0 +1,79 @@ +import type { JobState, JobHistorySummary } from '@/backend-contract'; +import type { + ProcessingJobStatus, + SubmittedJobContext, +} from '@/src/processing/types'; +import { jobKey } from '@/src/processing/types'; + +// Rows key on (providerId, jobId) because two providers may share a raw jobId. +export type TrackedJobHistorySummary = JobHistorySummary & { + providerId: string; +}; + +export type JobHistoryFilters = { + statuses?: JobState[]; + task?: string; +}; + +export type JobHistoryDisplayRow = TrackedJobHistorySummary & { + errorTail?: string; +}; + +export const filterJobHistory = ( + jobs: T[], + filters: JobHistoryFilters +): T[] => { + const task = filters.task?.trim().toLocaleLowerCase(); + return jobs.filter((job) => { + if (filters.statuses?.length && !filters.statuses.includes(job.state)) { + return false; + } + if ( + task && + !job.taskId.toLocaleLowerCase().includes(task) && + !job.taskTitle.toLocaleLowerCase().includes(task) + ) { + return false; + } + return true; + }); +}; + +// A live status carries no providerId; the context at the same key supplies it. +export const selectJobHistoryRows = ( + durable: TrackedJobHistorySummary[], + live: ReadonlyMap, + contexts: ReadonlyMap +): JobHistoryDisplayRow[] => { + const byKey = new Map( + durable.map((job) => [ + jobKey({ providerId: job.providerId, jobId: job.jobId }), + job, + ]) + ); + live.forEach((status, key) => { + const liveFields = { + state: status.state, + resultState: status.resultState, + ...(status.progress != null ? { progress: status.progress } : {}), + ...(status.errorTail ? { errorTail: status.errorTail } : {}), + }; + const existing = byKey.get(key); + if (existing) { + byKey.set(key, { ...existing, ...liveFields }); + return; + } + const context = contexts.get(key); + if (!context) return; + byKey.set(key, { + providerId: context.providerId, + jobId: status.jobId, + taskId: context.taskId, + taskTitle: context.display?.taskTitle ?? context.taskId, + createdBy: { id: '', name: '' }, + createdAt: context.submittedAt, + ...liveFields, + }); + }); + return Array.from(byKey.values()); +}; diff --git a/src/processing/engine/mintInput.ts b/src/processing/engine/mintInput.ts new file mode 100644 index 000000000..2c6f627a6 --- /dev/null +++ b/src/processing/engine/mintInput.ts @@ -0,0 +1,191 @@ +import type { DataSource } from '@/src/io/import/dataSource'; +import { getDataSourceName } from '@/src/io/import/dataSource'; +import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; +import { basename } from '@/src/utils/path'; +import type { InputValue } from '@/backend-contract'; +import { TYPE_TAG_IMAGE } from '@/backend-contract'; +import type { ProcessingValue } from '@/src/processing/types'; +import type { + FormField, + FormValidationIssue, + TaskFormModel, +} from './formModel'; +import { fieldLabel } from './formModel'; + +// A partial uri set would be processed as an incomplete volume. +export const collectProvenanceUris = (ds: DataSource | undefined): string[] => { + if (!ds) return []; + if (ds.type === 'collection') { + const perSource = ds.sources.map(collectProvenanceUris); + return perSource.every((uris) => uris.length > 0) ? perSource.flat() : []; + } + if (ds.type === 'uri') return [ds.uri]; + return collectProvenanceUris(ds.parent); +}; + +// Compound extensions collapse to the last segment, which is fine because +// `format` is only an advisory hint. +const lastExtension = (name: string | undefined): string | undefined => { + if (!name) return undefined; + const base = basename(name); + const dot = base.lastIndexOf('.'); + return dot > 0 ? base.slice(dot + 1).toLowerCase() : undefined; +}; + +export const deriveFormat = (ds: DataSource): string | undefined => { + if (ds.type === 'collection') { + const isDicom = ds.sources.some( + (s) => s.type === 'chunk' && s.mime === FILE_EXT_TO_MIME.dcm + ); + return isDicom ? 'dicom-series' : undefined; + } + return ( + lastExtension(getDataSourceName(ds) ?? undefined) ?? + lastExtension(collectProvenanceUris(ds)[0]) + ); +}; + +export const mintInputValue = ( + ds: DataSource | undefined, + type: string = TYPE_TAG_IMAGE +): InputValue | null => { + const uris = collectProvenanceUris(ds); + if (uris.length === 0) return null; + const format = ds ? deriveFormat(ds) : undefined; + return format ? { type, format, uris } : { type, uris }; +}; + +export type SourceRefBindingState = + | 'bound' + | 'unbound' + | 'no-provenance' + | 'no-segment-group' + | 'ambiguous'; + +export type SourceRefField = Extract; + +export const sourceRefFields = ( + model: TaskFormModel, + typeTag: string +): SourceRefField[] => + model.fields.filter( + (f): f is SourceRefField => + f.kind === 'sourceRef' && f.accepts.includes(typeTag) + ); + +export const imageInputFields = (model: TaskFormModel): SourceRefField[] => + sourceRefFields(model, TYPE_TAG_IMAGE); + +// One source for the user-facing binding sentences: the binders emit them as +// validation issues and FileWidget renders the same text in the form body. +export const bindingStateMessage = ( + state: SourceRefBindingState, + noun: 'image' | 'segment group' +): string | undefined => { + if (state === 'no-provenance') + return 'The active volume was not loaded from the server, so it cannot be used as an input.'; + if (state === 'no-segment-group') + return 'Paint or select a segment group first.'; + if (state === 'ambiguous') + return `This task needs more than one ${noun} input, which this version cannot bind automatically.`; + return undefined; +}; + +// More than one input of a kind needs a picker that does not exist, so refuse +// rather than guess. +export const ambiguousBinding = ( + fields: SourceRefField[], + noun: 'image' | 'segment group' +): { + states: Record; + issues: FormValidationIssue[]; +} => ({ + states: Object.fromEntries(fields.map((f) => [f.id, 'ambiguous' as const])), + issues: [ + { + parameter: fields[0].id, + message: bindingStateMessage('ambiguous', noun)!, + }, + ], +}); + +export type ImageBindingResult = { + values: Record; + states: Record; + // Caller suppresses generic duplicates for these param ids. + issues: FormValidationIssue[]; +}; + +const EMPTY_BINDING: ImageBindingResult = { + values: {}, + states: {}, + issues: [], +}; + +const bindImageFields = ( + fields: SourceRefField[], + activeDataSource: DataSource | undefined, + value: InputValue | null +): ImageBindingResult => { + if (fields.length === 0) return EMPTY_BINDING; + + // Explicit null, not `{}`: a stale merge could submit the previous image. + const nullValues = () => + Object.fromEntries(fields.map((f) => [f.id, null as ProcessingValue])); + + if (fields.length > 1) { + return { values: nullValues(), ...ambiguousBinding(fields, 'image') }; + } + + const [field] = fields; + + if (!activeDataSource) { + return { + values: nullValues(), + states: { [field.id]: 'unbound' }, + issues: field.required + ? [{ parameter: field.id, message: `${fieldLabel(field)} is required` }] + : [], + }; + } + + // Blocks regardless of required-ness: a volume is selected, it just cannot be + // used as input. + if (!value) { + return { + values: nullValues(), + states: { [field.id]: 'no-provenance' }, + issues: [ + { + parameter: field.id, + message: bindingStateMessage('no-provenance', 'image')!, + }, + ], + }; + } + + return { + values: { [field.id]: value }, + states: { [field.id]: 'bound' }, + issues: [], + }; +}; + +export const bindMintedImageInputs = ( + model: TaskFormModel, + activeDataSource: DataSource | undefined, + value: InputValue | null +): ImageBindingResult => + bindImageFields(imageInputFields(model), activeDataSource, value); + +export const bindImageInputs = ( + model: TaskFormModel, + activeDataSource: DataSource | undefined +): ImageBindingResult => { + const fields = imageInputFields(model); + const value = + fields.length === 1 && activeDataSource + ? mintInputValue(activeDataSource, TYPE_TAG_IMAGE) + : null; + return bindImageFields(fields, activeDataSource, value); +}; diff --git a/src/processing/engine/mintLabelmap.ts b/src/processing/engine/mintLabelmap.ts new file mode 100644 index 000000000..ec14ab1de --- /dev/null +++ b/src/processing/engine/mintLabelmap.ts @@ -0,0 +1,132 @@ +import type { InputValue } from '@/backend-contract'; +import { TYPE_TAG_LABELMAP } from '@/backend-contract'; +import type { DataSource } from '@/src/io/import/dataSource'; +import type { FormValidationIssue, TaskFormModel } from './formModel'; +import type { SourceRefBindingState, SourceRefField } from './mintInput'; +import { + ambiguousBinding, + bindingStateMessage, + mintInputValue, + sourceRefFields, +} from './mintInput'; + +export const labelmapInputFields = (model: TaskFormModel): SourceRefField[] => + sourceRefFields(model, TYPE_TAG_LABELMAP); + +// Passed in rather than read from the store so resolution stays pure. +export type SegmentGroupView = { + orderByParent: Record; + metadataByID: Record; +}; + +export const mintLabelmapReferenceImage = ( + segmentGroupId: string, + view: SegmentGroupView, + getDataSource: (imageId: string) => DataSource | undefined +): InputValue | null => { + const parentImage = view.metadataByID[segmentGroupId]?.parentImage; + return parentImage ? mintInputValue(getDataSource(parentImage)) : null; +}; + +export type LabelmapResolution = + | { kind: 'resolved'; groupId: string } + | { kind: 'unresolved' }; + +export const resolveLabelmapGroup = ( + backgroundImageId: string | undefined, + activeSegmentGroupId: string | null | undefined, + view: SegmentGroupView +): LabelmapResolution => { + if (!backgroundImageId) return { kind: 'unresolved' }; + + const belongsToBackground = (groupId: string): boolean => + view.metadataByID[groupId]?.parentImage === backgroundImageId; + + if (activeSegmentGroupId && belongsToBackground(activeSegmentGroupId)) { + return { kind: 'resolved', groupId: activeSegmentGroupId }; + } + + // No picker exists, so an ambiguous background fails closed. + const groups = view.orderByParent[backgroundImageId] ?? []; + if (groups.length === 1 && belongsToBackground(groups[0])) { + return { kind: 'resolved', groupId: groups[0] }; + } + + return { kind: 'unresolved' }; +}; + +export type LabelmapBindingResult = { + groups: Record; + states: Record; + // Caller must suppress its generic issue for these param ids. + issues: FormValidationIssue[]; +}; + +const EMPTY_BINDING: LabelmapBindingResult = { + groups: {}, + states: {}, + issues: [], +}; + +const bindLabelmapFields = ( + fields: SourceRefField[], + resolution: LabelmapResolution +): LabelmapBindingResult => { + if (fields.length === 0) return EMPTY_BINDING; + + if (fields.length > 1) { + return { groups: {}, ...ambiguousBinding(fields, 'segment group') }; + } + + const [field] = fields; + + if (resolution.kind === 'unresolved') { + return { + groups: {}, + states: { [field.id]: 'no-segment-group' }, + issues: field.required + ? [ + { + parameter: field.id, + message: bindingStateMessage( + 'no-segment-group', + 'segment group' + )!, + }, + ] + : [], + }; + } + + return { + groups: { [field.id]: resolution.groupId }, + states: { [field.id]: 'bound' }, + issues: [], + }; +}; + +export const bindResolvedLabelmapInputs = ( + model: TaskFormModel, + resolution: LabelmapResolution +): LabelmapBindingResult => + bindLabelmapFields(labelmapInputFields(model), resolution); + +export const bindLabelmapInputs = ( + model: TaskFormModel, + backgroundImageId: string | undefined, + activeSegmentGroupId: string | null | undefined, + view: SegmentGroupView +): LabelmapBindingResult => { + const fields = labelmapInputFields(model); + const resolution = + fields.length === 1 + ? resolveLabelmapGroup(backgroundImageId, activeSegmentGroupId, view) + : { kind: 'unresolved' as const }; + return bindLabelmapFields(fields, resolution); +}; + +// `format` is omitted: the staged uri already carries the extension. +export const mintLabelmapValue = (uris: string[]): InputValue => ({ + type: TYPE_TAG_LABELMAP, + uris, +}); diff --git a/src/processing/engine/resultDownload.ts b/src/processing/engine/resultDownload.ts new file mode 100644 index 000000000..b162e2e43 --- /dev/null +++ b/src/processing/engine/resultDownload.ts @@ -0,0 +1,24 @@ +import { $fetch, canFetchUrl } from '@/src/utils/fetch'; +import type { ProcessingResult } from '@/src/processing/types'; + +export async function fetchProcessingResult( + result: ProcessingResult +): Promise { + if (!canFetchUrl(result.url)) { + throw new Error(`Cannot download unsupported URL: ${result.url}`); + } + + const response = await $fetch(result.url, { + credentials: 'same-origin', + }); + if (!response.ok) { + throw new Error( + `Result download failed: ${response.status} ${response.statusText}` + ); + } + + const blob = await response.blob(); + return new File([blob], result.name, { + type: result.mimeType ?? blob.type, + }); +} diff --git a/src/processing/engine/resultFiles.ts b/src/processing/engine/resultFiles.ts new file mode 100644 index 000000000..8f2c82b4d --- /dev/null +++ b/src/processing/engine/resultFiles.ts @@ -0,0 +1,26 @@ +import { resultToIntent } from '@/src/processing/engine/resultToIntent'; +import type { ProcessingResult } from '@/src/processing/types'; + +/** + * The results Load can put in the scene — the ones carrying a known intent. + * + * A job's other outputs (reports, tables, logs) are ordinary files: the backend + * emits them with no state directive, so the single applier has nothing to do + * with them and they are retrievable only by download. + */ +export const sceneApplicableResults = ( + results: ProcessingResult[] +): ProcessingResult[] => results.filter((result) => !!resultToIntent(result)); + +/** + * Whether to offer Load for a job whose fetched result list is `results`. + * + * `undefined` means the list has not been fetched yet: the answer is unknown, + * so Load stays offered rather than hidden on a guess. Once the list is known, + * a job with no scene-applicable output (a report-only task) drops the button + * instead of leaving a control that can only dead-end. + */ +export const offersSceneLoad = ( + results: ProcessingResult[] | undefined +): boolean => + results === undefined || sceneApplicableResults(results).length > 0; diff --git a/src/processing/engine/resultToIntent.ts b/src/processing/engine/resultToIntent.ts new file mode 100644 index 000000000..3d998ceb5 --- /dev/null +++ b/src/processing/engine/resultToIntent.ts @@ -0,0 +1,13 @@ +import { + knownResultIntentSchema, + type KnownResultIntent, +} from '@/backend-contract'; +import type { ProcessingResult } from '@/src/processing/types'; + +export const resultToIntent = ( + result: ProcessingResult +): KnownResultIntent | undefined => { + // A known intent name with an invalid shape must not be applied. + const known = knownResultIntentSchema.safeParse(result); + return known.success ? known.data : undefined; +}; diff --git a/src/processing/engine/sourceRefs.ts b/src/processing/engine/sourceRefs.ts new file mode 100644 index 000000000..cfc0857d5 --- /dev/null +++ b/src/processing/engine/sourceRefs.ts @@ -0,0 +1,151 @@ +import { TYPE_TAG_IMAGE, TYPE_TAG_LABELMAP } from '@/backend-contract'; +import type { DataSource } from '@/src/io/import/dataSource'; +import type { TaskFormModel } from './formModel'; +import { + bindMintedImageInputs, + mintInputValue, + type ImageBindingResult, + type SourceRefBindingState, + type SourceRefField, +} from './mintInput'; +import { + bindResolvedLabelmapInputs, + mintLabelmapReferenceImage, + resolveLabelmapGroup, + type LabelmapBindingResult, + type SegmentGroupView, +} from './mintLabelmap'; + +export type BoundSourceRefType = + | typeof TYPE_TAG_IMAGE + | typeof TYPE_TAG_LABELMAP; + +export type SourceRefBindings = { + image: ImageBindingResult; + labelmap: LabelmapBindingResult; + types: Record; + states: Record; + issues: ImageBindingResult['issues']; +}; + +export type SourceRefBindingContext = { + activeDataSource: DataSource | undefined; + backgroundImageId: string | undefined; + activeSegmentGroupId: string | null | undefined; + segmentGroups: SegmentGroupView; + getDataSource: (imageId: string) => DataSource | undefined; +}; + +const acceptedTypes = (field: SourceRefField): BoundSourceRefType[] => + Array.from( + new Set( + field.accepts.filter( + (type): type is BoundSourceRefType => + type === TYPE_TAG_IMAGE || type === TYPE_TAG_LABELMAP + ) + ) + ); + +const modelForType = ( + model: TaskFormModel, + types: Record, + type: BoundSourceRefType +): TaskFormModel => ({ + ...model, + fields: model.fields.filter( + (field) => field.kind === 'sourceRef' && types[field.id] === type + ), +}); + +export const bindSourceRefs = ( + model: TaskFormModel, + context: SourceRefBindingContext +): SourceRefBindings => { + const fields = model.fields.filter( + (field): field is SourceRefField => field.kind === 'sourceRef' + ); + const acceptsImage = fields.some((field) => + acceptedTypes(field).includes(TYPE_TAG_IMAGE) + ); + const acceptsLabelmap = fields.some((field) => + acceptedTypes(field).includes(TYPE_TAG_LABELMAP) + ); + const imageValue = acceptsImage + ? mintInputValue(context.activeDataSource, TYPE_TAG_IMAGE) + : null; + const labelmapResolution = acceptsLabelmap + ? resolveLabelmapGroup( + context.backgroundImageId, + context.activeSegmentGroupId, + context.segmentGroups + ) + : { kind: 'unresolved' as const }; + const labelmapReference = + labelmapResolution.kind === 'resolved' + ? mintLabelmapReferenceImage( + labelmapResolution.groupId, + context.segmentGroups, + context.getDataSource + ) + : null; + const available = new Set(); + if (imageValue) { + available.add(TYPE_TAG_IMAGE); + } + if (labelmapResolution.kind === 'resolved' && labelmapReference) { + available.add(TYPE_TAG_LABELMAP); + } + + const types: Record = {}; + const dedicated = new Set(); + fields.forEach((field) => { + const accepted = acceptedTypes(field); + if (accepted.length !== 1) return; + types[field.id] = accepted[0]; + dedicated.add(accepted[0]); + }); + fields.forEach((field) => { + const accepted = acceptedTypes(field); + if (accepted.length <= 1) return; + const availableTypes = accepted.filter((type) => available.has(type)); + const selected = + availableTypes.find((type) => !dedicated.has(type)) ?? + availableTypes[0] ?? + accepted.find((type) => !dedicated.has(type)) ?? + accepted[0]; + if (selected) types[field.id] = selected; + }); + + const image = bindMintedImageInputs( + modelForType(model, types, TYPE_TAG_IMAGE), + context.activeDataSource, + imageValue + ); + const labelmap = bindResolvedLabelmapInputs( + modelForType(model, types, TYPE_TAG_LABELMAP), + labelmapResolution + ); + const labelmapIssues = [...labelmap.issues]; + Object.entries(labelmap.groups).forEach(([parameterId, groupId]) => { + const reference = + labelmapResolution.kind === 'resolved' && + labelmapResolution.groupId === groupId + ? labelmapReference + : null; + if (reference) return; + labelmap.states[parameterId] = 'no-provenance'; + labelmapIssues.push({ + parameter: parameterId, + message: + 'The segment group reference image was not loaded from the server, so it cannot be used as an input.', + }); + }); + + return { + image, + labelmap, + types, + states: { ...image.states, ...labelmap.states }, + issues: [...image.issues, ...labelmapIssues], + }; +}; diff --git a/src/processing/engine/taskSpec.ts b/src/processing/engine/taskSpec.ts new file mode 100644 index 000000000..b328d3745 --- /dev/null +++ b/src/processing/engine/taskSpec.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { + reportDuplicateTaskSpecIds, + SPEC_VERSION, + taskSpecStructuralSchema, +} from '@/backend-contract'; + +// Parameters stay raw so one unknown parameter kind hides a single param instead of rejecting the whole spec. +export const taskSpecEnvelopeSchema = taskSpecStructuralSchema + .omit({ specVersion: true, parameters: true }) + .extend({ + specVersion: z.literal(SPEC_VERSION), + parameters: z.array(z.unknown()), + }) + .superRefine(reportDuplicateTaskSpecIds); + +export type TaskSpecEnvelope = z.infer; + +export const parseTaskSpecEnvelope = (raw: unknown): TaskSpecEnvelope => + taskSpecEnvelopeSchema.parse(raw); diff --git a/src/processing/engine/transport.ts b/src/processing/engine/transport.ts new file mode 100644 index 000000000..26d210c9b --- /dev/null +++ b/src/processing/engine/transport.ts @@ -0,0 +1,170 @@ +// `$fetch` attaches the same-origin bearer that raw `fetch` would omit. + +import { z } from 'zod'; +import { pathSegmentIdSchema } from '@/backend-contract'; +import { $fetch } from '@/src/utils/fetch'; +import type { + ProcessingProvider, + ProcessingProviderConfig, + TaskSummary, +} from '@/src/processing/types'; +import { parseTaskSpecEnvelope } from './taskSpec'; +import { + parseJobHistoryPage, + parseJobHistoryDetail, + parseJobRef, + parseJobStatus, + parseResults, + parseStageResponse, +} from './wire'; + +// One malformed task summary must not kill the whole picker. +const taskSummarySchema = z.object({ + id: pathSegmentIdSchema, + title: z.string(), +}); + +const parseTaskSummaries = (raw: unknown): TaskSummary[] => { + if (!Array.isArray(raw)) { + console.warn('processing: task list was not an array; ignoring it'); + return []; + } + return raw.filter((entry): entry is TaskSummary => { + const parsed = taskSummarySchema.safeParse(entry); + if (!parsed.success) { + console.warn('processing: dropping malformed task summary', entry); + } + return parsed.success; + }); +}; + +const join = (base: string, path: string) => + `${base.replace(/\/+$/, '')}/${path.replace(/^\/+/, '')}`; + +const id = (taskOrJobId: string) => + encodeURIComponent(pathSegmentIdSchema.parse(taskOrJobId)); + +// Status rides on the error so the poller can classify the failure. +export type HttpError = Error & { + status: number; + code?: string; + body?: unknown; +}; + +const requestJson = async (url: string, init?: RequestInit): Promise => { + const res = await $fetch(url, { + credentials: 'same-origin', + ...init, + }); + if (!res.ok) { + const text = await res.text().catch(() => ''); + let body: unknown; + try { + body = JSON.parse(text); + } catch { + body = text; + } + const err = new Error( + `Request failed: ${res.status} ${res.statusText} ${text}` + ) as HttpError; + err.status = res.status; + err.body = body; + if (body && typeof body === 'object' && 'code' in body) { + const code = (body as { code?: unknown }).code; + if (typeof code === 'string') err.code = code; + } + throw err; + } + return res.json() as Promise; +}; + +const requestEmpty = async (url: string, init: RequestInit): Promise => { + const res = await $fetch(url, { + credentials: 'same-origin', + ...init, + }); + if (!res.ok) { + const err = new Error( + `Request failed: ${res.status} ${res.statusText}` + ) as HttpError; + err.status = res.status; + throw err; + } +}; + +export type EngineTransport = Omit; + +export const createEngineTransport = ( + config: ProcessingProviderConfig +): EngineTransport => { + const { baseUrl, jobsBaseUrl } = config; + return { + listTasks: async () => + parseTaskSummaries(await requestJson(join(baseUrl, 'tasks'))), + + getTaskSpec: async (taskId) => + parseTaskSpecEnvelope( + await requestJson(join(baseUrl, `tasks/${id(taskId)}/spec`)) + ), + + runTask: async (taskId, values) => + parseJobRef( + await requestJson(join(baseUrl, `tasks/${id(taskId)}/run`), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ values }), + }) + ), + + getJob: async (jobId) => + parseJobStatus( + jobId, + await requestJson(join(jobsBaseUrl, `jobs/${id(jobId)}`)) + ), + + getResults: async (jobId) => + parseResults( + await requestJson(join(jobsBaseUrl, `jobs/${id(jobId)}/results`)) + ), + + cancelJob: async (jobId) => + parseJobStatus( + jobId, + await requestJson(join(jobsBaseUrl, `jobs/${id(jobId)}/cancel`), { + method: 'POST', + }) + ), + + deleteJob: async (jobId) => { + await requestEmpty(join(jobsBaseUrl, `jobs/${id(jobId)}`), { + method: 'DELETE', + }); + }, + + stageInput: async (request) => { + const body = new FormData(); + body.append('file', request.file, request.descriptor.name); + body.append('descriptor', JSON.stringify(request.descriptor)); + return parseStageResponse( + await requestJson(join(baseUrl, 'stage'), { + method: 'POST', + body, + }) + ); + }, + + listJobHistory: async (cursor) => { + const url = join(baseUrl, 'jobs'); + return parseJobHistoryPage( + await requestJson( + cursor ? `${url}?cursor=${encodeURIComponent(cursor)}` : url + ) + ); + }, + + getJobHistoryDetail: async (jobId) => + parseJobHistoryDetail( + await requestJson(join(jobsBaseUrl, `jobs/${id(jobId)}/detail`)) + ), + }; +}; diff --git a/src/processing/engine/wire.ts b/src/processing/engine/wire.ts new file mode 100644 index 000000000..03ad0692e --- /dev/null +++ b/src/processing/engine/wire.ts @@ -0,0 +1,112 @@ +import { z } from 'zod'; +import { + neutralJobStatusSchema, + jobHistoryPageSchema, + jobHistoryDetailSchema, + jobResultsSchema, + pathSegmentIdSchema, + type JobHistoryPage, + type JobHistoryDetail, +} from '@/backend-contract'; +import type { + ProcessingJobRef, + ProcessingJobStatus, + JobResultsBundle, + ProcessingResult, +} from '@/src/processing/types'; + +const jobStatusSchema = neutralJobStatusSchema.and( + z.object({ jobId: pathSegmentIdSchema }).passthrough() +) satisfies z.ZodType; + +// The initial status is validated separately so a malformed born-terminal +// status becomes a terminal error instead of failing the whole ref. +const jobRefEnvelopeSchema = z.object({ + jobId: pathSegmentIdSchema, + status: z.unknown().optional(), +}); + +// The client constructs no URI, so an empty response fails closed rather than +// minting a labelmap value with no provenance. +const stageResponseSchema = z.object({ + uris: z.array(z.string()).min(1), +}); + +const formatIssues = (error: z.ZodError): string => + error.issues + .map((issue) => { + const path = issue.path.join('.'); + return path ? `${path}: ${issue.message}` : issue.message; + }) + .join('; '); + +const parseOrThrow = + (schema: S, label: string) => + (raw: unknown): z.output => { + const parsed = schema.safeParse(raw); + if (!parsed.success) { + throw new Error( + `Malformed ${label} from provider: ${formatIssues(parsed.error)}` + ); + } + return parsed.data; + }; + +const parseJobRefEnvelope = parseOrThrow(jobRefEnvelopeSchema, 'job ref'); +const parseResultsEnvelope = parseOrThrow(jobResultsSchema, 'job results'); +const parseStageEnvelope = parseOrThrow( + stageResponseSchema, + 'staging response' +); + +// An invalid payload becomes a terminal error status so the poller stops +// instead of looping forever on an unknown state. +export const parseJobStatus = ( + jobId: string, + raw: unknown +): ProcessingJobStatus => { + const parsed = jobStatusSchema.safeParse(raw); + // The store keys its job map off `status.jobId`, so a provider returning a + // different id would record the job under the wrong key. + if (parsed.success) return { ...parsed.data, jobId }; + return { + jobId, + state: 'error', + resultState: 'unavailable', + errorTail: `Malformed job status from provider: ${formatIssues(parsed.error)}`, + }; +}; + +export const parseJobRef = (raw: unknown): ProcessingJobRef => { + const { jobId, status } = parseJobRefEnvelope(raw); + // `null` is a common serialization of an omitted optional field, so treat it + // as absent rather than as a malformed born-terminal status. + return status == null + ? { jobId } + : { jobId, status: parseJobStatus(jobId, status) }; +}; + +export const parseResults = (raw: unknown): JobResultsBundle => { + const envelope = parseResultsEnvelope(raw); + // The wire allows null metadata (and any intent shape); `ProcessingResult` + // uses `undefined` and a string intent. + const results: ProcessingResult[] = envelope.intents.map((row) => ({ + ...row, + intent: typeof row.intent === 'string' ? row.intent : undefined, + mimeType: row.mimeType ?? undefined, + size: row.size ?? undefined, + })); + return { + results, + missing: envelope.missing, + }; +}; + +export const parseStageResponse = (raw: unknown): string[] => + parseStageEnvelope(raw).uris; + +export const parseJobHistoryPage: (raw: unknown) => JobHistoryPage = + parseOrThrow(jobHistoryPageSchema, 'job history page'); + +export const parseJobHistoryDetail: (raw: unknown) => JobHistoryDetail = + parseOrThrow(jobHistoryDetailSchema, 'job history detail'); diff --git a/src/processing/index.ts b/src/processing/index.ts new file mode 100644 index 000000000..b0bd53b50 --- /dev/null +++ b/src/processing/index.ts @@ -0,0 +1,51 @@ +// Sole entry point for the processing feature, enforced by eslint import zones. + +import { defineAsyncComponent } from 'vue'; + +import { registerConfigSection } from '@/src/io/import/configJson'; +import { onLaunchLoadComplete } from '@/src/core/launchLoad'; +import { + processingSection, + selectAllowedProviders, + type ProcessingSection, +} from './config'; +import { useProcessingJobsStore } from './store'; + +export { useProcessingJobsStore } from './store'; +export type { ProcessingProviderConfig } from './types'; + +// Lazy so the panel chunk stays out of the boot bundle. +export const JobsModule = defineAsyncComponent( + () => import('./components/JobsModule.vue') +); + +// The feature's top-level config section. Exported so tests can exercise the +// schema/apply contract directly; `apply` is the only caller that touches the +// store, keeping `config.ts` store-free. +export const processingConfigSection = { + key: 'processing', + schema: processingSection, + apply: (section: ProcessingSection) => { + const store = useProcessingJobsStore(); + selectAllowedProviders(section).forEach((config) => + store.registerProviderConfig(config) + ); + }, +}; + +// Module-evaluation-time registration: this entry point is statically imported +// at boot (App.vue), which runs before any config file can be recognized. +registerConfigSection(processingConfigSection); + +// Job re-discovery after reload: once the launch data + providers are in, +// re-find THIS study's jobs for the Jobs panel — still-running jobs join the +// normal poller (finishing while open fires the ordinary in-session live +// path), terminal ones are observability rows only. Inert with no configured +// provider (the demo posture registers none) and never fatal to the load. +onLaunchLoadComplete(async () => { + try { + await useProcessingJobsStore().adoptJobHistory(); + } catch (err) { + console.error('Job re-discovery failed', err); + } +}); diff --git a/src/processing/store.ts b/src/processing/store.ts new file mode 100644 index 000000000..bffbd6898 --- /dev/null +++ b/src/processing/store.ts @@ -0,0 +1,1013 @@ +// Job records and poll machinery outlive the Jobs component's unmounts. + +import { defineStore } from 'pinia'; +import { computed, reactive, ref } from 'vue'; +import deepEqual from 'fast-deep-equal'; + +import type { JobHistoryDetail, JobHistorySummary } from '@/backend-contract'; +import { inputValueSchema, TYPE_TAG_LABELMAP } from '@/backend-contract'; +import { collectProvenanceUris } from '@/src/processing/engine/mintInput'; +import type { + ProcessingJobStatus, + ProcessingProvider, + ProcessingProviderConfig, + ProcessingResult, + ProcessingValue, + SubmittedJobContext, + TrackedJobRef, +} from '@/src/processing/types'; +import { + jobKey, + isTerminalJobState, + missingJobErrorDetails, +} from '@/src/processing/types'; +import type { TrackedJobHistorySummary } from '@/src/processing/engine/jobHistory'; +import { selectJobHistoryRows } from '@/src/processing/engine/jobHistory'; +import { autoLoadProcessingResults } from '@/src/processing/applyResults'; +import { useMessageStore } from '@/src/store/messages'; +import { useDatasetStore } from '@/src/store/datasets'; +import { ensureError, getErrorDetail, plural } from '@/src/utils'; + +export const POLL_INTERVAL_MS = 2000; +export const MAX_POLL_RETRIES = 4; +export const MAX_POLL_BACKOFF_MS = 30000; +export const MAX_JOB_HISTORY_PAGES = 1000; + +const completionReady = (status: ProcessingJobStatus): boolean => + isTerminalJobState(status.state); + +type PollErrorKind = + | 'transient' + | 'permanent' + | 'session-expired' + | 'resource-gone'; + +const classifyError = (err: unknown): PollErrorKind => { + const status = (err as { status?: number } | null | undefined)?.status; + // 401 means the session itself is gone (Girder answers 401 only for a + // missing/expired token). 403 is a per-resource denial on a still-valid + // session — another user's job, an ACL edited mid-session — so it fails just + // that request, never the whole session. + if (status === 401) return 'session-expired'; + if (status === 404 || status === 410) return 'resource-gone'; + if (status === 429) return 'transient'; + if (typeof status === 'number' && status >= 400 && status < 500) + return 'permanent'; + // No HTTP status means the fetch itself rejected: offline, DNS, or CORS. + return 'transient'; +}; + +// Unresolved outputs are a partial loss: warn without dropping the results that +// did resolve. +const warnMissingOutputs = (n: number): void => { + const outputs = plural(n, 'output'); + useMessageStore().addWarning(`${n} ${outputs} could not be retrieved`, { + details: `${n} of this job's recorded ${outputs} could not be retrieved (deleted or unreadable). The results that resolved are available in the Jobs panel.`, + }); +}; + +// `baseImageMissing` means the results must be surfaced but not auto-attached to +// a parent that no longer exists. +export type JobCompletion = { + status: ProcessingJobStatus; + results: ProcessingResult[]; + context?: SubmittedJobContext; + baseImageMissing?: boolean; +}; + +type CompletionListener = (completion: JobCompletion) => void; + +const loadProvider = async ( + config: ProcessingProviderConfig +): Promise => { + // Dynamic import keeps the engine chunk out of the boot bundle. + const { createEngineTransport } = + await import('@/src/processing/engine/transport'); + return { config, ...createEngineTransport(config) }; +}; + +export const useProcessingJobsStore = defineStore('processingJobs', () => { + const configs = reactive(new Map()); + + const instances = reactive(new Map()); + const loading = reactive(new Map>()); + + // Every per-job collection is keyed by jobKey, never a raw jobId: two + // context-scoped providers may mint the same raw jobId. + const jobs = reactive(new Map()); + const jobHistory = reactive(new Map()); + const jobHistoryDetails = reactive(new Map()); + const jobHistoryCursors = new Map(); + const jobHistoryLoading = ref(false); + const jobHistoryComplete = ref(false); + const jobHistoryErrors = reactive(new Map()); + const jobHistoryError = computed( + () => Array.from(jobHistoryErrors.values()).join('; ') || null + ); + let jobHistoryRequest: Promise | null = null; + const submittedContexts = reactive(new Map()); + const jobResults = reactive(new Map()); + const jobResultMissing = reactive(new Map()); + const jobResultsApplied = reactive(new Set()); + const jobResultApplicationFailures = reactive(new Map>()); + const jobResultApplications = reactive(new Map>()); + const pollTimers = new Map>(); + const pollRetries = new Map(); + const resultFetchRetries = new Map(); + + // Generation guards stop a late response from resurrecting a deleted job. + let generationCounter = 0; + const jobGenerations = new Map(); + + function mintGeneration(key: string): number { + generationCounter += 1; + jobGenerations.set(key, generationCounter); + return generationCounter; + } + + const isCurrent = (key: string, gen: number | undefined): boolean => + gen !== undefined && jobGenerations.get(key) === gen; + + // The Jobs component watches this to prompt a reload. + const sessionExpired = ref(false); + + const completionListeners = new Set(); + // Retained so a listener that subscribes after the event, because the Jobs tab + // was unmounted when the job finished, can be replayed on remount. + const terminalCompletions = new Map(); + // Store-level rather than per-callback: the component re-subscribes with a + // fresh callback every mount, so a per-callback set would double-fire. + const firedCompletions = new Set(); + const inFlightCompletions = new Set(); + + // Merged once here so every consumer (list, count, filter options) shares a + // single durable+live merge per store change instead of re-merging. + const jobHistoryRows = computed(() => + selectJobHistoryRows( + Array.from(jobHistory.values()), + jobs, + submittedContexts + ) + ); + + // A provider id encodes its launch context, so re-registering one id with a + // different config must never silently swap a live instance's config. + function registerProviderConfig(config: ProcessingProviderConfig) { + const existing = configs.get(config.id); + if (existing) { + if (deepEqual(existing, config)) return; + throw new Error( + `Processing provider "${config.id}" is already registered with a different configuration` + ); + } + // Read before registering: boot-time registrations precede the one-time + // adoption and must stay inert here. + const historyStarted = + jobHistoryComplete.value || jobHistoryRequest !== null; + configs.set(config.id, config); + if (historyStarted) { + // A provider arriving after adoption must still discover its jobs. + jobHistoryComplete.value = false; + const prior = jobHistoryRequest ?? Promise.resolve(); + void prior + .catch(() => {}) + .then(() => loadMoreJobHistory()) + .catch(() => {}); + } + } + + // One list so deleteJob and clearJobs stay in lockstep. + const perJobCollections: Array<{ + delete(key: string): unknown; + clear(): void; + }> = [ + jobs, + jobHistory, + jobHistoryDetails, + submittedContexts, + jobResults, + jobResultMissing, + jobResultsApplied, + jobResultApplicationFailures, + jobGenerations, + terminalCompletions, + firedCompletions, + inFlightCompletions, + ]; + + function clearJobs() { + Array.from(pollTimers.keys()).forEach(stopPolling); + perJobCollections.forEach((collection) => collection.clear()); + jobHistoryCursors.clear(); + jobHistoryErrors.clear(); + jobHistoryLoading.value = false; + jobHistoryComplete.value = false; + jobHistoryRequest = null; + sessionExpired.value = false; + } + + function clearProviders() { + configs.clear(); + instances.clear(); + loading.clear(); + clearJobs(); + } + + async function getProvider(id: string): Promise { + const existing = instances.get(id); + if (existing) return existing; + const inflight = loading.get(id); + if (inflight) return inflight; + const config = configs.get(id); + if (!config) throw new Error(`Unknown provider id: ${id}`); + const promise = loadProvider(config).then((provider) => { + instances.set(id, provider); + if (loading.get(id) === promise) loading.delete(id); + return provider; + }); + // Evict a rejected load so later calls retry instead of returning the same + // dead promise. + promise.catch(() => { + if (loading.get(id) === promise) loading.delete(id); + }); + loading.set(id, promise); + return promise; + } + + function recordJob(providerId: string, status: ProcessingJobStatus) { + const key = jobKey({ providerId, jobId: status.jobId }); + // Skipping an unchanged write avoids rebuilding and re-sorting the JobList + // `jobs` computed on every poll tick. + const existing = jobs.get(key); + if (existing && deepEqual(existing, status)) return; + jobs.set(key, status); + } + + function recordSubmittedContext(context: SubmittedJobContext) { + const key = jobKey({ + providerId: context.providerId, + jobId: context.jobId, + }); + // Never re-mint a live generation: that would orphan the incarnation's own + // in-flight continuations. + if (!jobGenerations.has(key)) mintGeneration(key); + submittedContexts.set(key, context); + } + + // With no listener subscribed the completion is retained but not marked seen, + // so the next subscriber replays it exactly once. + function deliverCompletion(jobRef: TrackedJobRef, completion: JobCompletion) { + // An already-retained completion means a second poll loop reached the + // terminal state, so delivering again would double-toast. + const key = jobKey(jobRef); + if (terminalCompletions.has(key) || firedCompletions.has(key)) return; + addTerminalJobMessage(jobRef, completion.status, completion.results); + terminalCompletions.set(key, completion); + if (completionListeners.size === 0) return; + firedCompletions.add(key); + completionListeners.forEach((cb) => cb(completion)); + } + + function onJobComplete(cb: CompletionListener): () => void { + completionListeners.add(cb); + terminalCompletions.forEach((completion, key) => { + if (firedCompletions.has(key)) return; + firedCompletions.add(key); + cb(completion); + }); + return () => completionListeners.delete(cb); + } + + function stopPolling(key: string) { + const timer = pollTimers.get(key); + if (timer) clearTimeout(timer); + pollTimers.delete(key); + pollRetries.delete(key); + resultFetchRetries.delete(key); + // The polling epoch is over: advance the generation so a late getJob + // continuation drops instead of re-recording over the terminal status. + if (jobGenerations.has(key)) mintGeneration(key); + } + + function scheduleNextPoll( + provider: ProcessingProvider, + jobId: string, + delay: number + ) { + const key = jobKey({ providerId: provider.config.id, jobId }); + // Replace, never orphan: a second poll loop for the same job would leave an + // untracked timer that stopPolling can no longer cancel. + const existing = pollTimers.get(key); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => pollOnce(provider, jobId), delay); + pollTimers.set(key, timer); + } + + function scheduleResultFetchRetry( + provider: ProcessingProvider, + status: ProcessingJobStatus, + delay: number + ) { + const key = jobKey({ providerId: provider.config.id, jobId: status.jobId }); + const existing = pollTimers.get(key); + if (existing) clearTimeout(existing); + const timer = setTimeout(() => { + pollTimers.delete(key); + void fireCompletion(provider, status); + }, delay); + pollTimers.set(key, timer); + } + + function taskLabelFor(jobRef: TrackedJobRef): string { + return submittedContexts.get(jobKey(jobRef))?.taskId ?? jobRef.jobId; + } + + function addTerminalJobMessage( + jobRef: TrackedJobRef, + status: ProcessingJobStatus, + results: ProcessingResult[] + ) { + const title = taskLabelFor(jobRef); + const messageStore = useMessageStore(); + if (status.state === 'success') { + const count = results.length; + messageStore.addSuccess( + `Job complete: ${title}`, + `${count} ${plural(count, 'result')} available in the Jobs panel.` + ); + } else if (status.state === 'error') { + const failureTitle = /result/i.test(status.errorTail ?? '') + ? `Job failed while fetching results: ${title}` + : `Job failed: ${title}`; + messageStore.addError(failureTitle, { + details: + status.errorTail?.trim() || missingJobErrorDetails(status.jobId), + }); + } else if (status.state === 'cancelled') { + messageStore.addInfo(`Job cancelled: ${title}`); + } + } + + // The synthesized error status routes through the same completion path as any + // other terminal job. + function failJob( + jobRef: TrackedJobRef, + err: unknown, + detail?: string + ): ProcessingJobStatus { + stopPolling(jobKey(jobRef)); + const message = ensureError(err).message; + const errorTail = detail ? `${detail}: ${message}` : message; + const status: ProcessingJobStatus = { + jobId: jobRef.jobId, + state: 'error', + resultState: 'unavailable', + errorTail, + }; + recordJob(jobRef.providerId, status); + return status; + } + + // Same-origin means the whole backend session is gone, not just this job, so + // there is no subtler recovery than a reload. + function markSessionExpired(err: unknown) { + if (sessionExpired.value) return; + sessionExpired.value = true; + Array.from(pollTimers.keys()).forEach(stopPolling); + useMessageStore().addError( + 'Your session has expired. Reload the page to continue.', + { error: ensureError(err), persist: true } + ); + } + + // True when the error ended the whole session (now reported): callers bail + // with a plain guard instead of re-writing the classify+mark pair. + function expireSessionIf(err: unknown): boolean { + if (classifyError(err) !== 'session-expired') return false; + markSessionExpired(err); + return true; + } + + function handlePollError( + provider: ProcessingProvider, + jobId: string, + err: unknown + ) { + const jobRef: TrackedJobRef = { providerId: provider.config.id, jobId }; + const key = jobKey(jobRef); + const kind = classifyError(err); + if (kind === 'session-expired') { + markSessionExpired(err); + return; + } + if (kind === 'transient') { + const attempts = (pollRetries.get(key) ?? 0) + 1; + if (attempts <= MAX_POLL_RETRIES) { + pollRetries.set(key, attempts); + scheduleNextPoll( + provider, + jobId, + Math.min(POLL_INTERVAL_MS * 2 ** attempts, MAX_POLL_BACKOFF_MS) + ); + return; + } + } + const detail = + kind === 'resource-gone' + ? 'the job or its base image may have been deleted' + : kind === 'permanent' + ? undefined + : `polling gave up after ${MAX_POLL_RETRIES} retries`; + const status = failJob(jobRef, err, detail); + deliverCompletion(jobRef, { + status, + results: [], + context: submittedContexts.get(key), + }); + } + + function baseImageMissing(context?: SubmittedJobContext): boolean { + const id = context?.activeDatasetId; + if (!id) return false; // no base bound at submit — nothing to lose + return !useDatasetStore().idsAsSelections.includes(id); + } + + // Shared by every results auto-load path: results must never attach to a base + // image that left the scene, so strip the binding and let labelmaps open as + // plain datasets. Pass `missing` to reuse a liveness check already made (e.g. + // the completion payload's snapshot); omit it to check live. + function contextForAutoLoad( + context?: SubmittedJobContext, + missing = baseImageMissing(context) + ): SubmittedJobContext | undefined { + return missing && context + ? { ...context, activeDatasetId: undefined } + : context; + } + + // An adopted job's persisted image input carries the parent's provenance URIs, + // so the parent can be re-identified among the loaded datasets. + function isImageInputValue( + v: unknown + ): v is { type: string; uris: string[] } { + const parsed = inputValueSchema.safeParse(v); + return parsed.success && parsed.data.type !== TYPE_TAG_LABELMAP; + } + + // Order-insensitive: a re-loaded dataset's provenance walk need not enumerate + // in submit order. + function sameUriSet(a: string[], b: ReadonlySet): boolean { + const unique = new Set(a); + return unique.size === b.size && a.every((uri) => b.has(uri)); + } + + function datasetIdForUris(uris: string[]): string | undefined { + const datasets = useDatasetStore(); + const wanted = new Set(uris); + const matches = datasets.idsAsSelections.filter((id) => + sameUriSet(collectProvenanceUris(datasets.getDataSource(id)), wanted) + ); + return matches.length === 1 ? matches[0] : undefined; + } + + async function reconstructAdoptedParentId( + provider: ProcessingProvider, + jobId: string + ): Promise { + let detail: JobHistoryDetail | undefined; + try { + detail = await provider.getJobHistoryDetail(jobId); + } catch { + return undefined; // best-effort: the open-as-dataset fallback still works + } + const imageInputs = Object.values(detail?.parameters ?? {}).filter( + isImageInputValue + ); + // Anything but exactly one image input is ambiguous; never guess a parent to + // attach results to. + if (imageInputs.length !== 1) return undefined; + return datasetIdForUris(imageInputs[0].uris); + } + + // Rebuild an adopted job's missing parent id so a labelmap result attaches + // as a segment group instead of opening as a top-level dataset. + async function ensureAdoptedParentId( + provider: ProcessingProvider, + key: string, + gen: number | undefined, + context: SubmittedJobContext | undefined + ): Promise { + if (!context || context.activeDatasetId != null) return context; + const parentId = await reconstructAdoptedParentId(provider, context.jobId); + if (parentId === undefined || !isCurrent(key, gen)) return context; + const updated = { ...context, activeDatasetId: parentId }; + submittedContexts.set(key, updated); + return updated; + } + + // Shared by the live completion path and the reopened-session load: fetch + // the results and the adopted parent id concurrently (they are independent + // round trips), record them, and warn on unresolved outputs. Returns null + // when the generation went stale mid-fetch; errors propagate to the caller, + // whose recovery differs (fail the job vs. toast). + async function fetchAndRecordResults( + provider: ProcessingProvider, + jobId: string, + key: string, + gen: number | undefined, + context: SubmittedJobContext | undefined + ) { + const [bundle, updatedContext] = await Promise.all([ + provider.getResults(jobId), + ensureAdoptedParentId(provider, key, gen, context), + ]); + if (!isCurrent(key, gen)) return null; + jobResults.set(key, bundle.results); + jobResultMissing.set(key, bundle.missing); + if (bundle.missing > 0) warnMissingOutputs(bundle.missing); + return { results: bundle.results, context: updatedContext }; + } + + async function fireCompletion( + provider: ProcessingProvider, + status: ProcessingJobStatus + ) { + const { jobId } = status; + const jobRef: TrackedJobRef = { providerId: provider.config.id, jobId }; + const key = jobKey(jobRef); + if ( + inFlightCompletions.has(key) || + terminalCompletions.has(key) || + firedCompletions.has(key) + ) + return; + const gen = jobGenerations.get(key); + if (gen === undefined) return; + inFlightCompletions.add(key); + let context = submittedContexts.get(key); + try { + if (status.state !== 'success') { + context = await ensureAdoptedParentId(provider, key, gen, context); + if (!isCurrent(key, gen)) return; + deliverCompletion(jobRef, { status, results: [], context }); + return; + } + + // A results-fetch error must never read as "succeeded, no outputs". + let recorded: Awaited>; + try { + recorded = await fetchAndRecordResults( + provider, + jobId, + key, + gen, + context + ); + } catch (err) { + if (expireSessionIf(err)) return; + if (!isCurrent(key, gen)) return; + const kind = classifyError(err); + if (kind === 'transient') { + const attempts = (resultFetchRetries.get(key) ?? 0) + 1; + if (attempts <= MAX_POLL_RETRIES) { + resultFetchRetries.set(key, attempts); + scheduleResultFetchRetry( + provider, + status, + Math.min(POLL_INTERVAL_MS * 2 ** attempts, MAX_POLL_BACKOFF_MS) + ); + return; + } + } + resultFetchRetries.delete(key); + useMessageStore().addError('Failed to fetch job results', { + error: ensureError(err), + }); + return; + } + if (recorded == null) return; + resultFetchRetries.delete(key); + const { results } = recorded; + context = recorded.context; + + const missing = baseImageMissing(context); + if (missing) { + const count = results.length; + useMessageStore().addWarning( + 'Base image was closed before the job finished', + { + details: `${count} ${plural(count, 'result')} for this job are available in the Jobs panel but were not attached automatically.`, + } + ); + } + deliverCompletion(jobRef, { + status, + results, + context, + baseImageMissing: missing, + }); + } finally { + // A delivered completion is owned by terminalCompletions; a pre-delivery + // failure may be attempted again. + inFlightCompletions.delete(key); + } + } + + async function pollOnce(provider: ProcessingProvider, jobId: string) { + const key = jobKey({ providerId: provider.config.id, jobId }); + const gen = jobGenerations.get(key); + let status: ProcessingJobStatus; + try { + status = await provider.getJob(jobId); + } catch (err) { + if (!isCurrent(key, gen)) return; + handlePollError(provider, jobId, err); + return; + } + if (!isCurrent(key, gen)) return; + pollRetries.delete(key); + recordJob(provider.config.id, status); + if (completionReady(status)) { + stopPolling(key); + await fireCompletion(provider, status); + return; + } + scheduleNextPoll(provider, jobId, POLL_INTERVAL_MS); + } + + async function submitJob( + providerId: string, + taskId: string, + values: Record, + submittedContext: Omit< + SubmittedJobContext, + 'jobId' | 'submittedAt' | 'taskId' | 'providerId' + > + ): Promise { + try { + const provider = await getProvider(providerId); + const jobRef = await provider.runTask(taskId, values); + const jobId = jobRef.jobId; + recordSubmittedContext({ + jobId, + taskId, + providerId, + submittedAt: new Date().toISOString(), + ...submittedContext, + }); + + // A provider may hand back an already-terminal job: route it through the + // completion path but never register a poller. + const initialStatus: ProcessingJobStatus = jobRef.status + ? { ...jobRef.status, jobId } + : { jobId, state: 'pending', resultState: 'waiting' }; + recordJob(providerId, initialStatus); + if (completionReady(initialStatus)) { + await fireCompletion(provider, initialStatus); + return jobId; + } + + pollOnce(provider, jobId); + return jobId; + } catch (err) { + // Re-thrown so the caller's form resets its submitting flag. + useMessageStore().addError('Failed to submit job', { + error: ensureError(err), + }); + throw err; + } + } + + // Records no terminal status itself: cancel is best-effort, so the existing + // poller stays the single source of convergence. + async function cancelJob(jobRef: TrackedJobRef): Promise { + const context = submittedContexts.get(jobKey(jobRef)); + if (!context) return false; + try { + const provider = await getProvider(jobRef.providerId); + await provider.cancelJob(jobRef.jobId); + return true; + } catch (err) { + // Leave the poller running so a job that terminates on its own still + // converges. + if (expireSessionIf(err)) return false; + useMessageStore().addError('Failed to cancel job', { + error: ensureError(err), + }); + return false; + } + } + + async function deleteJob(jobRef: TrackedJobRef) { + const key = jobKey(jobRef); + // Result application mutates the scene and cannot be cancelled midway. + // Serialize deletion behind it so the scene never changes after deletion. + await jobResultApplications.get(key)?.catch(() => {}); + const context = submittedContexts.get(key); + if (!context) return; + // stopPolling re-mints the generation rather than removing it, which keeps + // the job tracked if the server delete fails below. + stopPolling(key); + try { + const provider = await getProvider(jobRef.providerId); + await provider.deleteJob(jobRef.jobId); + // Leaving completion bookkeeping behind would suppress a recycled jobId as + // already-delivered and leak its retained JobCompletion. + perJobCollections.forEach((collection) => collection.delete(key)); + } catch (err) { + useMessageStore().addError('Failed to delete job', { + error: ensureError(err), + }); + } + } + + // A job that finished while VolView was closed never ran the live-completion + // path, so its results are not in `jobResults`. + async function loadJobResults(jobRef: TrackedJobRef) { + const key = jobKey(jobRef); + if (jobResults.has(key)) return; + const context = submittedContexts.get(key); + if (!context) return; + // A delete landing while this fetch is in flight must not commit results or + // an error toast afterward. + const gen = jobGenerations.get(key); + try { + const provider = await getProvider(jobRef.providerId); + await fetchAndRecordResults(provider, jobRef.jobId, key, gen, context); + } catch (err) { + if (expireSessionIf(err)) return; + if (!isCurrent(key, gen)) return; + useMessageStore().addError('Failed to load job results', { + error: ensureError(err), + }); + } + } + + function recordJobResultApplication( + jobRef: TrackedJobRef, + failedResultIds: string[] + ) { + const key = jobKey(jobRef); + if (!submittedContexts.has(key)) return; + if (failedResultIds.length === 0) { + jobResultsApplied.add(key); + jobResultApplicationFailures.delete(key); + return; + } + jobResultsApplied.delete(key); + jobResultApplicationFailures.set(key, new Set(failedResultIds)); + } + + function resultsPendingApplication( + jobRef: TrackedJobRef + ): ProcessingResult[] { + const key = jobKey(jobRef); + if (jobResultsApplied.has(key)) return []; + const results = jobResults.get(key) ?? []; + const failures = jobResultApplicationFailures.get(key); + return failures + ? results.filter((result) => failures.has(result.id)) + : results; + } + + function applyJobResults(jobRef: TrackedJobRef): Promise { + const key = jobKey(jobRef); + const existing = jobResultApplications.get(key); + if (existing) return existing; + + const gen = jobGenerations.get(key); + if ( + gen === undefined || + !submittedContexts.has(key) || + !jobResults.has(key) + ) + return Promise.resolve(); + + // Start on the next microtask so the promise is registered before any + // result loader can yield and admit a second caller. + const application = Promise.resolve().then(async () => { + if (!isCurrent(key, gen)) return; + const context = contextForAutoLoad(submittedContexts.get(key)); + const pending = resultsPendingApplication(jobRef); + const { failedResultIds } = await autoLoadProcessingResults( + pending, + context + ); + if (!isCurrent(key, gen)) return; + recordJobResultApplication(jobRef, failedResultIds); + }); + jobResultApplications.set(key, application); + + const cleanup = () => { + if (jobResultApplications.get(key) === application) { + jobResultApplications.delete(key); + } + }; + void application.then(cleanup, cleanup); + return application; + } + + async function loadJobHistoryDetail(jobRef: TrackedJobRef) { + const key = jobKey(jobRef); + if (jobHistoryDetails.has(key)) return; + const context = submittedContexts.get(key); + if (!context) return; + const gen = jobGenerations.get(key); + try { + const provider = await getProvider(jobRef.providerId); + const detail = await provider.getJobHistoryDetail(jobRef.jobId); + if (!isCurrent(key, gen)) return; + jobHistoryDetails.set(key, detail); + } catch (err) { + if (!isCurrent(key, gen)) return; + useMessageStore().addError('Failed to load job details', { + error: ensureError(err), + }); + } + } + + // Terminal summaries are observability rows only; non-terminal ones join the + // normal poller so jobs that finish while open keep the live-result behavior. + async function adoptDiscoveredJob( + provider: ProcessingProvider, + providerId: string, + summary: JobHistorySummary + ) { + const jobRef: TrackedJobRef = { providerId, jobId: summary.jobId }; + const key = jobKey(jobRef); + jobHistory.set(key, { ...summary, providerId }); + // An in-session submit already owns it. + if (submittedContexts.has(key) || jobs.has(key)) return; + + recordSubmittedContext({ + jobId: summary.jobId, + taskId: summary.taskId, + providerId, + submittedAt: summary.createdAt, + display: { taskTitle: summary.taskTitle, parameters: [] }, + }); + // A clearJobs or deleteJob landing while the getJob below is in flight must + // not re-record the adopted job. + const gen = jobGenerations.get(key); + + if (isTerminalJobState(summary.state)) { + recordJob(providerId, { + jobId: summary.jobId, + state: summary.state, + resultState: summary.resultState, + ...(summary.progress != null ? { progress: summary.progress } : {}), + }); + return; + } + + let status: ProcessingJobStatus; + try { + status = await provider.getJob(summary.jobId); + } catch (err) { + if (!isCurrent(key, gen)) return; + handlePollError(provider, summary.jobId, err); + return; + } + if (!isCurrent(key, gen)) return; + recordJob(providerId, status); + if (completionReady(status)) { + await fireCompletion(provider, status); + return; + } + // scheduleNextPoll, not pollOnce: the status above is already fresh. + scheduleNextPoll(provider, summary.jobId, POLL_INTERVAL_MS); + } + + async function loadProviderJobHistory(providerId: string) { + let provider: ProcessingProvider; + try { + provider = await getProvider(providerId); + } catch (err) { + jobHistoryErrors.set( + providerId, + getErrorDetail(err, 'Failed to load job history') + ); + return; + } + const cursor = jobHistoryCursors.get(providerId) ?? undefined; + try { + const page = await provider.listJobHistory(cursor); + const stalled = page.nextCursor !== null && page.nextCursor === cursor; + if (!stalled) { + jobHistoryCursors.set(providerId, page.nextCursor); + jobHistoryErrors.delete(providerId); + } + await Promise.all( + page.jobs.map((summary) => + adoptDiscoveredJob(provider, providerId, summary) + ) + ); + if (stalled) { + jobHistoryErrors.set( + providerId, + 'Job history pagination did not advance' + ); + } + } catch (err) { + console.error('Job re-discovery failed', err); + jobHistoryErrors.set( + providerId, + getErrorDetail(err, 'Failed to load job history') + ); + } + } + + async function loadMoreJobHistory() { + if (jobHistoryComplete.value) return; + if (jobHistoryRequest) return jobHistoryRequest; + jobHistoryRequest = (async () => { + jobHistoryLoading.value = true; + const pending = Array.from(configs.keys()).filter( + (providerId) => jobHistoryCursors.get(providerId) !== null + ); + await Promise.all(pending.map(loadProviderJobHistory)); + jobHistoryComplete.value = Array.from(configs.keys()).every( + (providerId) => jobHistoryCursors.get(providerId) === null + ); + })(); + try { + await jobHistoryRequest; + } finally { + jobHistoryLoading.value = false; + jobHistoryRequest = null; + } + } + + // The leading fetch doubles as the retry after an error. + async function loadAllJobHistory() { + let pageCount = 0; + do { + await loadMoreJobHistory(); + pageCount += 1; + } while ( + pageCount < MAX_JOB_HISTORY_PAGES && + !jobHistoryComplete.value && + jobHistoryError.value == null + ); + + if ( + pageCount >= MAX_JOB_HISTORY_PAGES && + !jobHistoryComplete.value && + jobHistoryError.value == null + ) { + Array.from(configs.keys()) + .filter((providerId) => jobHistoryCursors.get(providerId) !== null) + .forEach((providerId) => + jobHistoryErrors.set( + providerId, + `Job history exceeded ${MAX_JOB_HISTORY_PAGES} pages` + ) + ); + } + } + + async function adoptJobHistory() { + jobHistoryCursors.clear(); + jobHistoryErrors.clear(); + jobHistoryComplete.value = configs.size === 0; + await loadMoreJobHistory(); + } + + return { + configs, + instances, + jobs, + jobHistory, + jobHistoryRows, + jobHistoryDetails, + jobHistoryLoading, + jobHistoryComplete, + jobHistoryError, + jobResults, + jobResultMissing, + jobResultsApplied, + jobResultApplications, + submittedContexts, + sessionExpired, + + registerProviderConfig, + clearProviders, + getProvider, + recordJob, + recordSubmittedContext, + submitJob, + cancelJob, + deleteJob, + loadJobResults, + applyJobResults, + recordJobResultApplication, + resultsPendingApplication, + loadJobHistoryDetail, + contextForAutoLoad, + onJobComplete, + stopPolling, + adoptJobHistory, + loadMoreJobHistory, + loadAllJobHistory, + }; +}); diff --git a/src/processing/types.ts b/src/processing/types.ts new file mode 100644 index 000000000..3ab233ac9 --- /dev/null +++ b/src/processing/types.ts @@ -0,0 +1,142 @@ +import type { TaskSpecEnvelope } from '@/src/processing/engine/taskSpec'; +import type { + InputValue, + StageInputDescriptor, + JobState, + ResultState, + JobHistoryPage, + JobHistoryDetail, + ResultSource, + SegmentDescriptor, +} from '@/backend-contract'; +import { JOB_STATES } from '@/backend-contract'; + +export { JOB_STATES }; +export type { JobState }; + +export const isTerminalJobState = (state: JobState): boolean => + state === 'success' || state === 'error' || state === 'cancelled'; + +export const missingJobErrorDetails = (jobId: string): string => + `The provider reported this job failed but did not include error details. Job ID: ${jobId}`; + +export type StageInputRequest = { + file: Blob; + descriptor: StageInputDescriptor; +}; + +export type ProcessingProviderConfig = { + id: string; + label: string; + baseUrl: string; + // Job-addressed routes live on a context-free surface, so an absent value is a + // configuration error rather than a fallback onto baseUrl. + jobsBaseUrl: string; +}; + +// Two context-scoped providers may each mint the same raw jobId, so the id alone +// cannot key a job-owned collection. +export type TrackedJobRef = { + providerId: string; + jobId: string; +}; + +// Provider ids contain colons, so a delimiter-joined key would be ambiguous. +export const jobKey = ({ providerId, jobId }: TrackedJobRef): string => + JSON.stringify([providerId, jobId]); + +export type ProcessingProvider = { + config: ProcessingProviderConfig; + + listTasks: () => Promise; + getTaskSpec: (taskId: string) => Promise; + runTask: ( + taskId: string, + values: Record + ) => Promise; + getJob: (jobId: string) => Promise; + getResults: (jobId: string) => Promise; + // Returns a projected status only; the poller converges the UI, since a job may + // finish before the cancel lands. + cancelJob: (jobId: string) => Promise; + deleteJob: (jobId: string) => Promise; + stageInput: (request: StageInputRequest) => Promise; + listJobHistory: (cursor?: string) => Promise; + getJobHistoryDetail: (jobId: string) => Promise; +}; + +export type TaskSummary = { + id: string; + title: string; + description?: string; + category?: string[]; +}; + +export type ProcessingValue = + | string + | number + | boolean + | string[] + | number[] + | InputValue + | null; + +export type ProcessingJobStatus = { + jobId: string; + state: JobState; + resultState: ResultState; + progress?: number; + errorTail?: string; +}; + +export type ProcessingJobRef = { + jobId: string; + // A synchronous backend can return an already-terminal job, which skips the + // poller; absent status means the job is treated as pending and polled. + status?: ProcessingJobStatus; +}; + +export type ProcessingResult = { + id: string; + name: string; + url: string; + // Typed loosely because it arrives as untrusted wire JSON; unknown or malformed + // values carry no state directive. + intent?: string; + mimeType?: string; + size?: number; + segments?: SegmentDescriptor[]; + // Durable idempotency key for segment-group result application. + source?: ResultSource; +}; + +// `missing` is reported rather than dropped so "success with no outputs" stays +// distinct from "outputs deleted". +export type JobResultsBundle = { + results: ProcessingResult[]; + missing: number; +}; + +export type SubmittedJobParameterDisplay = { + id: string; + label: string; + value: string; + summary?: boolean; +}; + +export type SubmittedJobDisplay = { + taskTitle: string; + inputName?: string; + parameters: SubmittedJobParameterDisplay[]; +}; + +// Remembered at submission time so result outputs auto-attach to the originating +// dataset. +export type SubmittedJobContext = { + jobId: string; + taskId: string; + providerId: string; + submittedAt: string; + activeDatasetId?: string; + display?: SubmittedJobDisplay; +}; diff --git a/src/store/__tests__/annotationToolImageDelete.spec.ts b/src/store/__tests__/annotationToolImageDelete.spec.ts new file mode 100644 index 000000000..2af22d133 --- /dev/null +++ b/src/store/__tests__/annotationToolImageDelete.spec.ts @@ -0,0 +1,152 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import { effectScope, nextTick } from 'vue'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; + +import { onImageDeleted } from '@/src/composables/onImageDeleted'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useRulerStore } from '@/src/store/tools/rulers'; +import { usePolygonStore } from '@/src/store/tools/polygons'; +import { useRectangleStore } from '@/src/store/tools/rectangles'; +import type { Ruler } from '@/src/types/ruler'; +import type { RequiredWithPartial } from '@/src/types'; + +// --------------------------------------------------------------------------- +// Delete-base-then-save, tool half: removing an +// annotated dataset must remove its annotation tools too — an orphaned +// imageID serialized into the save manifest is exactly the backend's +// intentionally fail-closed 400 ('tool has unresolvable imageID'), turning a +// routine delete gesture into a permanently unsavable session. +// +// The mechanism is the same onImageDeleted subscription segmentGroups already +// uses for its labelmap cascade — so this spec also pins the composable +// itself: it must actually FIRE on image-cache deletion (a watch on the ref +// of the reactive index never triggers on a key delete; the composable +// watches the key SET). +// --------------------------------------------------------------------------- + +const seatImage = (id: string, name: string) => + useImageCacheStore().addVTKImageData(vtkImageData.newInstance(), name, { + id, + }); + +const makeRuler = ( + imageID: string +): RequiredWithPartial< + Ruler, + | 'id' + | 'color' + | 'strokeWidth' + | 'label' + | 'labelName' + | 'hidden' + | 'metadata' + | 'frame' +> => ({ + firstPoint: [1, 1, 1], + secondPoint: [2, 2, 2], + imageID, + name: 'Ruler', + frameOfReference: { + planeNormal: [1, 0, 0], + planeOrigin: [0, 0, 0], + }, + slice: 23, + placing: false, +}); + +describe('onImageDeleted — fires on image-cache deletion', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('reports exactly the deleted image ids', async () => { + seatImage('img-1', 'CT'); + seatImage('img-2', 'PET'); + + const seen: string[][] = []; + onImageDeleted((ids) => seen.push([...ids])); + + useImageCacheStore().removeImage('img-1'); + await nextTick(); + + expect(seen).toEqual([['img-1']]); + }); + + it('stays quiet on additions', async () => { + const seen: string[][] = []; + onImageDeleted((ids) => seen.push([...ids])); + + seatImage('img-1', 'CT'); + await nextTick(); + + expect(seen).toEqual([]); + }); + + it('keeps later subscribers alive when the first scope is disposed', () => { + seatImage('img-1', 'CT'); + const first = effectScope(); + const second = effectScope(); + const firstCallback = vi.fn(); + const secondCallback = vi.fn(); + + first.run(() => onImageDeleted(firstCallback)); + second.run(() => onImageDeleted(secondCallback)); + first.stop(); + + useImageCacheStore().removeImage('img-1'); + + expect(firstCallback).not.toHaveBeenCalled(); + expect(secondCallback).toHaveBeenCalledWith(['img-1']); + second.stop(); + }); +}); + +describe('annotation tools — delete-base cleanup', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('removes exactly the deleted image’s tools; other images’ tools survive', async () => { + seatImage('img-1', 'CT'); + seatImage('img-2', 'PET'); + + const rulerStore = useRulerStore(); + const doomed = rulerStore.addRuler(makeRuler('img-1')); + const kept = rulerStore.addRuler(makeRuler('img-2')); + + useImageCacheStore().removeImage('img-1'); + await nextTick(); + + expect(rulerStore.rulerByID).not.toHaveProperty(doomed); + expect(rulerStore.rulerByID).toHaveProperty(kept); + }); + + it('a subsequent serialize carries NO tool referencing the deleted image', async () => { + seatImage('img-1', 'CT'); + + const rulerStore = useRulerStore(); + rulerStore.addRuler(makeRuler('img-1')); + + useImageCacheStore().removeImage('img-1'); + await nextTick(); + + const { tools } = rulerStore.serializeTools(); + expect(tools).toEqual([]); + }); + + it('cleans up every annotation tool store, not just rulers', async () => { + seatImage('img-1', 'CT'); + + const polygonStore = usePolygonStore(); + const rectangleStore = useRectangleStore(); + polygonStore.addTool({ imageID: 'img-1', placing: false }); + rectangleStore.addTool({ imageID: 'img-1', placing: false }); + + useImageCacheStore().removeImage('img-1'); + await nextTick(); + + expect(polygonStore.serializeTools().tools).toEqual([]); + expect(rectangleStore.serializeTools().tools).toEqual([]); + }); +}); diff --git a/src/store/__tests__/datasetRemoveCascade.spec.ts b/src/store/__tests__/datasetRemoveCascade.spec.ts new file mode 100644 index 000000000..ff27f8d96 --- /dev/null +++ b/src/store/__tests__/datasetRemoveCascade.spec.ts @@ -0,0 +1,211 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; + +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useDatasetStore } from '@/src/store/datasets'; +import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useRulerStore } from '@/src/store/tools/rulers'; +import { useViewStore } from '@/src/store/views'; +import { useCropStore } from '@/src/store/tools/crop'; +import { usePaintToolStore } from '@/src/store/tools/paint'; + +// Bind an existing (default-layout) view to a dataset via the public API — +// `addView` is internal, but every fresh store already seats slot views. +const bindFirstViewTo = (dataID: string) => { + const viewStore = useViewStore(); + const [viewID] = viewStore.viewIDs; + viewStore.setDataForView(viewID, dataID); + return viewID; +}; + +// --------------------------------------------------------------------------- +// Delete-a-dataset-then-save, the whole cascade. +// +// Removing a dataset must clean EVERY store that references it SYNCHRONOUSLY — +// before `remove()` returns — so a `serialize()` on the same tick (what +// applying a job "Open" result does) can never snapshot a dangling reference. +// +// Note the absence of `await nextTick()` in these tests: the whole cascade is +// synchronous, so a same-tick serialize never sees orphaned ids. +// --------------------------------------------------------------------------- + +const seatImage = (id: string, name: string) => { + const img = vtkImageData.newInstance(); + img.setDimensions(2, 2, 2); + const scalars = vtkDataArray.newInstance({ + name: 'scalars', + numberOfComponents: 1, + values: new Uint8Array(8), + }); + img.getPointData().setScalars(scalars); + return useImageCacheStore().addVTKImageData(img, name, { id }); +}; + +const makeRuler = (imageID: string) => + ({ + firstPoint: [1, 1, 1], + secondPoint: [2, 2, 2], + imageID, + name: 'Ruler', + frameOfReference: { + planeNormal: [1, 0, 0], + planeOrigin: [0, 0, 0], + }, + slice: 23, + placing: false, + }) as never; + +describe('dataset remove — synchronous reference cascade', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('clears segment groups whose parent image was removed', () => { + seatImage('img-1', 'CT'); + const segmentGroups = useSegmentGroupStore(); + const groupId = segmentGroups.newLabelmapFromImage('img-1'); + expect(groupId).not.toBeNull(); + expect(segmentGroups.orderByParent['img-1']).toContain(groupId); + + useDatasetStore().remove('img-1'); + + expect(segmentGroups.orderByParent['img-1'] ?? []).toEqual([]); + expect(segmentGroups.metadataByID).not.toHaveProperty(groupId as string); + }); + + it('clears ALL segment groups when an image has several (no splice-skip)', () => { + seatImage('img-1', 'CT'); + const segmentGroups = useSegmentGroupStore(); + const groupA = segmentGroups.newLabelmapFromImage('img-1'); + const groupB = segmentGroups.newLabelmapFromImage('img-1'); + const groupC = segmentGroups.newLabelmapFromImage('img-1'); + expect(groupA).not.toBeNull(); + expect(groupB).not.toBeNull(); + expect(groupC).not.toBeNull(); + expect(segmentGroups.orderByParent['img-1']).toEqual([ + groupA, + groupB, + groupC, + ]); + + useDatasetStore().remove('img-1'); + + expect(segmentGroups.orderByParent['img-1'] ?? []).toEqual([]); + [groupA, groupB, groupC].forEach((id) => { + expect(segmentGroups.metadataByID).not.toHaveProperty(id as string); + expect(segmentGroups.dataIndex).not.toHaveProperty(id as string); + }); + }); + + it('clears annotation tools bound to the removed image', () => { + seatImage('img-1', 'CT'); + const rulerStore = useRulerStore(); + rulerStore.addRuler(makeRuler('img-1')); + + useDatasetStore().remove('img-1'); + + expect(rulerStore.serializeTools().tools).toEqual([]); + }); + + it('unbinds views pointing at the removed dataset', () => { + seatImage('img-1', 'CT'); + const viewStore = useViewStore(); + bindFirstViewTo('img-1'); + expect(viewStore.getViewsForData('img-1')).toHaveLength(1); + + useDatasetStore().remove('img-1'); + + expect(viewStore.getViewsForData('img-1')).toEqual([]); + }); + + it('drops crop state keyed by the removed image', () => { + seatImage('img-1', 'CT'); + const cropStore = useCropStore(); + cropStore.setCropping('img-1', { + Sagittal: [0, 1], + Coronal: [0, 1], + Axial: [0, 1], + }); + expect('img-1' in cropStore.croppingByImageID).toBe(true); + + useDatasetStore().remove('img-1'); + + expect('img-1' in cropStore.croppingByImageID).toBe(false); + }); + + it('nulls the active paint segment group when its parent image is removed', () => { + seatImage('img-1', 'CT'); + const segmentGroups = useSegmentGroupStore(); + const paintStore = usePaintToolStore(); + const groupId = segmentGroups.newLabelmapFromImage('img-1'); + paintStore.setActiveSegmentGroup(groupId); + expect(paintStore.activeSegmentGroupID).toBe(groupId); + + useDatasetStore().remove('img-1'); + + expect(paintStore.activeSegmentGroupID).toBeNull(); + }); + + it('leaves references to OTHER datasets intact', () => { + seatImage('img-1', 'CT'); + seatImage('img-2', 'PET'); + const rulerStore = useRulerStore(); + const viewStore = useViewStore(); + rulerStore.addRuler(makeRuler('img-2')); + bindFirstViewTo('img-2'); + + useDatasetStore().remove('img-1'); + + expect(rulerStore.serializeTools().tools).toHaveLength(1); + expect(viewStore.getViewsForData('img-2')).toHaveLength(1); + }); +}); + +// Production wiring of the dev-only save backstop: each cascade-owning store +// module declares its manifest references (declareManifestRefs) next to its +// cascade. This walks the REAL declarations — loaded by the store imports +// above — over a manifest where every reference dangles, pinning that the +// backstop's coverage includes each cascade-owned section. +describe('manifest-ref declarations (cascade-owned save backstop coverage)', () => { + it('covers every cascade-owned manifest section', async () => { + // Side-effect imports for the declaring modules the tests above don't use. + await import('@/src/store/tools/rectangles'); + await import('@/src/store/tools/polygons'); + const { collectManifestRefs } = await import('@/src/core/manifestRefs'); + + const refs = collectManifestRefs({ + viewByID: { 'view-1': { dataID: 'ghost-img' } }, + activeView: 'ghost-view', + layoutSlots: ['ghost-slot'], + tools: { + rulers: { tools: [{ imageID: 'ghost-ruler-img' }] }, + rectangles: { tools: [{ imageID: 'ghost-rect-img' }] }, + polygons: { tools: [{ imageID: 'ghost-poly-img' }] }, + crop: { 'ghost-crop-img': {} }, + paint: { activeSegmentGroupID: 'ghost-group' }, + }, + }); + + const found = refs.map((ref) => `${ref.where} -> ${ref.kind} ${ref.id}`); + expect(found).toContain('viewByID[view-1].dataID -> dataset ghost-img'); + expect(found).toContain('activeView -> view ghost-view'); + expect(found).toContain('layoutSlots -> view ghost-slot'); + expect(found).toContain( + 'tools.rulers[0].imageID -> dataset ghost-ruler-img' + ); + expect(found).toContain( + 'tools.rectangles[0].imageID -> dataset ghost-rect-img' + ); + expect(found).toContain( + 'tools.polygons[0].imageID -> dataset ghost-poly-img' + ); + expect(found).toContain( + 'tools.crop[ghost-crop-img] -> dataset ghost-crop-img' + ); + expect(found).toContain( + 'tools.paint.activeSegmentGroupID -> segmentGroup ghost-group' + ); + }); +}); diff --git a/src/store/__tests__/datasets-layers.spec.ts b/src/store/__tests__/datasets-layers.spec.ts new file mode 100644 index 000000000..b0373abe7 --- /dev/null +++ b/src/store/__tests__/datasets-layers.spec.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; + +// Mock only the vtk/image leaf dependencies `_addLayer` reaches, so the REAL +// layers store + REAL `useErrorMessage` wrapper run. The former suite mocked +// `addLayer` itself, which hid the bug this file guards: `addLayer` must return +// the built layer id on success (and `undefined` only when a build throws and +// `useErrorMessage` swallows it). vtkBoundingBox stays real — its `intersects` +// is deterministic over the numeric bounds below. +const { getImage, ensureSameSpace, untilLoaded, imageCache } = vi.hoisted( + () => ({ + getImage: vi.fn(), + ensureSameSpace: vi.fn(), + untilLoaded: vi.fn(), + imageCache: { + getImageMetadata: vi.fn(), + addVTKImageData: vi.fn(), + removeImage: vi.fn(), + onImageDeleted: vi.fn(() => () => {}), + }, + }) +); + +vi.mock('@/src/utils/dataSelection', () => ({ getImage })); +vi.mock('@/src/io/resample/resample', () => ({ ensureSameSpace })); +vi.mock('@/src/composables/untilLoaded', () => ({ untilLoaded })); +vi.mock('@/src/store/image-cache', () => ({ + useImageCacheStore: () => imageCache, +})); + +import { useLayersStore } from '@/src/store/datasets-layers'; + +const imageWithBounds = (bounds: number[]) => ({ getBounds: () => bounds }); + +beforeEach(() => { + setActivePinia(createPinia()); + vi.clearAllMocks(); + untilLoaded.mockResolvedValue(undefined); + imageCache.getImageMetadata.mockReturnValue({ name: 'layer' }); + // ensureSameSpace echoes the source image; identity is enough here. + ensureSameSpace.mockImplementation( + async (_parent: unknown, source: unknown) => source + ); +}); + +describe('useLayersStore.addLayer return contract', () => { + it('returns the built layer id when a valid pair overlaps', async () => { + // Both images share physical space, so the build succeeds end to end. + getImage.mockImplementation(async () => + imageWithBounds([0, 2, 0, 2, 0, 2]) + ); + const store = useLayersStore(); + + const id = await store.addLayer('parent', 'source'); + + expect(id).toBe('parent::source'); + expect(store.getLayers('parent')).toHaveLength(1); + expect(imageCache.addVTKImageData).toHaveBeenCalledTimes(1); + }); + + it('returns undefined and removes the provisional layer when the build fails', async () => { + // Non-intersecting bounds: `_addLayer` deletes its provisional layer and + // throws; `useErrorMessage` swallows the throw and resolves to `undefined`. + getImage.mockImplementation(async (selection: unknown) => + selection === 'parent' + ? imageWithBounds([0, 1, 0, 1, 0, 1]) + : imageWithBounds([5, 6, 5, 6, 5, 6]) + ); + const store = useLayersStore(); + + const id = await store.addLayer('parent', 'source'); + + expect(id).toBeUndefined(); + expect(store.getLayers('parent')).toHaveLength(0); + expect(imageCache.addVTKImageData).not.toHaveBeenCalled(); + }); +}); + +describe('useLayersStore.remove', () => { + beforeEach(() => { + getImage.mockImplementation(async () => + imageWithBounds([0, 2, 0, 2, 0, 2]) + ); + }); + + it('removing a base image prunes and disposes the layers it owns', async () => { + // Removing a parent clears its parentToLayers entry and evicts each + // resampled image it owns from the cache. + const store = useLayersStore(); + await store.addLayer('parent', 'source'); + + store.remove('parent'); + + expect(store.getLayers('parent')).toHaveLength(0); + expect(imageCache.removeImage).toHaveBeenCalledWith('parent::source'); + }); + + it('removing a layer source prunes it from every parent layer list', async () => { + const store = useLayersStore(); + await store.addLayer('parent', 'source'); + + store.remove('source'); + + expect(store.getLayers('parent')).toHaveLength(0); + expect(imageCache.removeImage).toHaveBeenCalledWith('parent::source'); + }); +}); diff --git a/src/store/__tests__/duplicateDatasetSave.spec.ts b/src/store/__tests__/duplicateDatasetSave.spec.ts new file mode 100644 index 000000000..5f87cf353 --- /dev/null +++ b/src/store/__tests__/duplicateDatasetSave.spec.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import JSZip from 'jszip'; +import { useDatasetStore } from '@/src/store/datasets'; +import { uriToDataSource } from '@/src/io/import/dataSource'; +import { + normalizeManifest, + MANIFEST_VERSION, +} from '@/src/io/state-file/serialize'; +import type { Manifest } from '@/src/io/state-file/schema'; +import { + isRemoteDataSource, + type DataSource, +} from '@/src/io/import/dataSource'; +import type { Chunk } from '@/src/core/streaming/chunk'; +import { Tags } from '@/src/core/dicomTags'; +import { FILE_EXT_TO_MIME } from '@/src/io/mimeTypes'; + +// --------------------------------------------------------------------------- +// Regression: importing the same data twice (e.g. dragging the same DICOM +// folder in again) yields the same dataID. The provenance list must not grow a +// duplicate entry for it — a duplicate dataset id makes validateCoreGraph +// abort the save that used to succeed. +// --------------------------------------------------------------------------- + +describe('dataset provenance dedup on re-import', () => { + beforeEach(() => { + setActivePinia(createPinia()); + }); + + it('re-adding an already-loaded dataID neither duplicates it nor aborts save', async () => { + const store = useDatasetStore(); + const dataSource = uriToDataSource('https://ex/ct.nrrd', 'ct.nrrd'); + store.addDataSources([{ dataID: 'vol-1', dataSource }]); + store.addDataSources([{ dataID: 'vol-1', dataSource }]); + + const zip = new JSZip(); + const manifest = { + version: MANIFEST_VERSION, + datasets: [], + dataSources: [], + datasetFilePath: {}, + } as Manifest; + await store.serialize({ zip, manifest }); + + expect(manifest.datasets).toHaveLength(1); + expect(() => normalizeManifest(manifest, zip)).not.toThrow(); + }); + + const dicomBatch = (instances: string[]): DataSource => ({ + type: 'collection', + sources: instances.map((instance) => ({ + type: 'chunk', + chunk: { + metadata: [[Tags.SOPInstanceUID, instance]], + } as Chunk, + mime: FILE_EXT_TO_MIME.dcm, + parent: uriToDataSource( + `https://ex/series/${instance}.dcm`, + `${instance}.dcm`, + FILE_EXT_TO_MIME.dcm + ), + })), + }); + + const localDicomBatch = (instances: string[]): DataSource => ({ + type: 'collection', + sources: instances.map((instance) => ({ + type: 'chunk', + chunk: { + metadata: [[Tags.SOPInstanceUID, instance]], + } as Chunk, + mime: FILE_EXT_TO_MIME.dcm, + parent: { + type: 'file', + file: new File([instance], `${instance}.dcm`, { + type: FILE_EXT_TO_MIME.dcm, + lastModified: 1, + }), + fileType: FILE_EXT_TO_MIME.dcm, + }, + })), + }); + + it('merges complementary DICOM batches into one complete saved dataset', async () => { + const store = useDatasetStore(); + store.addDataSources([ + { dataID: 'series-1', dataSource: dicomBatch(['sop-1', 'sop-2']) }, + ]); + store.addDataSources([ + { dataID: 'series-1', dataSource: dicomBatch(['sop-3', 'sop-4']) }, + ]); + + const zip = new JSZip(); + const manifest = { + version: MANIFEST_VERSION, + datasets: [], + dataSources: [], + datasetFilePath: {}, + } as Manifest; + await store.serialize({ zip, manifest }); + + expect(manifest.datasets).toHaveLength(1); + const datasetSourceId = manifest.datasets![0].dataSourceId; + const collection = manifest.dataSources.find( + (source) => source.id === datasetSourceId + ); + expect(collection).toMatchObject({ + type: 'collection', + sources: expect.any(Array), + }); + if (collection?.type !== 'collection') { + throw new Error('Expected collection'); + } + expect(collection.sources).toHaveLength(4); + expect(() => normalizeManifest(manifest, zip)).not.toThrow(); + }); + + it('deduplicates repeated DICOM instances by SOP Instance UID', async () => { + const store = useDatasetStore(); + store.addDataSources([ + { dataID: 'series-1', dataSource: dicomBatch(['sop-1', 'sop-2']) }, + ]); + store.addDataSources([ + { dataID: 'series-1', dataSource: dicomBatch(['sop-1', 'sop-2']) }, + ]); + + const zip = new JSZip(); + const manifest = { + version: MANIFEST_VERSION, + datasets: [], + dataSources: [], + datasetFilePath: {}, + } as Manifest; + await store.serialize({ zip, manifest }); + + const datasetSourceId = manifest.datasets![0].dataSourceId; + const collection = manifest.dataSources.find( + (source) => source.id === datasetSourceId + ); + if (collection?.type !== 'collection') { + throw new Error('Expected collection'); + } + expect(collection.sources).toHaveLength(2); + }); + + it('replaces local provenance when the same instances are re-imported remotely', () => { + const store = useDatasetStore(); + store.addDataSources([ + { dataID: 'series-1', dataSource: localDicomBatch(['sop-1', 'sop-2']) }, + ]); + store.addDataSources([ + { dataID: 'series-1', dataSource: dicomBatch(['sop-1', 'sop-2']) }, + ]); + + expect(isRemoteDataSource(store.getDataSource('series-1'))).toBe(true); + }); +}); diff --git a/src/store/__tests__/legacyManifestSegmentGroups.spec.ts b/src/store/__tests__/legacyManifestSegmentGroups.spec.ts new file mode 100644 index 000000000..c527aa6ae --- /dev/null +++ b/src/store/__tests__/legacyManifestSegmentGroups.spec.ts @@ -0,0 +1,104 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useDatasetStore } from '@/src/store/datasets'; +import { ManifestSchema } from '@/src/io/state-file/schema'; +import { resolveArtifactRestoreSources } from '@/src/io/import/processors/restoreStateFile'; + +// --------------------------------------------------------------------------- +// Backward compatibility: manifests saved before `datasets` existed (and +// composed manifest.json launches, e.g. girder_volview's) carry only +// `dataSources`, and their path-less segment groups reference the labelmap by +// `dataSourceId`. On restore every uri source stands in for a dataset keyed by +// its stringified source id — so the group's artifact resolves through that +// covering dataset, exactly as it did on main, and the consumed artifact +// dataset is removed after conversion. +// --------------------------------------------------------------------------- + +const ioMocks = vi.hoisted(() => ({ + readImage: vi.fn(), + writeSegmentation: vi.fn(async () => new Uint8Array([1, 2, 3])), +})); + +vi.mock('@/src/io/readWriteImage', () => ({ + readImage: ioMocks.readImage, + writeSegmentation: ioMocks.writeSegmentation, +})); + +const segments = { + order: [1], + byValue: { + '1': { + value: 1, + name: 'Tumor', + color: [255, 0, 0, 255] as [number, number, number, number], + visible: true, + }, + }, +}; + +// No `datasets` root: the legacy composed shape. +const legacyManifest = ManifestSchema.parse({ + version: '6.4.0', + dataSources: [ + { id: 1, type: 'uri', uri: 'https://ex/ct.nrrd', name: 'CT Chest' }, + { id: 3, type: 'uri', uri: 'https://ex/tumor.seg.nrrd', name: 'Tumor' }, + ], + segmentGroups: [ + { + id: 'sg-tumor', + dataSourceId: 3, + metadata: { name: 'sg-tumor', parentImage: '1', segments }, + }, + ], +}); + +function makeImage() { + const image = vtkImageData.newInstance(); + image.setDimensions([4, 4, 4]); + image.getPointData().setScalars( + vtkDataArray.newInstance({ + numberOfComponents: 1, + values: new Uint8Array(4 * 4 * 4), + }) + ); + image.computeTransforms(); + return image; +} + +const seatImage = (id: string, name: string) => + useImageCacheStore().addVTKImageData(makeImage(), name, { id }); + +describe('segmentGroups.deserialize — legacy manifests without `datasets`', () => { + beforeEach(() => { + setActivePinia(createPinia()); + ioMocks.readImage.mockReset(); + }); + + it('attaches a path-less group via the dataset covering its dataSourceId', async () => { + seatImage('store-ct', 'CT Chest'); + seatImage('store-seg', 'Tumor'); + const removeSpy = vi.spyOn(useDatasetStore(), 'remove'); + + const store = useSegmentGroupStore(); + const { segmentGroupIDMap: idMap, skipped } = await store.deserialize( + legacyManifest, + [], + // Restore keys every fallback dataset by its stringified source id. + { '1': 'store-ct', '3': 'store-seg' }, + resolveArtifactRestoreSources(legacyManifest) + ); + + expect(skipped).toEqual([]); + expect(idMap['sg-tumor']).toBeDefined(); + expect( + Object.values(store.metadataByID).some((m) => m.name === 'sg-tumor') + ).toBe(true); + // The consumed artifact dataset is removed after conversion. + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledWith('store-seg'); + }); +}); diff --git a/src/store/__tests__/remote-save-state.spec.ts b/src/store/__tests__/remote-save-state.spec.ts new file mode 100644 index 000000000..3c8894a2b --- /dev/null +++ b/src/store/__tests__/remote-save-state.spec.ts @@ -0,0 +1,168 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createPinia, setActivePinia } from 'pinia'; + +vi.mock('@/src/utils/fetch', () => ({ + $fetch: vi.fn().mockResolvedValue({ ok: true }), +})); +vi.mock('@/src/io/state-file/serialize', () => ({ + serialize: vi + .fn() + .mockResolvedValue(new Blob(['x'], { type: 'application/zip' })), +})); + +import useRemoteSaveStateStore from '@/src/store/remote-save-state'; +import { $fetch } from '@/src/utils/fetch'; +import { useMessageStore } from '@/src/store/messages'; + +describe('remote save target', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.mocked($fetch).mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('accepts a same-origin save URL', () => { + const store = useRemoteSaveStateStore(); + const url = `${window.location.origin}/api/session/save`; + + store.setSaveUrl(url); + + expect(store.saveUrl).toBe(url); + }); + + it('accepts a relative save URL (it resolves same-origin)', () => { + const store = useRemoteSaveStateStore(); + + store.setSaveUrl('/api/v1/item/abc/volview'); + + expect(store.saveUrl).toBe('/api/v1/item/abc/volview'); + }); + + it('refuses a cross-origin save URL and never POSTs the session to it', async () => { + // A crafted ?save=https://attacker.example/ would otherwise exfiltrate the + // whole serialized session (plus any bearer $fetch attaches). + const store = useRemoteSaveStateStore(); + + store.setSaveUrl('https://attacker.example/collect'); + + // Empty target inerts the save UI, which is gated on a non-empty saveUrl. + expect(store.saveUrl).toBe(''); + await store.saveState(); + expect($fetch).not.toHaveBeenCalled(); + expect( + useMessageStore().messages.some((m) => m.title === 'Save Disabled') + ).toBe(true); + }); + + // A protocol-relative URL is the easy way to miss a naive `startsWith('http')` + // style check; javascript:/data: resolve to a null origin; a malformed URL + // fails to parse at all. All are off-origin and must be refused. + it.each([ + ['//evil.example/collect', 'protocol-relative'], + ['javascript:alert(1)', 'null origin'], + ['data:text/plain,x', 'null origin'], + ['http://', 'unparseable'], + ])('refuses %s (%s)', (url) => { + const store = useRemoteSaveStateStore(); + + store.setSaveUrl(url); + + expect(store.saveUrl).toBe(''); + }); +}); + +// A successful save repoints ONLY the tab's `urls=` at the returned +// `resumeUrl` so a future F5 reloads the save — no reload, and `save=`, the +// in-memory save target, and `config=` are all untouched. No `resumeUrl` (or +// an unparseable body) leaves the tab as-is. +describe('resume repoint on save', () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.mocked($fetch).mockClear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + window.history.replaceState(null, '', window.location.pathname); + }); + + it('repoints urls= to the resumeUrl and leaves the save target alone (save=/config= untouched)', async () => { + const resumeUrl = '/api/v1/item/session-123/volview'; + vi.mocked($fetch).mockResolvedValue( + new Response(JSON.stringify({ resumeUrl }), { status: 200 }) + ); + const replace = vi + .spyOn(window.history, 'replaceState') + .mockImplementation(() => {}); + const store = useRemoteSaveStateStore(); + const launchSaveUrl = `${window.location.origin}/api/session/save`; + store.setSaveUrl(launchSaveUrl); + + await store.saveState(); + + expect(vi.mocked($fetch)).toHaveBeenCalledWith( + launchSaveUrl, + expect.objectContaining({ method: 'POST' }) + ); + expect(vi.mocked($fetch).mock.calls[0][1]?.redirect).toBeUndefined(); + expect(replace).toHaveBeenCalledTimes(1); + const nextUrl = new URL(replace.mock.calls[0][2] as string); + expect(nextUrl.searchParams.get('urls')).toBe(resumeUrl); + // save= is never stamped: subsequent saves keep going to the + // launch-provided target, so folder saves mint a new session item each time. + expect(nextUrl.searchParams.get('save')).toBeNull(); + expect(store.saveUrl).toBe(launchSaveUrl); + expect( + useMessageStore().messages.some((m) => m.title === 'Save Successful') + ).toBe(true); + }); + + it('drops a stale names= when repointing urls= at the session zip', async () => { + // A launch of ?urls=&names=chest.nrrd that keeps names= after + // the repoint retypes the session zip by the stale filename extension on + // F5 — the NRRD reader chokes on zip bytes and the resume fails. + window.history.replaceState( + null, + '', + '?urls=%2Fchest.nrrd&names=chest.nrrd&save=%2Fapi%2Fsave' + ); + const resumeUrl = '/api/v1/item/session-123/volview'; + vi.mocked($fetch).mockResolvedValue( + new Response(JSON.stringify({ resumeUrl }), { status: 200 }) + ); + const replace = vi + .spyOn(window.history, 'replaceState') + .mockImplementation(() => {}); + const store = useRemoteSaveStateStore(); + store.setSaveUrl('/api/save'); + + await store.saveState(); + + expect(replace).toHaveBeenCalledTimes(1); + const nextUrl = new URL(replace.mock.calls[0][2] as string); + expect(nextUrl.searchParams.get('urls')).toBe(resumeUrl); + expect(nextUrl.searchParams.get('names')).toBeNull(); + expect(nextUrl.searchParams.get('save')).toBe('/api/save'); + }); + + it('leaves the tab as-is when the response carries no resumeUrl', async () => { + vi.mocked($fetch).mockResolvedValue( + new Response(JSON.stringify({ ok: true }), { status: 200 }) + ); + const replace = vi + .spyOn(window.history, 'replaceState') + .mockImplementation(() => {}); + const store = useRemoteSaveStateStore(); + store.setSaveUrl(`${window.location.origin}/api/session/save`); + + await store.saveState(); + + expect(replace).not.toHaveBeenCalled(); + expect( + useMessageStore().messages.some((m) => m.title === 'Save Successful') + ).toBe(true); + }); +}); diff --git a/src/store/__tests__/segmentGroupDescriptorlessParity.spec.ts b/src/store/__tests__/segmentGroupDescriptorlessParity.spec.ts new file mode 100644 index 000000000..6f74aedb8 --- /dev/null +++ b/src/store/__tests__/segmentGroupDescriptorlessParity.spec.ts @@ -0,0 +1,268 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { leafStateId } from '@/src/io/import/dataSource'; +import { resolveArtifactRestoreSources } from '@/src/io/import/processors/restoreStateFile'; +import { ManifestSchema, type Manifest } from '@/src/io/state-file/schema'; + +// --------------------------------------------------------------------------- +// Compose-validity refinement PARITY PIN: segment descriptors are OPTIONAL on +// a composed segment group. When they are absent, the composed restore MUST +// reuse the exact decode/enumerate/default-name/default-color path that live +// `convertImageToLabelmap` uses — never a backend-authored empty catalog, and +// never a parallel client reimplementation. These tests pin the two paths to +// IDENTICAL segment catalogs for the same descriptor-less labelmap, with and +// without embedded `.seg.nrrd` metadata. +// --------------------------------------------------------------------------- + +// `writeSegmentation` spawns a real Worker; keep the IO module out of the test. +const ioMocks = vi.hoisted(() => ({ + readImage: vi.fn(), + writeSegmentation: vi.fn(async () => new Uint8Array([1, 2, 3])), +})); + +vi.mock('@/src/io/readWriteImage', () => ({ + readImage: ioMocks.readImage, + writeSegmentation: ioMocks.writeSegmentation, +})); + +const BASE_URI = 'volview-backend:base/ct-chest-001'; +const ARTIFACT_URI = 'volview-backend:artifact/tumor-seg/v2'; + +// A labelmap with voxel values {0, 1, 2}: background plus two segments. +function makeLabelmapImage() { + const image = vtkImageData.newInstance(); + image.setDimensions([4, 4, 4]); + const values = new Uint8Array(4 * 4 * 4); + values.fill(0, 0, 20); + values.fill(1, 20, 44); + values.fill(2, 44); + image + .getPointData() + .setScalars(vtkDataArray.newInstance({ numberOfComponents: 1, values })); + image.computeTransforms(); + return image; +} + +function makeSparseLabelmapImage() { + const image = vtkImageData.newInstance(); + image.setDimensions([4, 4, 4]); + const values = new Uint8Array(4 * 4 * 4); + values.fill(1, 8, 16); + values.fill(255, 48); + image + .getPointData() + .setScalars(vtkDataArray.newInstance({ numberOfComponents: 1, values })); + image.computeTransforms(); + return image; +} + +function makeParentImage() { + const image = vtkImageData.newInstance(); + image.setDimensions([4, 4, 4]); + image.getPointData().setScalars( + vtkDataArray.newInstance({ + numberOfComponents: 1, + values: new Uint8Array(4 * 4 * 4), + }) + ); + image.computeTransforms(); + return image; +} + +// A parent-bound, descriptor-less group: metadata carries name + parentImage, +// NO segments. +const descriptorlessComposedManifest = (): Manifest => + ManifestSchema.parse({ + version: '6.4.0', + dataSources: [ + { id: 1, type: 'uri', uri: BASE_URI, name: 'CT Chest' }, + { + id: 3, + type: 'uri', + uri: ARTIFACT_URI, + name: 'Tumor.seg.nrrd', + mime: 'application/octet-stream', + }, + ], + datasets: [{ id: 'ds-ct', dataSourceId: 1 }], + segmentGroups: [ + { + id: 'sg-tumor', + dataSourceId: 3, + metadata: { name: 'Tumor', parentImage: 'ds-ct' }, + }, + ], + }); + +const descriptorlessArchiveManifest = (): Manifest => + ManifestSchema.parse({ + version: '6.4.0', + dataSources: [{ id: 1, type: 'uri', uri: BASE_URI, name: 'CT Chest' }], + datasets: [{ id: 'ds-ct', dataSourceId: 1 }], + segmentGroups: [ + { + id: 'sg-tumor', + path: 'segmentations/Tumor.seg.nrrd', + metadata: { name: 'Tumor', parentImage: 'ds-ct' }, + }, + ], + }); + +const seat = ( + id: string, + name: string, + image: vtkImageData, + headerMetadata?: Map +) => useImageCacheStore().addVTKImageData(image, name, { id, headerMetadata }); + +// The LIVE path: what convertImageToLabelmap builds for this labelmap. +async function liveCatalog(segmentMetadata?: Map) { + setActivePinia(createPinia()); + seat('parent-img', 'CT Chest', makeParentImage()); + seat('child-img', 'Tumor.seg.nrrd', makeLabelmapImage(), segmentMetadata); + const store = useSegmentGroupStore(); + const [groupId] = await store.convertImageToLabelmap( + 'child-img', + 'parent-img' + ); + return JSON.parse(JSON.stringify(store.metadataByID[groupId].segments)); +} + +// The COLD path: what deserialize builds from a descriptor-less composed +// manifest whose artifact materialized as a loaded dataset. +async function coldCatalog(segmentMetadata?: Map) { + setActivePinia(createPinia()); + seat('parent-store', 'CT Chest', makeParentImage()); + seat( + 'artifact-store', + 'Tumor.seg.nrrd', + makeLabelmapImage(), + segmentMetadata + ); + const store = useSegmentGroupStore(); + const manifest = descriptorlessComposedManifest(); + const { segmentGroupIDMap: idMap } = await store.deserialize( + manifest, + [], + { + 'ds-ct': 'parent-store', + [leafStateId(3)]: 'artifact-store', + }, + resolveArtifactRestoreSources(manifest) + ); + const groupId = idMap['sg-tumor']; + expect(groupId).toBeDefined(); + return JSON.parse(JSON.stringify(store.metadataByID[groupId].segments)); +} + +describe('descriptor-less segment catalogs: cold restore == live conversion (parity pin)', () => { + beforeEach(() => { + ioMocks.readImage.mockReset(); + }); + + it('defaults-only labelmap: identical enumeration, names, and colors', async () => { + const live = await liveCatalog(); + const cold = await coldCatalog(); + + // Sanity on the live shape: the full non-background enumeration got + // default names/colors — not an empty catalog. + expect(live.order).toEqual([1, 2]); + expect(live.byValue[1].name).toBe('Segment 1'); + expect(live.byValue[2].name).toBe('Segment 2'); + + expect(cold).toEqual(live); + }); + + it('embedded .seg.nrrd metadata: identical overlay result in both paths', async () => { + const embedded = () => + new Map([ + ['Segment0_LabelValue', '2'], + ['Segment0_Name', 'Tumor core'], + ['Segment0_Color', '1 0 0'], + ]); + const live = await liveCatalog(embedded()); + const cold = await coldCatalog(embedded()); + + // The described value carries its embedded name; the undescribed value + // still gets its default (merge, not replace). + expect(live.byValue[2].name).toBe('Tumor core'); + expect(live.byValue[1].name).toBe('Segment 1'); + + expect(cold).toEqual(live); + }); + + it('preserves embedded metadata from an archive-backed .seg.nrrd', async () => { + setActivePinia(createPinia()); + seat('parent-store', 'CT Chest', makeParentImage()); + ioMocks.readImage.mockResolvedValue({ + image: makeLabelmapImage(), + headerMetadata: new Map([ + ['Segment0_LabelValue', '2'], + ['Segment0_Name', 'Tumor core'], + ['Segment0_Color', '1 0 0'], + ]), + }); + + const store = useSegmentGroupStore(); + const { segmentGroupIDMap: idMap } = await store.deserialize( + descriptorlessArchiveManifest(), + [ + { + archivePath: 'segmentations/Tumor.seg.nrrd', + file: new File([''], 'Tumor.seg.nrrd'), + }, + ], + { 'ds-ct': 'parent-store' } + ); + + const segments = store.metadataByID[idMap['sg-tumor']].segments; + expect(segments.order).toEqual([1, 2]); + expect(segments.byValue[1].name).toBe('Segment 1'); + expect(segments.byValue[2]).toMatchObject({ + name: 'Tumor core', + color: [255, 0, 0, 255], + }); + }); + + it('enumerates only distinct sparse voxel labels', async () => { + setActivePinia(createPinia()); + seat('parent-img', 'CT Chest', makeParentImage()); + seat('child-img', 'Sparse.seg.nrrd', makeSparseLabelmapImage()); + const store = useSegmentGroupStore(); + + const [groupId] = await store.convertImageToLabelmap( + 'child-img', + 'parent-img' + ); + + expect(store.metadataByID[groupId].segments.order).toEqual([1, 255]); + expect(Object.keys(store.metadataByID[groupId].segments.byValue)).toEqual([ + '1', + '255', + ]); + }); + + it('enumerates no segments for an all-background labelmap', async () => { + setActivePinia(createPinia()); + seat('parent-img', 'CT Chest', makeParentImage()); + // Every voxel is LABELMAP_BACKGROUND_VALUE (0): an all-background labelmap + // built with the same all-zero image helper the parent uses. Distinct + // nonzero labels are enumerated, so this labelmap has none. + seat('child-img', 'Empty.seg.nrrd', makeParentImage()); + const store = useSegmentGroupStore(); + + const [groupId] = await store.convertImageToLabelmap( + 'child-img', + 'parent-img' + ); + + expect(store.metadataByID[groupId].segments.order).toEqual([]); + expect(Object.keys(store.metadataByID[groupId].segments.byValue)).toEqual( + [] + ); + }); +}); diff --git a/src/store/__tests__/segmentGroupRestoreResilience.spec.ts b/src/store/__tests__/segmentGroupRestoreResilience.spec.ts new file mode 100644 index 000000000..8194967b9 --- /dev/null +++ b/src/store/__tests__/segmentGroupRestoreResilience.spec.ts @@ -0,0 +1,376 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { setActivePinia, createPinia } from 'pinia'; +import vtkDataArray from '@kitware/vtk.js/Common/Core/DataArray'; +import vtkImageData from '@kitware/vtk.js/Common/DataModel/ImageData'; +import { useSegmentGroupStore } from '@/src/store/segmentGroups'; +import { leafStateId } from '@/src/io/import/dataSource'; +import { useImageCacheStore } from '@/src/store/image-cache'; +import { useDatasetStore } from '@/src/store/datasets'; +import { ManifestSchema, type Manifest } from '@/src/io/state-file/schema'; +import { + completeStateFileRestore, + resolveArtifactRestoreSources, +} from '@/src/io/import/processors/restoreStateFile'; +import { useMessageStore } from '@/src/store/messages'; + +// --------------------------------------------------------------------------- +// Resilient segment-group restore: +// deserialize must never hang on a missing dataIDMap key, and one group's +// failure must never reject the whole restore. Skips are NON-silent: deserialize +// returns `{ segmentGroupIDMap, skipped }` where each skip carries a concrete +// reason, and the caller aggregates those (with reasons) into the consolidated +// notice. +// --------------------------------------------------------------------------- + +const ioMocks = vi.hoisted(() => ({ + readImage: vi.fn(), + writeSegmentation: vi.fn(async () => new Uint8Array([1, 2, 3])), +})); + +vi.mock('@/src/io/readWriteImage', () => ({ + readImage: ioMocks.readImage, + writeSegmentation: ioMocks.writeSegmentation, +})); + +const BASE_URI = 'volview-backend:base/ct-chest-001'; +const ARTIFACT_URI = 'volview-backend:artifact/tumor-seg/v2'; +const OTHER_ARTIFACT_URI = 'volview-backend:artifact/liver-seg/v1'; + +const segments = { + order: [1], + byValue: { + '1': { + value: 1, + name: 'Tumor', + color: [255, 0, 0, 255] as [number, number, number, number], + visible: true, + }, + }, +}; + +const group = ( + id: string, + extras: Record, + parentImage = 'ds-ct' +) => ({ + id, + ...extras, + metadata: { name: id, parentImage, segments }, +}); + +const manifestWith = (groups: Array>): Manifest => + ManifestSchema.parse({ + version: '6.4.0', + dataSources: [ + { id: 1, type: 'uri', uri: BASE_URI, name: 'CT Chest' }, + { id: 3, type: 'uri', uri: ARTIFACT_URI, name: 'Tumor.seg.nrrd' }, + { id: 4, type: 'uri', uri: OTHER_ARTIFACT_URI, name: 'Liver.seg.nrrd' }, + ], + datasets: [{ id: 'ds-ct', dataSourceId: 1 }], + segmentGroups: groups, + }); + +function makeImage(fillValue = 0) { + const image = vtkImageData.newInstance(); + image.setDimensions([4, 4, 4]); + image.getPointData().setScalars( + vtkDataArray.newInstance({ + numberOfComponents: 1, + values: new Uint8Array(4 * 4 * 4).fill(fillValue), + }) + ); + image.computeTransforms(); + return image; +} + +const seatImage = (id: string, name: string) => + useImageCacheStore().addVTKImageData(makeImage(), name, { id }); + +// An image that IS registered in the cache (imageById) but whose zero-length +// scalars make getVtkImageData return null — so a path-less restore throws +// AFTER the temp artifact has already materialized. +function makeEmptyScalarsImage() { + const image = vtkImageData.newInstance(); + image.setDimensions([4, 4, 4]); + image.getPointData().setScalars( + vtkDataArray.newInstance({ + numberOfComponents: 1, + values: new Uint8Array(0), + }) + ); + image.computeTransforms(); + return image; +} + +// Mirrors production: the restore setup resolves each group's artifact state +// source from the manifest (resolveArtifactRestoreSources, the single-owner +// policy) and hands it to deserialize alongside the dataIDMap. +const restoreGroups = ( + manifest: Manifest, + stateFiles: { archivePath: string; file: File }[], + dataIDMap: Record +) => + useSegmentGroupStore().deserialize( + manifest, + stateFiles, + dataIDMap, + resolveArtifactRestoreSources(manifest) + ); + +describe('segmentGroups.deserialize — resilient restore', () => { + beforeEach(() => { + setActivePinia(createPinia()); + ioMocks.readImage.mockReset(); + }); + + it('skips a path-less group whose artifact never materialized, without hanging', async () => { + // Before the guard, a missing leaf key for dataSourceId 3 flowed into + // untilLoaded(undefined) — an await with no timeout. This test completing + // at all IS the assertion that the hang is gone. + seatImage('store-ct', 'CT Chest'); + seatImage('store-liver', 'Liver.seg.nrrd'); + + const { segmentGroupIDMap: idMap, skipped } = await restoreGroups( + manifestWith([ + group('sg-tumor', { dataSourceId: 3 }), + group('sg-liver', { dataSourceId: 4 }), + ]), + [], + { 'ds-ct': 'store-ct', [leafStateId(4)]: 'store-liver' } + ); + + expect(idMap['sg-tumor']).toBeUndefined(); + expect(idMap['sg-liver']).toBeDefined(); + expect(skipped).toEqual([ + { name: 'sg-tumor', reason: 'artifact source unavailable' }, + ]); + }); + + it('skips a group whose parent base never resolved', async () => { + seatImage('store-seg', 'Tumor.seg.nrrd'); + + const { segmentGroupIDMap: idMap, skipped } = await restoreGroups( + manifestWith([group('sg-tumor', { dataSourceId: 3 }, 'ds-missing')]), + [], + { [leafStateId(3)]: 'store-seg' } + ); + + expect(idMap).toEqual({}); + expect(Object.keys(useSegmentGroupStore().metadataByID)).toEqual([]); + expect(skipped).toEqual([ + { name: 'sg-tumor', reason: 'parent image did not load' }, + ]); + }); + + it('a read failure skips just that group — survivors still attach', async () => { + seatImage('store-ct', 'CT Chest'); + seatImage('store-liver', 'Liver.seg.nrrd'); + ioMocks.readImage.mockRejectedValue(new Error('corrupt bytes')); + + const { segmentGroupIDMap: idMap, skipped } = await restoreGroups( + manifestWith([ + group('sg-tumor', { path: 'segmentations/Tumor.seg.nrrd' }), + group('sg-liver', { dataSourceId: 4 }), + ]), + [ + { + archivePath: 'segmentations/Tumor.seg.nrrd', + file: new File([''], 'Tumor.seg.nrrd'), + }, + ], + { 'ds-ct': 'store-ct', [leafStateId(4)]: 'store-liver' } + ); + + expect(idMap['sg-tumor']).toBeUndefined(); + expect(idMap['sg-liver']).toBeDefined(); + expect(skipped).toEqual([ + { name: 'sg-tumor', reason: 'could not read/parse labelmap' }, + ]); + }); + + it('surfaces a warning when the full restore skips an unattached group', async () => { + seatImage('store-ct', 'CT Chest'); + + await completeStateFileRestore( + manifestWith([group('sg-tumor', { dataSourceId: 3 })]), + [], + { 'ds-ct': 'store-ct' } + ); + + const warning = useMessageStore().messages.find( + (message) => message.title === 'Some scene content could not be restored' + ); + expect(warning?.options.details).toContain( + 'segment group: sg-tumor (artifact source unavailable)' + ); + }); + + it('an unresolved base skips its group (with reason) while survivors attach', async () => { + // `sg-liver`'s base (ds-ct) resolves; `sg-tumor`'s base (ds-missing) does not. + seatImage('store-ct', 'CT Chest'); + seatImage('store-liver', 'Liver.seg.nrrd'); + + const store = useSegmentGroupStore(); + await completeStateFileRestore( + manifestWith([ + group('sg-tumor', { dataSourceId: 3 }, 'ds-missing'), + group('sg-liver', { dataSourceId: 4 }), + ]), + [], + { 'ds-ct': 'store-ct', [leafStateId(4)]: 'store-liver' } + ); + + expect( + Object.values(store.metadataByID).some((m) => m.name === 'sg-liver') + ).toBe(true); + expect( + Object.values(store.metadataByID).some((m) => m.name === 'sg-tumor') + ).toBe(false); + + const warning = useMessageStore().messages.find( + (message) => message.title === 'Some scene content could not be restored' + ); + expect(warning?.options.details).toContain( + 'segment group: sg-tumor (parent image did not load)' + ); + }); + + // ------------------------------------------------------------------------- + // Temporary artifact cleanup: a path-less group's imported artifact + // dataset must be removed exactly once, whether the restore succeeds or fails. + // ------------------------------------------------------------------------- + + it('removes the temporary artifact even when the restore fails after import', async () => { + seatImage('store-ct', 'CT Chest'); + // Materialize the artifact in the cache, but with empty scalars so the load + // throws AFTER import (getVtkImageData returns null). + useImageCacheStore().addVTKImageData( + makeEmptyScalarsImage(), + 'Tumor.seg.nrrd', + { id: 'store-tumor' } + ); + expect(useImageCacheStore().imageById).toHaveProperty('store-tumor'); + + const { segmentGroupIDMap: idMap, skipped } = await restoreGroups( + manifestWith([group('sg-tumor', { dataSourceId: 3 })]), + [], + { 'ds-ct': 'store-ct', [leafStateId(3)]: 'store-tumor' } + ); + + expect(idMap['sg-tumor']).toBeUndefined(); + expect(skipped).toEqual([ + { name: 'sg-tumor', reason: 'could not read/parse labelmap' }, + ]); + expect(useImageCacheStore().imageById).not.toHaveProperty('store-tumor'); + }); + + it('removes the temporary artifact exactly once on a successful path-less restore', async () => { + seatImage('store-ct', 'CT Chest'); + seatImage('store-tumor', 'Tumor.seg.nrrd'); + const removeSpy = vi.spyOn(useDatasetStore(), 'remove'); + + const { segmentGroupIDMap: idMap } = await restoreGroups( + manifestWith([group('sg-tumor', { dataSourceId: 3 })]), + [], + { 'ds-ct': 'store-ct', [leafStateId(3)]: 'store-tumor' } + ); + + expect(idMap['sg-tumor']).toBeDefined(); + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledWith('store-tumor'); + expect(useImageCacheStore().imageById).not.toHaveProperty('store-tumor'); + }); + + it('restores BOTH path-less groups that share one artifact dataSourceId', async () => { + // prepareLeafDataSources dedupes leaves by dataSourceId, so two path-less + // groups referencing the same artifact share ONE temp dataset. Removing it + // inside each group's `finally` let the first group's cleanup starve the + // second's getVtkImageData — the second group was dropped as unreadable. + // The shared temp dataset must survive until BOTH groups settle, then be + // removed exactly once. + seatImage('store-ct', 'CT Chest'); + seatImage('store-tumor', 'Tumor.seg.nrrd'); + const removeSpy = vi.spyOn(useDatasetStore(), 'remove'); + + const { segmentGroupIDMap: idMap, skipped } = await restoreGroups( + manifestWith([ + group('sg-a', { dataSourceId: 3 }), + group('sg-b', { dataSourceId: 3 }), + ]), + [], + { 'ds-ct': 'store-ct', [leafStateId(3)]: 'store-tumor' } + ); + + expect(idMap['sg-a']).toBeDefined(); + expect(idMap['sg-b']).toBeDefined(); + expect(skipped).toEqual([]); + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledWith('store-tumor'); + expect(useImageCacheStore().imageById).not.toHaveProperty('store-tumor'); + }); + + it('removes the temp artifact of a group skipped at the parent check', async () => { + // The base image never resolved but the artifact leaf DID import — with + // the cleanup set built from attachable groups only, the orphaned labelmap + // stayed in the dataset store as a stray volume and re-serialized into + // every future save. + seatImage('store-seg', 'Tumor.seg.nrrd'); + const removeSpy = vi.spyOn(useDatasetStore(), 'remove'); + + const { segmentGroupIDMap: idMap, skipped } = await restoreGroups( + manifestWith([group('sg-tumor', { dataSourceId: 3 }, 'ds-missing')]), + [], + { [leafStateId(3)]: 'store-seg' } + ); + + expect(idMap).toEqual({}); + expect(skipped).toEqual([ + { name: 'sg-tumor', reason: 'parent image did not load' }, + ]); + expect(removeSpy).toHaveBeenCalledTimes(1); + expect(removeSpy).toHaveBeenCalledWith('store-seg'); + expect(useImageCacheStore().imageById).not.toHaveProperty('store-seg'); + }); + + it('does not remove any dataset for an archive-backed (path) group', async () => { + seatImage('store-ct', 'CT Chest'); + seatImage('bystander', 'Unrelated'); + ioMocks.readImage.mockResolvedValue({ image: makeImage() }); + const removeSpy = vi.spyOn(useDatasetStore(), 'remove'); + + const { segmentGroupIDMap: idMap } = await restoreGroups( + manifestWith([ + group('sg-tumor', { path: 'segmentations/Tumor.seg.nrrd' }), + ]), + [ + { + archivePath: 'segmentations/Tumor.seg.nrrd', + file: new File([''], 'Tumor.seg.nrrd'), + }, + ], + { 'ds-ct': 'store-ct' } + ); + + expect(idMap['sg-tumor']).toBeDefined(); + // Archive-backed groups own no temp artifact — nothing must be removed. + expect(removeSpy).not.toHaveBeenCalled(); + expect(useImageCacheStore().imageById).toHaveProperty('bystander'); + }); + + it('does not remove an explicit dataset shared with a path-less group', async () => { + seatImage('store-ct', 'CT Chest'); + const removeSpy = vi.spyOn(useDatasetStore(), 'remove'); + const manifest = manifestWith([group('sg-shared', { dataSourceId: 1 })]); + + const { segmentGroupIDMap: idMap, skipped } = await restoreGroups( + manifest, + [], + { 'ds-ct': 'store-ct' } + ); + + expect(idMap['sg-shared']).toBeDefined(); + expect(skipped).toEqual([]); + expect(removeSpy).not.toHaveBeenCalled(); + expect(useImageCacheStore().imageById).toHaveProperty('store-ct'); + }); +}); diff --git a/src/store/datasets-images.ts b/src/store/datasets-images.ts index ce7dc8ff6..8c11a4216 100644 --- a/src/store/datasets-images.ts +++ b/src/store/datasets-images.ts @@ -35,7 +35,7 @@ export const useImageStore = defineStore('images', () => { function addVTKImageData( name: string, imageData: vtkImageData, - options: { id?: string } = {} + options: { id?: string; headerMetadata?: Map } = {} ) { if (options.id && idList.value.includes(options.id)) { throw new Error('ID already exists'); @@ -44,7 +44,10 @@ export const useImageStore = defineStore('images', () => { const id = options.id ?? useIdStore().nextId(); idList.value.push(id); - return useImageCacheStore().addVTKImageData(imageData, name, { id }); + return useImageCacheStore().addVTKImageData(imageData, name, { + id, + headerMetadata: options.headerMetadata, + }); } function deleteData(id: string) { diff --git a/src/store/datasets-layers.ts b/src/store/datasets-layers.ts index 5e1a35561..60138fa3d 100644 --- a/src/store/datasets-layers.ts +++ b/src/store/datasets-layers.ts @@ -61,12 +61,13 @@ export const useLayersStore = defineStore('layer', () => { const name = imageCacheStore.getImageMetadata(source)?.name ?? NO_NAME; imageCacheStore.addVTKImageData(image, name, { id }); + return id; } async function addLayer(parent: DataSelection, source: DataSelection) { return useErrorMessage('Failed to build layer', async () => { try { - await _addLayer(parent, source); + return await _addLayer(parent, source); } catch (error) { // remove failed layer from parent's layer list parentToLayers[parent] = parentToLayers[parent]?.filter( @@ -106,7 +107,7 @@ export const useLayersStore = defineStore('layer', () => { const remove = (selectionToRemove: DataSelection) => { // delete as parent getLayers(selectionToRemove).forEach(({ selection }) => - deleteLayer(selection, selection) + deleteLayer(selectionToRemove, selection) ); // delete from layer lists Object.keys(parentToLayers).forEach((parent) => diff --git a/src/store/datasets.ts b/src/store/datasets.ts index 05a257f76..ac3204ec0 100644 --- a/src/store/datasets.ts +++ b/src/store/datasets.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia'; import { computed, shallowRef } from 'vue'; import { isRegularImage } from '@/src/utils/dataSelection'; -import { DataSource } from '@/src/io/import/dataSource'; +import { DataSource, isRemoteDataSource } from '@/src/io/import/dataSource'; import { useDICOMStore } from '@/src/store/datasets-dicom'; import { useImageStore } from '@/src/store/datasets-images'; import * as Schema from '@/src/io/state-file/schema'; @@ -9,6 +9,7 @@ import { useLayersStore } from '@/src/store/datasets-layers'; import { useModelStore } from '@/src/store/datasets-models'; import { useViewConfigStore } from '@/src/store/view-configs'; import { useImageStatsStore } from '@/src/store/image-stats'; +import { Tags } from '@/src/core/dicomTags'; export const DataType = { Image: 'Image', @@ -20,6 +21,75 @@ interface LoadedData { dataSource: DataSource; } +function sourceIdentity(dataSource: DataSource): string | undefined { + if (dataSource.type === 'chunk') { + const sopInstanceUID = dataSource.chunk.metadata + ?.find(([tag]) => tag === Tags.SOPInstanceUID)?.[1] + ?.trim(); + if (sopInstanceUID) return `dicom:${sopInstanceUID}`; + } + + if (dataSource.type === 'uri') return `uri:${dataSource.uri}`; + if (dataSource.type === 'archive') { + const parent = sourceIdentity(dataSource.parent); + return parent ? `archive:${parent}:${dataSource.path}` : undefined; + } + if (dataSource.type === 'file') { + if (dataSource.parent) return sourceIdentity(dataSource.parent); + const relativePath = dataSource.file.webkitRelativePath || ''; + return [ + 'file', + relativePath, + dataSource.file.name, + dataSource.file.size, + dataSource.file.lastModified, + dataSource.file.type, + ].join(':'); + } + if (dataSource.parent) return sourceIdentity(dataSource.parent); + return undefined; +} + +function mergeCollectionSources( + existing: DataSource, + incoming: DataSource +): DataSource { + if (existing.type !== 'collection' || incoming.type !== 'collection') { + return isRemoteDataSource(incoming) && !isRemoteDataSource(existing) + ? incoming + : existing; + } + + const merged: DataSource[] = []; + const indexByIdentity = new Map(); + const seenByReference = new Set(); + + [...existing.sources, ...incoming.sources].forEach((source) => { + const identity = sourceIdentity(source); + if (identity === undefined) { + if (!seenByReference.has(source)) { + seenByReference.add(source); + merged.push(source); + } + return; + } + + const existingIndex = indexByIdentity.get(identity); + if (existingIndex === undefined) { + indexByIdentity.set(identity, merged.length); + merged.push(source); + return; + } + + const kept = merged[existingIndex]; + if (isRemoteDataSource(source) && !isRemoteDataSource(kept)) { + merged[existingIndex] = source; + } + }); + + return { type: 'collection', sources: merged }; +} + function createIdGenerator() { let nextId = 1; return () => nextId++; @@ -141,6 +211,17 @@ export const useDatasetStore = defineStore('dataset', () => { return [...volumeKeys, ...images]; }); + // Provenance lookup by dataset id: the loaded volume's `DataSource` (its + // parent chain records where every byte came from). The input mint + // reads this to author a bound input's verbatim URIs; a volume with no URI + // ancestor here is not bindable. Returns undefined for an unknown id. + const dataSourceByID = computed( + () => new Map(loadedData.value.map((d) => [d.dataID, d.dataSource])) + ); + const getDataSource = ( + id: string | null | undefined + ): DataSource | undefined => (id ? dataSourceByID.value.get(id) : undefined); + // --- actions --- // async function serialize(stateFile: Schema.StateFile) { @@ -169,6 +250,11 @@ export const useDatasetStore = defineStore('dataset', () => { const remove = (id: string | null) => { if (!id) return; + // Prune the provenance entry too, or `serialize` re-emits the removed + // dataset (e.g. the temp dataset a segment group consumed at restore) as a + // dangling manifest entry that a later restore fetches as a visible + // Anonymous volume. + loadedData.value = loadedData.value.filter((d) => d.dataID !== id); dicomStore.deleteVolume(id); imageStore.deleteData(id); layersStore.remove(id); @@ -190,11 +276,29 @@ export const useDatasetStore = defineStore('dataset', () => { }; function addDataSources(sources: Array) { - loadedData.value.push(...sources); + // Re-importing the same data yields the same dataID (e.g. the same DICOM + // series dragged in twice). Keep one dataset entry while merging distinct + // collection members so incremental imports retain every slice. Duplicate + // DICOM instances are keyed by SOP Instance UID, preferring remote + // provenance when both local and remote forms are available. + const byId = new Map(loadedData.value.map((d) => [d.dataID, d])); + sources.forEach((d) => { + const existing = byId.get(d.dataID); + if (!existing) { + byId.set(d.dataID, d); + return; + } + byId.set(d.dataID, { + dataID: d.dataID, + dataSource: mergeCollectionSources(existing.dataSource, d.dataSource), + }); + }); + loadedData.value = [...byId.values()]; } return { idsAsSelections, + getDataSource, addDataSources, serialize, remove, diff --git a/src/store/image-cache.ts b/src/store/image-cache.ts index ed864b5ec..f884a5309 100644 --- a/src/store/image-cache.ts +++ b/src/store/image-cache.ts @@ -23,6 +23,12 @@ export const useImageCacheStore = defineStore('image-cache', () => { const imageLoading = reactive>({}); const imageErrors = reactive>({}); const imageListenerCleanup: Record void> = {}; + const deletionCallbacks = new Set<(deletedIDs: string[]) => void>(); + + function onImageDeleted(callback: (deletedIDs: string[]) => void) { + deletionCallbacks.add(callback); + return () => deletionCallbacks.delete(callback); + } function getVtkImageData(id: Maybe): Maybe { if (!id) return null; @@ -101,9 +107,11 @@ export const useImageCacheStore = defineStore('image-cache', () => { function addVTKImageData( imageData: vtkImageData, name: string, - options: { id?: string } = {} + options: { id?: string; headerMetadata?: Map } = {} ) { - return addProgressiveImage(new LoadedVtkImage(imageData, name), options); + const image = new LoadedVtkImage(imageData, name); + if (options.headerMetadata) image.headerMetadata = options.headerMetadata; + return addProgressiveImage(image, { id: options.id }); } function removeImage(id: string) { @@ -121,6 +129,7 @@ export const useImageCacheStore = defineStore('image-cache', () => { delete imageStatus[id]; delete imageLoading[id]; delete imageErrors[id]; + [...deletionCallbacks].forEach((callback) => callback([id])); } /** @@ -147,6 +156,7 @@ export const useImageCacheStore = defineStore('image-cache', () => { imageErrors, getVtkImageData, getImageMetadata, + onImageDeleted, addProgressiveImage, addVTKImageData, updateVTKImageData, diff --git a/src/store/remote-save-state.ts b/src/store/remote-save-state.ts index 0e5fbf0a7..486fd7ba3 100644 --- a/src/store/remote-save-state.ts +++ b/src/store/remote-save-state.ts @@ -1,6 +1,8 @@ +import { isOriginAllowed } from '@/src/io/originGate'; import { serialize } from '@/src/io/state-file/serialize'; import { useMessageStore } from '@/src/store/messages'; import { $fetch } from '@/src/utils/fetch'; +import { repointLaunchUrls } from '@/src/utils/urlParams'; import { defineStore } from 'pinia'; import { ref } from 'vue'; @@ -10,7 +12,20 @@ const useRemoteSaveStateStore = defineStore('remoteSaveState', () => { const messageStore = useMessageStore(); + // A save target is accepted only if it is same-origin with the served client. + // Refusing leaves `saveUrl` empty, which inerts the save UI (it is gated on a + // non-empty target) — so a deployment that serves no save endpoint of its own, + // such as the public demo, simply has no save, with nothing to configure and + // no build flag to forget. This is what keeps a crafted `?save=` link from + // POSTing the session to a third-party origin. const setSaveUrl = (url: string) => { + if (!isOriginAllowed(url)) { + saveUrl.value = ''; + messageStore.addError('Save Disabled', { + details: `Refused a cross-origin save target: ${url}`, + }); + return; + } saveUrl.value = url; }; @@ -29,11 +44,28 @@ const useRemoteSaveStateStore = defineStore('remoteSaveState', () => { body: blob, }); - if (saveResult.ok) messageStore.addSuccess('Save Successful'); - else + if (saveResult.ok) { + // Repoint future refreshes at the saved session when the backend + // returns a `resumeUrl` (see repointLaunchUrls): a folder-scoped save + // mints a new session zip item per save and F5 follows the newest, + // while the in-memory save target stays on the launch-provided + // `save=`. VolView constructs no Girder route and never learns the + // item id. A non-JSON / bodyless / `resumeUrl`-less response is a + // fail-safe no-op — the ordinary save still succeeded. + try { + const body = await saveResult.json(); + if (body && typeof body.resumeUrl === 'string') { + repointLaunchUrls(body.resumeUrl); + } + } catch { + // leave the tab as-is + } + messageStore.addSuccess('Save Successful'); + } else { messageStore.addError('Save Failed', { details: 'Network response not OK', }); + } } catch (error) { messageStore.addError('Save Failed with error', { details: `Failed from: ${error}`, diff --git a/src/store/segmentGroups.ts b/src/store/segmentGroups.ts index cb5bed536..f37eee0a3 100644 --- a/src/store/segmentGroups.ts +++ b/src/store/segmentGroups.ts @@ -11,6 +11,11 @@ import { normalizeForStore, removeFromArray } from '@/src/utils'; import { SegmentMask } from '@/src/types/segment'; import { DEFAULT_SEGMENT_MASKS, CATEGORICAL_COLORS } from '@/src/config'; import { readImage, writeSegmentation } from '@/src/io/readWriteImage'; +import { + parseSegNrrdMetadata, + overlaySegmentMetadata, +} from '@/src/io/segNrrdMetadata'; +import type { ArtifactRestoreSource } from '@/src/io/import/processors/restoreStateFile'; import { type DataSelection, getImage, @@ -49,6 +54,16 @@ export type SegmentGroupMetadata = { order: number[]; byValue: Record; }; + // Provenance of a job-produced segment group. This is the durable + // idempotency key used to avoid reapplying a restored job output. Optional + + // additive — hand-painted groups have none. Flows through addLabelmap and + // round-trips the `.volview.zip` (see the matching `SegmentGroupSource` in + // io/state-file/schema.ts). + source?: { + providerId: string; + jobId: string; + outputId: string; + }; }; export function createLabelmapFromImage(imageData: vtkImageData) { @@ -189,15 +204,9 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { } // Used for constructing labelmap names in newLabelmapFromImage. + // Cleared by the onImageDeleted cascade below. const nextDefaultIndex: Record = Object.create(null); - // clear nextDefaultIndex - onImageDeleted((deleted) => { - deleted.forEach((id) => { - delete nextDefaultIndex[id]; - }); - }); - function pickUniqueName( formatName: (index: number) => string, parentID: string @@ -262,13 +271,19 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { return [...color, 255] as const; } + // `imageId` may be undefined when the labelmap's bytes did not arrive + // through a loaded image dataset (a zip-restored group). DICOM-SEG decoding + // still requires a source image, while file-header metadata can be supplied + // directly for archive-backed images. async function decodeSegments( - imageId: DataSelection, + imageId: DataSelection | undefined, image: vtkLabelMap, - component = 0 + component = 0, + headerMetadata?: Map ) { const dicomStore = useDICOMStore(); if ( + imageId !== undefined && !isRegularImage(imageId) && dicomStore.volumeInfo[imageId]?.kind !== 'cine' ) { @@ -286,13 +301,39 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { } } - const [min, max] = image.getPointData().getScalars().getRange(); - const noZeroBackground = Math.max(min, 1); - const values = Array.from( - { length: max - noZeroBackground + 1 }, - (_, i) => i + noZeroBackground - ); - return values.map((value) => ({ + // Slicer-convention `.seg.nrrd` embedded metadata: a labelmap + // produced by a backend CLI carries its real segment names/colors in the + // NRRD header, captured onto the loaded image at import. + // + // MERGE, not replace: the distinct nonzero voxel values are the spine, so + // a labelled voxel with NO `Segment{N}_*` block still gets a default, + // visible, manageable segment instead of being dropped. Embedded + // name/color/visibility are overlaid onto the matching `LabelValue == voxel + // value`; undescribed values keep their default. + const embedded = + headerMetadata ?? + (imageId !== undefined + ? imageCacheStore.imageById[imageId]?.headerMetadata + : undefined); + const described = embedded ? parseSegNrrdMetadata(embedded) : undefined; + + // Distinct nonzero voxel values, ascending — the segment spine. + // Labelmap scalars are Uint8Array by construction (both callers pass a + // `toLabelMap` result, which forces UInt8), so a fixed 256-slot presence map + // gives one branch-free typed-array write per voxel on the hot path, and the + // 0..255 sweep is already ascending (no Set, no per-voxel Number(), no sort). + const voxelValues = image.getPointData().getScalars().getData(); + const present = new Uint8Array(256); + for (let index = 0; index < voxelValues.length; index += 1) { + present[voxelValues[index]] = 1; + } + const values: number[] = []; + for (let value = 0; value < present.length; value += 1) { + if (present[value] && value !== LABELMAP_BACKGROUND_VALUE) + values.push(value); + } + + return overlaySegmentMetadata(values, described, (value) => ({ value, name: makeDefaultSegmentName(value), color: [...getNextColor()], @@ -302,11 +343,18 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { /** * Converts an image to a labelmap. + * + * Returns the created segment-group id(s) — one per component of the source + * image (one for the common single-component case). Awaits the per-component + * adds so the caller can act on the created groups synchronously afterwards + * (corroboration/present + descriptor application key off the + * returned ids rather than racing `orderByParent`). */ async function convertImageToLabelmap( imageID: DataSelection, - parentID: DataSelection - ) { + parentID: DataSelection, + source?: SegmentGroupMetadata['source'] + ): Promise { if (imageID === parentID) throw new Error('Cannot convert an image to be a labelmap of itself'); @@ -340,28 +388,36 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { const images = componentCount === 1 ? [childImage] : extractEachComponent(childImage); - images.forEach(async (image, component) => { - const matchingParentSpace = await ensureSameSpace( - parentImage, - image, - true - ); - const labelmapImage = toLabelMap(matchingParentSpace); + return Promise.all( + images.map(async (image, component) => { + const matchingParentSpace = await ensureSameSpace( + parentImage, + image, + true + ); + const labelmapImage = toLabelMap(matchingParentSpace); - const segments = await decodeSegments(imageID, labelmapImage, component); - const { order, byKey } = normalizeForStore(segments, 'value'); - const segmentGroupStore = useSegmentGroupStore(); + const segments = await decodeSegments( + imageID, + labelmapImage, + component + ); + const { order, byKey } = normalizeForStore(segments, 'value'); + const segmentGroupStore = useSegmentGroupStore(); - const name = pickUniqueName( - (index: number) => `${baseName} ${numberer(index)}`, - parentID - ); - segmentGroupStore.addLabelmap(labelmapImage, { - name, - parentImage: parentID, - segments: { order, byValue: byKey }, - }); - }); + const name = pickUniqueName( + (index: number) => `${baseName} ${numberer(index)}`, + parentID + ); + const id = segmentGroupStore.addLabelmap(labelmapImage, { + name, + parentImage: parentID, + segments: { order, byValue: byKey }, + ...(source ? { source } : {}), + }); + return id; + }) + ); } /** @@ -511,66 +567,184 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { this: _This, manifest: Manifest, stateFiles: FileEntry[], - dataIDMap: Record + dataIDMap: Record, + // Per-group artifact source, resolved by the restore setup (see + // resolveArtifactRestoreSources in restoreStateFile.ts, the single owner + // of the synthesized-leaf and ownership policy). Mapped through dataIDMap + // here. + artifactSources: Record = {} ) { const { segmentGroups } = manifest; const datasetStore = useDatasetStore(); const segmentGroupIDMap: Record = {}; + // Non-silent drops: every group left out of the restore is recorded here + // with a concrete reason so the caller can surface it. + const skipped: Array<{ name: string; reason: string }> = []; if (!segmentGroups || segmentGroups.length === 0) { - return segmentGroupIDMap; + return { segmentGroupIDMap, skipped }; } // First restore the data, then restore the store. // This preserves ordering from orderByParent. - async function loadSegmentGroupImage(segmentGroup: SegmentGroup) { - if (segmentGroup.dataSourceId !== undefined) { - const storeId = dataIDMap[String(segmentGroup.dataSourceId)]; - await untilLoaded(storeId); - const image = imageCacheStore.getVtkImageData(storeId); - if (!image) { - throw new Error( - `Could not get image data for dataSourceId ${segmentGroup.dataSourceId}` - ); - } - return { image, storeIdToRemove: storeId }; + // `path` is authoritative for bytes when present: a re-saved + // zip carries the archive bytes AND the provenance `dataSourceId`, but + // `dataIDMap` is keyed by save-time DATASET ids. Consulting it for a + // path-carrying group could hang restore on a missing key, or worse, + // build the group from an unrelated dataset's voxels and then delete that + // dataset. The `dataSourceId` branch remains for composed manifests, + // whose groups carry no archive bytes — see `artifactStoreId` for how the + // artifact's store id is resolved. + // The temporary artifact dataset id (if any) is resolved by the CALLER + // before the restore `try`, so its cleanup can run unconditionally in a + // `finally` even when this load throws. This function yields the image and + // any file-header metadata needed to reconstruct segment descriptors. + async function loadSegmentGroupImage( + segmentGroup: SegmentGroup, + storeId: string | undefined + ) { + if (segmentGroup.path !== undefined) { + const file = stateFiles.find( + (entry) => entry.archivePath === normalize(segmentGroup.path!) + )?.file; + return readImage(file!); } - const file = stateFiles.find( - (entry) => entry.archivePath === normalize(segmentGroup.path!) - )?.file; - return { image: await readImage(file!) }; + await untilLoaded(storeId!); + const image = imageCacheStore.getVtkImageData(storeId!); + if (!image) { + throw new Error( + `Could not get image data for dataSourceId ${segmentGroup.dataSourceId}` + ); + } + return { + image, + headerMetadata: imageCacheStore.imageById[storeId!]?.headerMetadata, + }; } - const labelmapResults = await Promise.all( - segmentGroups.map(async (segmentGroup) => { - const { image, storeIdToRemove } = - await loadSegmentGroupImage(segmentGroup); - const labelmapImage = toLabelMap(image); + // A path-less group's artifact store id: the restore setup already + // resolved which STATE id carries each group's artifact (synthesized leaf + // or covering dataset — a policy owned entirely by restoreStateFile.ts); + // this only maps that id through dataIDMap. + const artifactStoreId = (segmentGroup: SegmentGroup) => { + if (segmentGroup.path !== undefined) return undefined; + const source = artifactSources[segmentGroup.id]; + return source !== undefined ? dataIDMap[source.stateId] : undefined; + }; - const id = useIdStore().nextId(); - dataIndex[id] = labelmapImage; - return { id, storeIdToRemove }; - }) + // Resilient restore. Skip BEFORE awaiting anything a + // group whose base image is unresolved, or a path-less group whose artifact + // datasource never materialized — `untilLoaded(undefined)` never times out + // and would hang restore forever. A missing key is knowable up front, so + // the pre-await guard catches it; the per-group settle below is the safety + // net for a fetch/parse failure. Skipped groups drop out of the id map so + // they are left out of the restore. + const attachable = segmentGroups.filter((segmentGroup) => { + if (dataIDMap[segmentGroup.metadata.parentImage] === undefined) { + skipped.push({ + name: segmentGroup.metadata.name, + reason: 'parent image did not load', + }); + return false; + } + if (segmentGroup.path !== undefined) return true; + const hasArtifact = artifactStoreId(segmentGroup) !== undefined; + if (!hasArtifact) { + skipped.push({ + name: segmentGroup.metadata.name, + reason: 'artifact source unavailable', + }); + } + return hasArtifact; + }); + + // Every path-less group's temporary imported artifact must be removed + // exactly ONCE, and only AFTER every group that reads it has settled. + // prepareLeafDataSources dedupes leaves by dataSourceId, so two path-less + // groups referencing the same artifact share ONE temp dataset id; removing + // it inside each group's `finally` let the first group's cleanup starve the + // second group's `getVtkImageData`, dropping it as unreadable. Collect the + // unique ids here and remove them after the `Promise.all` — in a `finally` + // so the cleanup runs even if a group throws unexpectedly. Archive-backed + // groups (path !== undefined) own no temp dataset. + // Collected from EVERY group, not just the attachable ones: a group + // skipped at the parent-image check may still have imported its artifact + // leaf, and that orphan would otherwise sit in the dataset store and + // re-serialize into every future save. + const tempStoreIdsToRemove = new Set( + segmentGroups + .filter( + (segmentGroup) => artifactSources[segmentGroup.id]?.temporary === true + ) + .map(artifactStoreId) + .filter((storeId): storeId is string => storeId !== undefined) ); - segmentGroups.forEach((segmentGroup, index) => { - const { id: newID, storeIdToRemove } = labelmapResults[index]; + let labelmapResults; + try { + labelmapResults = await Promise.all( + attachable.map(async (segmentGroup) => { + const storeId = artifactStoreId(segmentGroup); + try { + const { image, headerMetadata } = await loadSegmentGroupImage( + segmentGroup, + storeId + ); + const labelmapImage = toLabelMap(image); + + // Descriptor-less group: `segments` is optional on the wire. When absent, + // build the catalog through the SAME decode/enumerate/default path + // live convertImageToLabelmap uses (voxel enumeration + embedded + // .seg.nrrd metadata overlay + default names/colors) — parity is + // pinned by segmentGroupDescriptorlessParity.spec.ts. + const segments = + segmentGroup.metadata.segments ?? + (await (async () => { + const decoded = await decodeSegments( + storeId, + labelmapImage, + 0, + headerMetadata + ); + const { order, byKey } = normalizeForStore(decoded, 'value'); + return { order, byValue: byKey }; + })()); + + const id = useIdStore().nextId(); + dataIndex[id] = labelmapImage; + return { segmentGroup, id, segments }; + } catch { + // A parse/read failure skips just this group — never rejects the + // whole restore; the survivors still attach. Recorded (not silent) so + // the caller can report it. + skipped.push({ + name: segmentGroup.metadata.name, + reason: 'could not read/parse labelmap', + }); + return undefined; + } + }) + ); + } finally { + tempStoreIdsToRemove.forEach((storeId) => datasetStore.remove(storeId)); + } + + labelmapResults.forEach((result) => { + if (!result) return; + const { segmentGroup, id: newID, segments } = result; segmentGroupIDMap[segmentGroup.id] = newID; const parentImage = dataIDMap[segmentGroup.metadata.parentImage]; - metadataByID[newID] = { ...segmentGroup.metadata, parentImage }; + metadataByID[newID] = { ...segmentGroup.metadata, parentImage, segments }; + orderByParent.value[parentImage] ??= []; orderByParent.value[parentImage].push(newID); - - if (storeIdToRemove) { - datasetStore.remove(storeIdToRemove); - } }); - return segmentGroupIDMap; + return { segmentGroupIDMap, skipped }; } // --- sync segments --- // @@ -603,9 +777,12 @@ export const useSegmentGroupStore = defineStore('segmentGroup', () => { onImageDeleted((deleted) => { deleted.forEach((parentID) => { - orderByParent.value[parentID]?.forEach((segmentGroupID) => { - removeGroup(segmentGroupID); - }); + delete nextDefaultIndex[parentID]; + // Iterate a COPY: removeGroup splices the same orderByParent array via + // removeFromArray, so forEaching the live array skips every other group + // when an image has 2+ groups (the normal case once job labelmaps and + // multi-component conversions land). + [...(orderByParent.value[parentID] ?? [])].forEach(removeGroup); }); }); diff --git a/src/store/tools/crop.ts b/src/store/tools/crop.ts index f85b009e3..2aa6c3fdf 100644 --- a/src/store/tools/crop.ts +++ b/src/store/tools/crop.ts @@ -8,13 +8,28 @@ import type { MaybeRef } from 'vue'; import { computed, reactive, readonly, unref } from 'vue'; import { vec3 } from 'gl-matrix'; import { defineStore } from 'pinia'; -import { arrayEqualsWithComparator } from '@/src/utils'; +import { arrayEqualsWithComparator, isRecord } from '@/src/utils'; import { Maybe } from '@/src/types'; import { useImageCacheStore } from '@/src/store/image-cache'; +import { onImageDeleted } from '@/src/composables/onImageDeleted'; +import { declareManifestRefs } from '@/src/core/manifestRefs'; import { LPSCroppingPlanes } from '../../types/crop'; import { ImageMetadata } from '../../types/image'; import { StateFile, Manifest } from '../../io/state-file/schema'; +// The manifest references this store's remove cascade keeps clean (see the +// onImageDeleted registration below), declared for the dev-only save backstop. +declareManifestRefs('tools.crop', (manifest) => { + const tools = isRecord(manifest.tools) ? manifest.tools : {}; + return isRecord(tools.crop) + ? Object.keys(tools.crop).map((id) => ({ + kind: 'dataset' as const, + id, + where: `tools.crop[${id}]`, + })) + : []; +}); + type Plane = { origin: Vector3; normal: Vector3; @@ -131,6 +146,14 @@ export const useCropStore = defineStore('crop', () => { state.croppingByImageID[imageID] = clampCroppingPlanes(imageID, planes); }; + // Delete-base cleanup: crop state is keyed by dataset id, so a removed image + // must drop its entry or `serialize` carries an orphaned crop key. + onImageDeleted((deletedIDs) => { + deletedIDs.forEach((id) => { + delete state.croppingByImageID[id]; + }); + }); + const setCroppingForAxis = ( imageID: string, axis: LPSAxis, diff --git a/src/store/tools/paint.ts b/src/store/tools/paint.ts index 85204c0b0..9abe2e976 100644 --- a/src/store/tools/paint.ts +++ b/src/store/tools/paint.ts @@ -16,6 +16,24 @@ import useViewSliceStore from '../view-configs/slicing'; import { useViewStore } from '../views'; import { useViewCameraStore } from '../view-configs/camera'; import { useImageCacheStore } from '../image-cache'; +import { declareManifestRefs } from '@/src/core/manifestRefs'; +import { isRecord } from '@/src/utils'; + +// The manifest reference this store's sync orphan-watch keeps clean (see the +// activeSegmentGroupID watch below), declared for the dev-only save backstop. +declareManifestRefs('tools.paint', (manifest) => { + const tools = isRecord(manifest.tools) ? manifest.tools : {}; + const paint = isRecord(tools.paint) ? tools.paint : {}; + return typeof paint.activeSegmentGroupID === 'string' + ? [ + { + kind: 'segmentGroup' as const, + id: paint.activeSegmentGroupID, + where: 'tools.paint.activeSegmentGroupID', + }, + ] + : []; +}); const DEFAULT_BRUSH_SIZE = 4; const DEFAULT_THRESHOLD_RANGE: Vector2 = [ @@ -52,6 +70,22 @@ export const usePaintToolStore = defineStore('paint', () => { const segmentGroupStore = useSegmentGroupStore(); + // Delete-base cleanup: removing a dataset cascades away its segment groups. + // `serialize` writes the raw `activeSegmentGroupID`, so null it the instant + // its record leaves the store or the save manifest carries an orphaned id. + // Sync flush keeps this within the same `datasetStore.remove` call — the same + // remove-cascade contract as onImageDeleted, but keyed on segmentGroupID (not + // imageID), so it watches the record set instead of using that composable. + watch( + () => + activeSegmentGroupID.value != null && + !(activeSegmentGroupID.value in segmentGroupStore.metadataByID), + (orphaned) => { + if (orphaned) activeSegmentGroupID.value = null; + }, + { flush: 'sync' } + ); + const isPaintingModeActive = computed( () => activeMode.value === PaintMode.CirclePaint || diff --git a/src/store/tools/polygons.ts b/src/store/tools/polygons.ts index 574577282..410e3650b 100644 --- a/src/store/tools/polygons.ts +++ b/src/store/tools/polygons.ts @@ -11,7 +11,12 @@ import { Manifest, StateFile } from '@/src/io/state-file/schema'; import { getPlaneTransforms } from '@/src/utils/frameOfReference'; import { ToolID } from '@/src/types/annotation-tool'; import { defineAnnotationToolStore } from '@/src/utils/defineAnnotationToolStore'; -import { useAnnotationTool } from './useAnnotationTool'; +import { + declareAnnotationToolManifestRefs, + useAnnotationTool, +} from './useAnnotationTool'; + +declareAnnotationToolManifestRefs('polygons'); const toolDefaults = () => ({ points: [] as Array, diff --git a/src/store/tools/rectangles.ts b/src/store/tools/rectangles.ts index ddf934b1b..24d028e68 100644 --- a/src/store/tools/rectangles.ts +++ b/src/store/tools/rectangles.ts @@ -4,7 +4,12 @@ import { Manifest, StateFile } from '@/src/io/state-file/schema'; import { RECTANGLE_LABEL_DEFAULTS } from '@/src/config'; import { ToolID } from '@/src/types/annotation-tool'; -import { useAnnotationTool } from './useAnnotationTool'; +import { + declareAnnotationToolManifestRefs, + useAnnotationTool, +} from './useAnnotationTool'; + +declareAnnotationToolManifestRefs('rectangles'); const rectangleDefaults = () => ({ firstPoint: [0, 0, 0] as Vector3, diff --git a/src/store/tools/rulers.ts b/src/store/tools/rulers.ts index b752e2d94..cf02ca1a6 100644 --- a/src/store/tools/rulers.ts +++ b/src/store/tools/rulers.ts @@ -7,7 +7,12 @@ import { ToolID } from '@/src/types/annotation-tool'; import { RULER_LABEL_DEFAULTS } from '@/src/config'; import { Manifest, StateFile } from '@/src/io/state-file/schema'; -import { useAnnotationTool } from './useAnnotationTool'; +import { + declareAnnotationToolManifestRefs, + useAnnotationTool, +} from './useAnnotationTool'; + +declareAnnotationToolManifestRefs('rulers'); const rulerDefaults = () => ({ firstPoint: [0, 0, 0] as Vector3, diff --git a/src/store/tools/useAnnotationTool.ts b/src/store/tools/useAnnotationTool.ts index 73d314fce..d9a348913 100644 --- a/src/store/tools/useAnnotationTool.ts +++ b/src/store/tools/useAnnotationTool.ts @@ -5,8 +5,10 @@ import { STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT, TOOL_COLORS, } from '@/src/config'; -import { removeFromArray } from '@/src/utils'; +import { isRecord, removeFromArray } from '@/src/utils'; import { useCurrentImage } from '@/src/composables/useCurrentImage'; +import { onImageDeleted } from '@/src/composables/onImageDeleted'; +import { declareManifestRefs } from '@/src/core/manifestRefs'; import { AnnotationTool, ToolID } from '@/src/types/annotation-tool'; import { useIdStore } from '@/src/store/id'; import { useToolSelectionStore } from '@/src/store/tools/toolSelection'; @@ -14,6 +16,29 @@ import type { IToolStore } from '@/src/store/tools/types'; import { applyLocator } from '@/src/core/annotations/locator'; import { useLabels, type Labels } from './useLabels'; +// Shared manifest-ref declaration for the annotation-tool stores. Each store +// calls this at module scope next to its serialize, pairing the dev-backstop +// coverage with the onImageDeleted cascade this composable registers. +export const declareAnnotationToolManifestRefs = ( + key: 'rulers' | 'rectangles' | 'polygons' +) => + declareManifestRefs(`tools.${key}`, (manifest) => { + const tools = isRecord(manifest.tools) ? manifest.tools : {}; + const section = tools[key]; + if (!isRecord(section) || !Array.isArray(section.tools)) return []; + return section.tools.flatMap((entry, index) => + isRecord(entry) && typeof entry.imageID === 'string' + ? [ + { + kind: 'dataset' as const, + id: entry.imageID, + where: `tools.${key}[${index}].imageID`, + }, + ] + : [] + ); + }); + const annotationToolLabelDefault = Object.freeze({ strokeWidth: STROKE_WIDTH_ANNOTATION_TOOL_DEFAULT as number, }); @@ -115,6 +140,17 @@ export const useAnnotationTool = < toolByID.value[id] = { ...toolByID.value[id], ...patch, id }; } + // Delete-base cleanup: a removed image's tools + // must not linger — they are invisible in the UI (tool lists filter to the + // current image) and an orphaned imageID in the next save manifest is the + // backend's intentional fail-closed 400. Mirrors the segment-group cascade. + onImageDeleted((deletedIDs) => { + const deleted = new Set(deletedIDs); + toolIDs.value + .filter((id) => deleted.has(toolByID.value[id].imageID)) + .forEach((id) => removeTool(id)); + }); + // updates props controlled by labels watch(labels.labels, () => { toolIDs.value.forEach((id) => { diff --git a/src/store/views.ts b/src/store/views.ts index 5a38b117d..ec6b1ea73 100644 --- a/src/store/views.ts +++ b/src/store/views.ts @@ -11,6 +11,45 @@ import { type LayoutConfig, } from '@/src/utils/layoutParsing'; import type { Manifest, StateFile } from '../io/state-file/schema'; +import { onImageDeleted } from '@/src/composables/onImageDeleted'; +import { declareManifestRefs } from '@/src/core/manifestRefs'; +import { isRecord } from '@/src/utils'; + +// The manifest references this store's remove cascade keeps clean (see the +// onImageDeleted registration below), declared for the dev-only save backstop. +declareManifestRefs('views', (manifest) => { + const views = isRecord(manifest.viewByID) ? manifest.viewByID : {}; + return [ + ...Object.entries(views).flatMap(([id, raw]) => + isRecord(raw) && typeof raw.dataID === 'string' + ? [ + { + kind: 'dataset' as const, + id: raw.dataID, + where: `viewByID[${id}].dataID`, + }, + ] + : [] + ), + ...(typeof manifest.activeView === 'string' + ? [ + { + kind: 'view' as const, + id: manifest.activeView, + where: 'activeView', + }, + ] + : []), + ...(Array.isArray(manifest.layoutSlots) + ? manifest.layoutSlots + : [] + ).flatMap((id) => + typeof id === 'string' + ? [{ kind: 'view' as const, id, where: 'layoutSlots' }] + : [] + ), + ]; +}); const DEFAULT_VIEW_INIT: ViewInfoInit = { type: '2D', @@ -284,10 +323,11 @@ export const useViewStore = defineStore('view', () => { } function removeDataFromViews(dataID: string) { - layoutSlots.value.forEach((id) => { - if (viewByID[id].dataID === dataID) { - setDataForView(id, null); - } + // Every `viewByID` entry is serialized (not just the ones currently in a + // layout slot), so a view preserved off-slot with a stale dataID would + // still dangle in the save manifest — unbind ALL matching views. + getViewsForData(dataID).forEach((view) => { + setDataForView(view.id, null); }); } @@ -368,6 +408,10 @@ export const useViewStore = defineStore('view', () => { applyDisabledViewTypesFilter(); }); + onImageDeleted((deletedIDs) => { + deletedIDs.forEach((id) => removeDataFromViews(id)); + }); + return { visibleLayout: computed(() => { if (maximizedView.value) diff --git a/src/utils/__tests__/fetch.spec.ts b/src/utils/__tests__/fetch.spec.ts new file mode 100644 index 000000000..0537edd0a --- /dev/null +++ b/src/utils/__tests__/fetch.spec.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { $fetch, setGlobalHeader, deleteGlobalHeader } from '@/src/utils/fetch'; +import { RequestPool } from '@/src/core/streaming/requestPool'; + +describe('$fetch global-header merging', () => { + let fetchStub: ReturnType; + + beforeEach(() => { + fetchStub = vi.fn().mockResolvedValue(new Response('ok')); + vi.stubGlobal('fetch', fetchStub); + setGlobalHeader('Authorization', 'Bearer global'); + }); + + afterEach(() => { + deleteGlobalHeader('Authorization'); + vi.unstubAllGlobals(); + }); + + const capturedHeaders = (call = 0): Headers => + new Headers((fetchStub.mock.calls[call][1] as RequestInit).headers); + + const sameOrigin = (path: string) => `${window.location.origin}${path}`; + + it('attaches the global bearer to a same-origin request', async () => { + await $fetch(sameOrigin('/api/x')); + expect(capturedHeaders().get('Authorization')).toBe('Bearer global'); + }); + + it('attaches the global bearer to a cross-origin request (token=/tokenUrl= deployments)', async () => { + // Hosted instances point urls=/save= at other origins and authenticate + // them with the launch token — the bearer rides on every request. + await $fetch('https://data.example/volume'); + expect(capturedHeaders().get('Authorization')).toBe('Bearer global'); + }); + + it('keeps both Authorization and Range on a streaming request', async () => { + await $fetch(sameOrigin('/api/x'), { headers: { Range: 'bytes=10-' } }); + const headers = capturedHeaders(); + expect(headers.get('Authorization')).toBe('Bearer global'); + expect(headers.get('Range')).toBe('bytes=10-'); + }); + + it('layers precedence globalHeaders < Request.headers < RequestInit.headers', async () => { + const request = new Request(sameOrigin('/api/x'), { + headers: { 'X-Foo': 'from-request', Authorization: 'Bearer request' }, + }); + await $fetch(request, { headers: { 'X-Foo': 'from-init' } }); + const headers = capturedHeaders(); + expect(headers.get('X-Foo')).toBe('from-init'); + expect(headers.get('Authorization')).toBe('Bearer request'); + }); +}); + +describe('RequestPool default fetch is the authenticated $fetch', () => { + let fetchStub: ReturnType; + + beforeEach(() => { + fetchStub = vi.fn().mockResolvedValue(new Response('ok')); + vi.stubGlobal('fetch', fetchStub); + setGlobalHeader('Authorization', 'Bearer pool'); + }); + + afterEach(() => { + deleteGlobalHeader('Authorization'); + vi.unstubAllGlobals(); + }); + + it('routes a default-pool request through $fetch so the bearer is attached', async () => { + // No custom fetchFn: the singleton/default path the streaming importers use. + const pool = new RequestPool(); + await pool.fetch(`${window.location.origin}/api/stream`); + const init = fetchStub.mock.calls[0][1] as RequestInit; + expect(new Headers(init.headers).get('Authorization')).toBe('Bearer pool'); + }); +}); diff --git a/src/utils/__tests__/token.spec.ts b/src/utils/__tests__/token.spec.ts new file mode 100644 index 000000000..69631fc41 --- /dev/null +++ b/src/utils/__tests__/token.spec.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const extractURLParameters = vi.fn(); +vi.mock('@kitware/vtk.js/Common/Core/URLExtract', () => ({ + default: { extractURLParameters: () => extractURLParameters() }, +})); + +import { populateAuthorizationToken } from '@/src/utils/token'; +import { globalHeaders, deleteGlobalHeader } from '@/src/utils/fetch'; + +describe('populateAuthorizationToken', () => { + let fetchStub: ReturnType; + + beforeEach(() => { + fetchStub = vi.fn(); + vi.stubGlobal('fetch', fetchStub); + vi.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + deleteGlobalHeader('Authorization'); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + const bearer = () => globalHeaders.get('Authorization'); + + it('sets the bearer synchronously from token=', async () => { + extractURLParameters.mockReturnValue({ token: 'abc' }); + + await populateAuthorizationToken(); + + expect(bearer()).toBe('Bearer abc'); + expect(fetchStub).not.toHaveBeenCalled(); + }); + + // Regression: the 2xx check was `status % 100 !== 2`, which takes the last + // two digits — it rejected every ordinary 200 and accepted 202/302/502. + it.each([200, 201, 204])('accepts a %i token response', async (status) => { + extractURLParameters.mockReturnValue({ + tokenUrl: 'https://example.com/userToken', + }); + fetchStub.mockResolvedValue(new Response('tok', { status })); + + await populateAuthorizationToken(); + + expect(bearer()).toBe('Bearer tok'); + }); + + it.each([302, 404, 502])('rejects a %i token response', async (status) => { + extractURLParameters.mockReturnValue({ + tokenUrl: 'https://example.com/userToken', + }); + fetchStub.mockResolvedValue(new Response('nope', { status })); + + await populateAuthorizationToken(); + + expect(bearer()).toBeNull(); + }); + + it('resolves only after the tokenUrl bearer is set', async () => { + // The caller awaits this before loading, so the first data request carries + // the header rather than racing an un-awaited fetch. + extractURLParameters.mockReturnValue({ + tokenUrl: 'https://example.com/userToken', + }); + let release: (r: Response) => void = () => {}; + fetchStub.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }) + ); + + const pending = populateAuthorizationToken(); + expect(bearer()).toBeNull(); + + release(new Response('late', { status: 200 })); + await pending; + + expect(bearer()).toBe('Bearer late'); + }); + + it('uses tokenUrlMethod when given', async () => { + extractURLParameters.mockReturnValue({ + tokenUrl: 'https://example.com/userToken', + tokenUrlMethod: 'POST', + }); + fetchStub.mockResolvedValue(new Response('tok', { status: 200 })); + + await populateAuthorizationToken(); + + expect(fetchStub).toHaveBeenCalledWith('https://example.com/userToken', { + method: 'POST', + }); + }); + + it('continues without a bearer when the token fetch fails', async () => { + extractURLParameters.mockReturnValue({ + tokenUrl: 'https://example.com/userToken', + }); + fetchStub.mockRejectedValue(new Error('network down')); + + await expect(populateAuthorizationToken()).resolves.toBeUndefined(); + + expect(bearer()).toBeNull(); + }); +}); diff --git a/src/utils/fetch.ts b/src/utils/fetch.ts index dbaeb60ad..a730bf550 100644 --- a/src/utils/fetch.ts +++ b/src/utils/fetch.ts @@ -27,14 +27,19 @@ function mergeHeaders(base: Headers, supplementInit?: HeadersInit) { return merged; } +/** + * The authenticated browser HTTP primitive: merges every global header + * (bearer/auth) into the request, honoring precedence + * `globalHeaders < Request.headers < RequestInit.headers`. + */ export const $fetch: typeof fetch = ( input: RequestInfo | URL, init?: RequestInit ): Promise => { - return fetch(input, { - ...init, - headers: mergeHeaders(globalHeaders, init?.headers), - }); + let headers = new Headers(globalHeaders); + if (input instanceof Request) headers = mergeHeaders(headers, input.headers); + headers = mergeHeaders(headers, init?.headers); + return fetch(input, { ...init, headers }); }; /** diff --git a/src/utils/index.ts b/src/utils/index.ts index eb1140850..c722704f3 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -50,6 +50,9 @@ export function clampValue(value: number, min: number, max: number) { return Math.min(max, Math.max(min, value)); } +export const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + export function pick(obj: T, ...keys: K[]): Pick { return keys.reduce((o, k) => ({ ...o, [k]: obj[k] }), {} as Pick); } @@ -110,7 +113,7 @@ export const chunk = (arr: T[], size: number) => ); export function plural(n: number, word: string, pluralWord?: string) { - return n > 1 ? (pluralWord ?? `${word}s`) : word; + return n === 1 ? word : (pluralWord ?? `${word}s`); } export const ensureDefault = ( diff --git a/src/utils/path.ts b/src/utils/path.ts index 0ddd6dbcf..f1f8307c3 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -11,11 +11,14 @@ export function dirname(path: string) { /** * Returns the base name of a path. + * + * Splits on both forward and backslashes so a Windows-style path or zip entry + * name yields its final segment too. * @param path * @returns */ export function basename(path: string) { - return path.split(/\/+/g).at(-1) ?? path; + return path.split(/[\\/]+/g).at(-1) ?? path; } /** diff --git a/src/utils/token.ts b/src/utils/token.ts index a98793db1..15be438f9 100644 --- a/src/utils/token.ts +++ b/src/utils/token.ts @@ -10,7 +10,11 @@ export function stripTokenFromUrl() { window.history.replaceState(null, '', url.toString()); } -export function populateAuthorizationToken() { +// Awaited by the caller before any data request goes out: a `tokenUrl=` token +// that lands after loading has started would leave the first requests +// unauthenticated. A failure is non-fatal — the app continues without a bearer +// and the data requests fail on their own terms. +export async function populateAuthorizationToken() { const urlParams = vtkURLExtract.extractURLParameters() as UrlParams; if (urlParams.token) { @@ -18,20 +22,16 @@ export function populateAuthorizationToken() { } if (urlParams.tokenUrl) { - fetch(String(urlParams.tokenUrl), { - method: String(urlParams.tokenUrlMethod || 'GET'), - }) - .then((response) => { - if (response.status % 100 !== 2) { - throw new Error('received non-200 response'); - } - return response.text(); - }) - .then((text) => { - setGlobalHeader('Authorization', `Bearer ${text}`); - }) - .catch((err) => { - console.error('error while fetching token from tokenUrl:', err); + try { + const response = await fetch(String(urlParams.tokenUrl), { + method: String(urlParams.tokenUrlMethod || 'GET'), }); + if (!response.ok) { + throw new Error(`received ${response.status} response`); + } + setGlobalHeader('Authorization', `Bearer ${await response.text()}`); + } catch (err) { + console.error('error while fetching token from tokenUrl:', err); + } } } diff --git a/src/utils/urlParams.ts b/src/utils/urlParams.ts index 3146ad619..01b4ca8af 100644 --- a/src/utils/urlParams.ts +++ b/src/utils/urlParams.ts @@ -1,6 +1,12 @@ import { UrlParams } from '@vueuse/core'; +import vtkURLExtract from '@kitware/vtk.js/Common/Core/URLExtract'; import { logError } from '@/src/utils/loggers'; +// This module owns the tab's launch params (`urls=`, `names=`, `config=`, +// `save=`): both READING them at boot (readLaunchParams) and REWRITING them +// after a remote save (repointLaunchUrls). Keeping both sides here means the +// stale-`names=` interaction below stays next to the parsing it protects. + type ParsedUrlParams = { urls?: string[]; names?: string[]; @@ -81,3 +87,30 @@ export const normalizeUrlParams = (rawParams: UrlParams): ParsedUrlParams => { return normalized; }; + +// The current tab's launch params. Unparseable params degrade to an empty +// launch (logged), never a failed boot. +export const readLaunchParams = (): ParsedUrlParams => { + try { + return normalizeUrlParams( + vtkURLExtract.extractURLParameters() as UrlParams + ); + } catch (error) { + logError(new Error(`Failed to parse URL parameters: ${error}`)); + return {}; + } +}; + +// On a successful remote save the backend returns `resumeUrl` — the saved +// session's load URL. Repoint ONLY the tab's `urls=` at it (so a future F5 +// reloads the just-made save instead of the fresh launch manifest), via +// `history.replaceState` (no reload). `save=` and `config=` are untouched: +// every save keeps going to the launch-provided target. +export const repointLaunchUrls = (resumeUrl: string) => { + const url = new URL(window.location.toString()); + url.searchParams.set('urls', resumeUrl); + // A stale names= would rename the session zip after the original data file, + // and filename-extension typing would then misparse the zip on reload. + url.searchParams.delete('names'); + window.history.replaceState(null, '', url.toString()); +}; diff --git a/tsconfig.json b/tsconfig.json index d336fd871..12fb2fac0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -39,6 +39,7 @@ "src/**/*.ts", "src/**/*.tsx", "src/**/*.vue", + "backend-contract/**/*.ts", "test/**/*.ts", "tests/**/*.ts", "tests/**/*.tsx" diff --git a/vite.config.ts b/vite.config.ts index b1a86bbe1..d585bae48 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -225,6 +225,9 @@ export default defineConfig({ // so `npm run test:e2e:dev` can access the webdriver static server temp directory proxy: { '/tmp': config.baseUrl!, + // Local Girder stack, so girder-launched sessions (urls=/api/v1/...) + // work same-origin against the dev server. + '/api': 'http://localhost:8080', }, }, optimizeDeps: {